From 5a181039a491a733a367b4ef789951248d545b39 Mon Sep 17 00:00:00 2001 From: regeter <2320305+regeter@users.noreply.github.com> Date: Sat, 7 Mar 2026 12:27:29 -0800 Subject: [PATCH] feat(datasets): implement google sheets deep linking and dataset copy/paste --- src/App.js | 196 ++++++++++++++++++++++++++++++++++---- src/DatasetLoading.js | 9 +- src/DatasetSideLoading.js | 21 +++- src/GoogleSheets.js | 4 +- 4 files changed, 204 insertions(+), 26 deletions(-) diff --git a/src/App.js b/src/App.js index dda415e..d7879c1 100644 --- a/src/App.js +++ b/src/App.js @@ -18,7 +18,13 @@ import { saveDatasetAsJson, saveToIndexedDB, } from "./localStorage"; -import { exportToGoogleSheet, requestSheetsToken } from "./GoogleSheets"; +import { + exportToGoogleSheet, + requestSheetsToken, + extractSpreadsheetId, + importFromGoogleSheet, + getApiType, +} from "./GoogleSheets"; import _ from "lodash"; import { getQueryStringValue, setQueryStringValue } from "./queryString"; import "./global.css"; @@ -177,6 +183,16 @@ class App extends React.Component { this.updateMapAndAssociatedData(); }); + const pendingSheetId = getQueryStringValue("sheetId"); + if (pendingSheetId) { + // Remove the sheetId from the URL to prevent reload loops + const url = new URL(window.location.href); + url.searchParams.delete("sheetId"); + window.history.replaceState({}, document.title, url.toString()); + + this.loadSharedSheet(pendingSheetId); + } + // Initialize the Broadcast Channel this.syncChannel = new BroadcastChannel("app_playback_sync"); this.syncChannel.onmessage = this.handleSyncMessage; @@ -203,6 +219,50 @@ class App extends React.Component { } }; + loadSharedSheet = async (sheetId) => { + this.setState({ pendingSheetId: null }); + try { + toast.info("Loading Google Sheet..."); + const token = await requestSheetsToken(); + const logs = await importFromGoogleSheet(sheetId, token); + + const logsWithTimestamp = logs.map((logEntry) => ({ + ...logEntry, + timestamp: logEntry.timestamp || new Date().toISOString(), + })); + + await uploadCloudLogs(logsWithTimestamp, "_clipboard"); + + toast.success("Dataset from Google Sheet loaded. You can now Paste into any dataset.", { autoClose: false }); + } catch (error) { + log(`Error loading shared sheet: ${error.message}`, error); + + // If the browser blocks the popup (often happens on page load without user gesture) + if (error.message && error.message.includes("Failed to open login popup window")) { + toast.warning( +
+ Popup blocked by browser. +
+
+ +
, + { autoClose: false, closeOnClick: false } + ); + } else { + toast.error(`Failed to load shared sheet: ${error.message}`); + } + } + }; + updateToggleState(newValue, toggleName, jsonPaths) { this.setState((prevState) => { const newToggleOptions = { @@ -587,12 +647,16 @@ class App extends React.Component { try { const token = await requestSheetsToken(); const sheetUrl = await exportToGoogleSheet(index, token); + const spreadsheetId = extractSpreadsheetId(sheetUrl); + const deepLink = `${window.location.origin}${window.location.pathname}?sheetId=${spreadsheetId}`; toast.success( Exported to{" "} Google Sheet +
+ Shareable Fleet Debugger URL {deepLink}
, { autoClose: false } ); @@ -602,6 +666,71 @@ class App extends React.Component { } }; + const handleCopyClick = async (e) => { + e.stopPropagation(); + log(`Copy dataset ${index} initiated`); + this.setState({ activeMenuIndex: null }); + + try { + const data = await getUploadedData(index); + if (!data || !data.rawLogs || data.rawLogs.length === 0) { + toast.warning("Dataset is empty, nothing to copy."); + return; + } + + let logsToCopy = data.rawLogs; + const { logTypes } = this.state.filters; + const totalFilterCount = Object.keys(logTypes).length; + const activeFilterCount = Object.values(logTypes).filter(Boolean).length; + + if (activeFilterCount !== totalFilterCount) { + if (window.confirm("Do you want to copy only the currently filtered log types?")) { + logsToCopy = data.rawLogs.filter((logEntry) => { + const apiType = getApiType(logEntry); + return logTypes[apiType] !== false; + }); + } + } + + const dataToSave = { ...data, rawLogs: logsToCopy }; + await saveToIndexedDB(dataToSave, "_clipboard"); + toast.success(`Dataset ${index + 1} copied to clipboard!`); + } catch (error) { + log(`Error copying dataset: ${error.message}`, error); + toast.error(`Error copying dataset: ${error.message}`); + } + }; + + const handlePasteClick = async (e) => { + e.stopPropagation(); + log(`Paste to dataset ${index} initiated`); + this.setState({ activeMenuIndex: null }); + + try { + const clipboardData = await getUploadedData("_clipboard"); + if (!clipboardData || !clipboardData.rawLogs) { + toast.warning("Clipboard is empty."); + return; + } + await saveToIndexedDB(clipboardData, index); + toast.success(`Pasted into Dataset ${index + 1}!`); + + this.setState( + (prevState) => { + const newUploadedDatasets = [...prevState.uploadedDatasets]; + newUploadedDatasets[index] = "Uploaded"; + return { uploadedDatasets: newUploadedDatasets }; + }, + () => { + this.switchDataset(index); + } + ); + } catch (error) { + log(`Error pasting dataset: ${error.message}`, error); + toast.error(`Error pasting dataset: ${error.message}`); + } + }; + const handlePruneClick = async (e) => { e.stopPropagation(); log(`Prune initiated for dataset ${index}`); @@ -680,6 +809,25 @@ class App extends React.Component { } try { + if (result.pasteClipboard) { + const clipboardData = await getUploadedData("_clipboard"); + if (!clipboardData || !clipboardData.rawLogs) { + toast.warning("Clipboard is empty."); + return; + } + await saveToIndexedDB(clipboardData, index); + toast.success(`Pasted into Dataset ${index + 1}!`); + this.setState( + (prevState) => { + const newUploadedDatasets = [...prevState.uploadedDatasets]; + newUploadedDatasets[index] = "Uploaded"; + return { uploadedDatasets: newUploadedDatasets }; + }, + () => this.switchDataset(index) + ); + return; + } + if (result.file) { const uploadEvent = { target: { files: [result.file] } }; await this.handleFileUpload(uploadEvent, index); @@ -706,7 +854,9 @@ class App extends React.Component { newUploadedDatasets[index] = "Uploaded"; return { uploadedDatasets: newUploadedDatasets }; }, - () => this.switchDataset(index) + () => { + this.switchDataset(index); + } ); } } catch (error) { @@ -748,29 +898,34 @@ class App extends React.Component { isActive ? "dataset-button-active" : isUploaded ? "dataset-button-uploaded" : "dataset-button-empty" }`} > - {isUploaded ? `Dataset ${index + 1}` : `Select Dataset ${index + 1}`} - + {isUploaded ? `Dataset ${index + 1}` : `Select Data ${index + 1}`} {isUploaded && isActive && ( ▼ - {isMenuOpen && ( -
-
- Export File -
-
- Export GSheet -
-
- Prune -
-
- Delete -
-
- )}
)} + {isMenuOpen && ( +
+
+ Paste Dataset +
+
+ Copy Dataset +
+
+ Export File +
+
+ Export GSheet +
+
+ Prune +
+
+ Delete +
+
+ )} ); @@ -858,6 +1013,7 @@ class App extends React.Component { onLogsReceived: handleCloudLogsReceived, onExtraLogsReceived: handleExtraLogsReceived, onFileUpload: handleFileUpload, + onPasteClipboard: () => cleanupAndResolve({ pasteClipboard: true }), hasExtraDataSource: HAS_EXTRA_DATA_SOURCE, }); dialogRoot.render(datasetLoadingComponent); diff --git a/src/DatasetLoading.js b/src/DatasetLoading.js index 8504811..ecde73a 100644 --- a/src/DatasetLoading.js +++ b/src/DatasetLoading.js @@ -8,7 +8,7 @@ import { isTokenValid, fetchLogsWithToken, useCloudLoggingLogin, buildQueryFilte import DatasetSideLoading from "./DatasetSideLoading"; import { GOOGLE_CLIENT_ID } from "./constants"; -const CloudLoggingFormComponent = ({ onLogsReceived, onFileUpload }) => { +const CloudLoggingFormComponent = ({ onLogsReceived, onFileUpload, onPasteClipboard }) => { const getStoredValue = (key, defaultValue = "") => localStorage.getItem(`datasetLoading_${key}`) || defaultValue; const [fetching, setFetching] = useState(false); @@ -162,7 +162,12 @@ const CloudLoggingFormComponent = ({ onLogsReceived, onFileUpload }) => { )} - + diff --git a/src/DatasetSideLoading.js b/src/DatasetSideLoading.js index e8dafed..dc92ffb 100644 --- a/src/DatasetSideLoading.js +++ b/src/DatasetSideLoading.js @@ -1,7 +1,8 @@ // src/DatasetSideLoading.js -import { useState } from "react"; +import { useState, useEffect } from "react"; import { toast } from "react-toastify"; import { useSheetsLogin, isSheetsTokenValid, getSheetsToken, importFromGoogleSheet } from "./GoogleSheets"; +import { getUploadedData } from "./localStorage"; import { log } from "./Utils"; /** @@ -14,10 +15,21 @@ import { log } from "./Utils"; * @param {Function} props.setLocalError Callback to set error messages in the parent. * @param {React.ReactNode} props.children The primary fetch button(s) to show alongside sideload buttons. */ -export const DatasetSideLoading = ({ onLogsReceived, onFileUpload, setLocalError, children }) => { +export const DatasetSideLoading = ({ onLogsReceived, onFileUpload, setLocalError, onPasteClipboard, children }) => { const [sheetFormVisible, setSheetFormVisible] = useState(false); const [sheetUrl, setSheetUrl] = useState(localStorage.getItem("datasetLoading_sheetUrl") || ""); const [sheetLoading, setSheetLoading] = useState(false); + const [hasClipboard, setHasClipboard] = useState(false); + + useEffect(() => { + getUploadedData("_clipboard") + .then((data) => { + if (data && data.rawLogs && data.rawLogs.length > 0) { + setHasClipboard(true); + } + }) + .catch(() => {}); + }, []); const handleSheetImport = (token) => { setSheetLoading(true); @@ -69,6 +81,11 @@ export const DatasetSideLoading = ({ onLogsReceived, onFileUpload, setLocalError <>
{children} + {onPasteClipboard && hasClipboard && ( + + )} diff --git a/src/GoogleSheets.js b/src/GoogleSheets.js index 33bcd53..562e934 100644 --- a/src/GoogleSheets.js +++ b/src/GoogleSheets.js @@ -25,7 +25,7 @@ const API_TYPE_REGEX_MAP = [ { name: "updateTask", regex: /updateTask/i }, ]; -function getApiType(logEntry) { +export function getApiType(logEntry) { const typeField = logEntry["@type"] || logEntry.jsonpayload?.["@type"] || ""; for (const { name, regex } of API_TYPE_REGEX_MAP) { if (regex.test(typeField)) return name; @@ -266,7 +266,7 @@ export async function exportToGoogleSheet(index, token) { // --- Import --- -function extractSpreadsheetId(input) { +export function extractSpreadsheetId(input) { const trimmed = input.trim(); const urlMatch = trimmed.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/); if (urlMatch) return urlMatch[1];