diff --git a/background.js b/background.js index 7cc7b40..51e174b 100644 --- a/background.js +++ b/background.js @@ -7,9 +7,73 @@ import { Question } from './src/Annotation.js'; import { ExportSessionCSV } from './src/ExportSessionCSV.js'; import { JSonSessionService } from './src/JSonSessionService.js'; import { getSystemInfo } from './src/browserInfo.js'; +import { GoogleDriveService } from './src/GoogleDriveService.js'; let session = new Session(); +// --- Google Drive state --- +const driveService = new GoogleDriveService(); +let driveAutoSave = false; +let driveFileId = null; +let driveSyncStatus = 'idle'; // idle | syncing | synced | error +let syncTimeout = null; + +async function loadDriveSettings() { + const data = await chrome.storage.local.get(['driveAutoSave', 'driveFileId']); + driveAutoSave = data.driveAutoSave || false; + driveFileId = data.driveFileId || null; + + // Try to restore token silently if auto-save was on + if (driveAutoSave) { + try { + await driveService.getToken(); + } catch (e) { + driveAutoSave = false; + await chrome.storage.local.set({ driveAutoSave: false }); + } + } +} + +async function saveDriveSettings() { + await chrome.storage.local.set({ driveAutoSave, driveFileId }); +} + +function scheduleDriveSync() { + if (!driveAutoSave || !driveService.isAuthenticated()) return; + if (syncTimeout) clearTimeout(syncTimeout); + syncTimeout = setTimeout(() => syncToDrive(), 1500); +} + +async function syncToDrive() { + if (!driveService.isAuthenticated()) return; + if (session.getAnnotations().length === 0) return; + + driveSyncStatus = 'syncing'; + try { + const jsonService = new JSonSessionService(); + const sessionJson = jsonService.getJSon(session); + const fileName = getDriveFileName(); + + const result = await driveService.uploadSession(sessionJson, fileName, driveFileId); + driveFileId = result.id; + driveSyncStatus = 'synced'; + await saveDriveSettings(); + } catch (error) { + console.error('Drive sync failed:', error); + driveSyncStatus = 'error'; + } +} + +function getDriveFileName() { + const date = new Date(session.getStartDateTime()); + const startDateTime = date.getFullYear() + + ('0' + (date.getMonth() + 1)).slice(-2) + + ('0' + date.getDate()).slice(-2) + '_' + + ('0' + date.getHours()).slice(-2) + + ('0' + date.getMinutes()).slice(-2); + return `ExploratorySession_${startDateTime}.json`; +} + // Función para guardar la sesión en el storage async function saveSession() { try { @@ -78,6 +142,9 @@ async function saveSession() { throw error; } } + + // Trigger Drive auto-sync after local save + scheduleDriveSync(); } // Función para cargar la sesión desde el storage @@ -113,8 +180,9 @@ async function loadSession() { } } -// Cargar la sesión al iniciar +// Cargar la sesión y configuración de Drive al iniciar loadSession(); +loadDriveSettings(); // Helper function for notifications of processing errors (before addAnnotation is called) function notifyProcessingError(annotationType, descriptionName, errorMessage = "") { @@ -328,6 +396,105 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { })) }); break; + + // --- Google Drive handlers --- + case "driveConnect": + driveService.authenticate() + .then(() => { + sendResponse({ status: "ok" }); + }) + .catch(error => { + sendResponse({ status: "error", error: error.message }); + }); + isAsync = true; + break; + + case "driveDisconnect": + driveService.disconnect() + .then(async () => { + driveAutoSave = false; + driveFileId = null; + driveSyncStatus = 'idle'; + await saveDriveSettings(); + sendResponse({ status: "ok" }); + }) + .catch(error => { + sendResponse({ status: "error", error: error.message }); + }); + isAsync = true; + break; + + case "driveGetStatus": + sendResponse({ + connected: driveService.isAuthenticated(), + autoSave: driveAutoSave, + syncStatus: driveSyncStatus, + fileId: driveFileId + }); + break; + + case "driveSetAutoSave": + driveAutoSave = request.enabled; + saveDriveSettings().then(() => { + sendResponse({ status: "ok", autoSave: driveAutoSave }); + }); + isAsync = true; + break; + + case "driveSaveNow": + syncToDrive() + .then(() => { + sendResponse({ status: "ok", syncStatus: driveSyncStatus, fileId: driveFileId }); + }) + .catch(error => { + sendResponse({ status: "error", error: error.message, syncStatus: driveSyncStatus }); + }); + isAsync = true; + break; + + case "driveListSessions": + driveService.listSessions() + .then(files => { + sendResponse({ status: "ok", files }); + }) + .catch(error => { + sendResponse({ status: "error", error: error.message }); + }); + isAsync = true; + break; + + case "driveLoadSession": + driveService.downloadSession(request.fileId) + .then(jsonData => { + if (importSessionJSon(jsonData)) { + driveFileId = request.fileId; + return saveDriveSettings().then(() => saveSession()).then(() => { + sendResponse({ status: "ok" }); + }); + } else { + sendResponse({ status: "error", error: "Invalid session data" }); + } + }) + .catch(error => { + sendResponse({ status: "error", error: error.message }); + }); + isAsync = true; + break; + + case "driveDeleteSession": + driveService.deleteSession(request.fileId) + .then(() => { + if (driveFileId === request.fileId) { + driveFileId = null; + saveDriveSettings(); + } + sendResponse({ status: "ok" }); + }) + .catch(error => { + sendResponse({ status: "error", error: error.message }); + }); + isAsync = true; + break; } return isAsync; // Return true only if sendResponse is used asynchronously in any of the handled cases. }); @@ -511,6 +678,8 @@ async function startSession() { async function clearSession() { session.clearAnnotations(); + driveFileId = null; + await saveDriveSettings(); await saveSession(); } diff --git a/css/popUp.css b/css/popUp.css index 9a71962..bf802de 100644 --- a/css/popUp.css +++ b/css/popUp.css @@ -546,4 +546,325 @@ button input.file-input { #resetNo.cancelButton:active { transform: translateY(0); +} + +/* ============================================ + Google Drive Section + ============================================ */ +.drive-section { + margin-top: 10px; + padding: 10px 8px 8px; + border-top: 1px solid var(--color-border); +} + +.drive-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.drive-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.drive-status { + font-size: 0.7rem; + font-weight: 500; + padding: 2px 8px; + border-radius: 10px; +} + +.drive-status-synced { + color: #16a34a; + background-color: #f0fdf4; +} + +.drive-status-syncing { + color: var(--color-primary); + background-color: #eef2ff; +} + +.drive-status-error { + color: var(--color-bug); + background-color: var(--color-bug-light); +} + +/* Connect button */ +.drive-connect-btn { + width: 100%; + background: linear-gradient(135deg, #4285f4 0%, #34a853 100%); + color: #ffffff; + border: none; + border-radius: var(--radius-md); + padding: 8px 16px; + font-weight: 500; + font-size: 0.85rem; + cursor: pointer; + transition: all var(--transition); +} + +.drive-connect-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(66, 133, 244, 0.3); + color: #ffffff; +} + +.drive-connect-btn:active { + transform: translateY(0); +} + +.drive-connect-btn:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; +} + +/* Connected controls row */ +.drive-controls { + display: flex; + align-items: center; + gap: 6px; +} + +/* Auto-save toggle */ +.drive-autosave-toggle { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + margin: 0; + flex-shrink: 0; +} + +.drive-autosave-toggle input[type="checkbox"] { + display: none; +} + +.drive-toggle-slider { + width: 32px; + height: 18px; + background-color: #cbd5e1; + border-radius: 9px; + position: relative; + transition: background-color var(--transition); +} + +.drive-toggle-slider::after { + content: ''; + position: absolute; + width: 14px; + height: 14px; + border-radius: 50%; + background-color: #ffffff; + top: 2px; + left: 2px; + transition: transform var(--transition); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); +} + +.drive-autosave-toggle input:checked+.drive-toggle-slider { + background-color: #4285f4; +} + +.drive-autosave-toggle input:checked+.drive-toggle-slider::after { + transform: translateX(14px); +} + +.drive-toggle-label { + font-size: 0.7rem; + color: var(--color-text-secondary); + font-weight: 500; +} + +/* Drive action buttons (save/load) */ +.drive-action-btn { + background-repeat: no-repeat; + background-size: 18px 18px; + background-position: center; + min-width: 34px; + min-height: 34px; + background-color: var(--color-surface); + border: 1.5px solid var(--color-border); + border-radius: var(--radius-md); + transition: all var(--transition); + cursor: pointer; + padding: 0; +} + +.drive-action-btn:hover { + border-color: #4285f4; + transform: translateY(-1px); + box-shadow: 0 3px 8px rgba(66, 133, 244, 0.15); +} + +.drive-action-btn:active { + transform: translateY(0); +} + +.drive-action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +#driveSaveBtn { + background-image: url("../images/cloud-upload.svg"); +} + +#driveLoadBtn.drive-load-icon { + background-image: url("../images/cloud-download.svg"); +} + +/* Disconnect button */ +.drive-disconnect-btn { + min-width: 28px; + min-height: 28px; + padding: 0; + margin-left: auto; + background-color: transparent; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + color: var(--color-text-secondary); + font-size: 1rem; + line-height: 1; + cursor: pointer; + transition: all var(--transition); +} + +.drive-disconnect-btn:hover { + background-color: var(--color-bug-light); + border-color: var(--color-bug); + color: var(--color-bug); +} + +/* Load from Drive panel */ +#driveLoadPanel { + margin-top: 8px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + overflow: hidden; +} + +.drive-load-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + background-color: #f8fafc; + border-bottom: 1px solid var(--color-border); + font-size: 0.8rem; + font-weight: 600; + color: var(--color-text); +} + +.drive-load-close { + background: none; + border: none; + font-size: 1.1rem; + color: var(--color-text-secondary); + cursor: pointer; + padding: 0 4px; + line-height: 1; +} + +.drive-load-close:hover { + color: var(--color-bug); +} + +.drive-file-list { + max-height: 200px; + overflow-y: auto; +} + +.drive-loading, +.drive-empty { + padding: 16px; + text-align: center; + font-size: 0.8rem; + color: var(--color-text-secondary); +} + +.drive-file-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + border-bottom: 1px solid #f1f5f9; + transition: background-color var(--transition); +} + +.drive-file-item:last-child { + border-bottom: none; +} + +.drive-file-item:hover { + background-color: #f8fafc; +} + +.drive-file-info { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; +} + +.drive-file-name { + font-size: 0.78rem; + font-weight: 500; + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.drive-file-date { + font-size: 0.68rem; + color: var(--color-text-secondary); +} + +.drive-file-actions { + display: flex; + gap: 4px; + flex-shrink: 0; + margin-left: 8px; +} + +.drive-file-load { + font-size: 0.7rem; + padding: 3px 10px; + border: 1px solid #4285f4; + border-radius: var(--radius-sm); + background-color: #ffffff; + color: #4285f4; + cursor: pointer; + font-weight: 500; + transition: all var(--transition); +} + +.drive-file-load:hover { + background-color: #4285f4; + color: #ffffff; +} + +.drive-file-delete { + font-size: 0.9rem; + padding: 1px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background-color: #ffffff; + color: var(--color-text-secondary); + cursor: pointer; + line-height: 1; + transition: all var(--transition); +} + +.drive-file-delete:hover { + background-color: var(--color-bug-light); + border-color: var(--color-bug); + color: var(--color-bug); } \ No newline at end of file diff --git a/images/cloud-download.svg b/images/cloud-download.svg new file mode 100644 index 0000000..ed5e03c --- /dev/null +++ b/images/cloud-download.svg @@ -0,0 +1,4 @@ + + diff --git a/images/cloud-upload.svg b/images/cloud-upload.svg new file mode 100644 index 0000000..5a8ad23 --- /dev/null +++ b/images/cloud-upload.svg @@ -0,0 +1,4 @@ + + diff --git a/images/gdrive.svg b/images/gdrive.svg new file mode 100644 index 0000000..789354a --- /dev/null +++ b/images/gdrive.svg @@ -0,0 +1,5 @@ + + diff --git a/jest.setup.js b/jest.setup.js index 382f5cf..d87fa0a 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -2,6 +2,7 @@ global.chrome = { runtime: { getManifest: () => ({ version: '1.0.0' }), // Basic mock + lastError: null, // Add other chrome.runtime APIs if needed by tests }, // Mock other chrome.* APIs as necessary @@ -16,6 +17,10 @@ global.chrome = { tabs: { query: jest.fn((queryInfo, callback) => callback([{ id: 1, url: 'http://example.com' }])), // Add other chrome.tabs APIs if needed + }, + identity: { + getAuthToken: jest.fn((options, callback) => callback('mock-token-123')), + removeCachedAuthToken: jest.fn((details, callback) => callback()), } // Add more chrome API mocks as identified during testing }; diff --git a/js/popup.js b/js/popup.js index 1b56e6e..b327c17 100644 --- a/js/popup.js +++ b/js/popup.js @@ -12,7 +12,9 @@ window.onload = function () { function initElements() { annotationListeners(); exportListeners(); + driveListeners(); updateCounters(); + updateDriveUI(); registerPopupMessageListener(); // Added listener registration $(function () { $('[data-toggle="tooltip"]').tooltip() @@ -480,3 +482,179 @@ document.addEventListener('DOMContentLoaded', function () { $("#resetConfirmation").slideUp(); }); }); + +/* ============================================ + Google Drive Integration + ============================================ */ + +function driveListeners() { + $(document).on('click', '#driveConnectBtn', driveConnect); + $(document).on('click', '#driveDisconnectBtn', driveDisconnect); + $(document).on('click', '#driveSaveBtn', driveSaveNow); + $(document).on('click', '#driveLoadBtn', driveShowLoadPanel); + $(document).on('click', '#driveLoadClose', driveHideLoadPanel); + $(document).on('change', '#driveAutoSaveToggle', driveToggleAutoSave); +} + +function updateDriveUI() { + chrome.runtime.sendMessage({ type: "driveGetStatus" }, function (response) { + if (chrome.runtime.lastError || !response) return; + + if (response.connected) { + $("#driveDisconnected").hide(); + $("#driveConnected").show(); + $("#driveAutoSaveToggle").prop('checked', response.autoSave); + updateDriveSyncStatusUI(response.syncStatus); + } else { + $("#driveDisconnected").show(); + $("#driveConnected").hide(); + $("#driveSyncStatus").text("").removeClass(); + $("#driveSyncStatus").addClass("drive-status"); + } + }); +} + +function updateDriveSyncStatusUI(status) { + var $el = $("#driveSyncStatus"); + $el.removeClass("drive-status-synced drive-status-syncing drive-status-error"); + + switch (status) { + case "synced": + $el.text("Synced").addClass("drive-status-synced"); + break; + case "syncing": + $el.text("Syncing...").addClass("drive-status-syncing"); + break; + case "error": + $el.text("Error").addClass("drive-status-error"); + break; + default: + $el.text(""); + break; + } +} + +function driveConnect() { + $("#driveConnectBtn").prop('disabled', true).text("Connecting..."); + chrome.runtime.sendMessage({ type: "driveConnect" }, function (response) { + $("#driveConnectBtn").prop('disabled', false).text("Connect"); + if (response && response.status === "ok") { + updateDriveUI(); + } else { + var errorMsg = response && response.error ? response.error : "Connection failed"; + console.error("Drive connect failed:", errorMsg); + } + }); +} + +function driveDisconnect() { + chrome.runtime.sendMessage({ type: "driveDisconnect" }, function (response) { + updateDriveUI(); + driveHideLoadPanel(); + }); +} + +function driveToggleAutoSave() { + var enabled = $("#driveAutoSaveToggle").is(':checked'); + chrome.runtime.sendMessage({ type: "driveSetAutoSave", enabled: enabled }, function (response) { + if (response && response.status === "ok") { + // If enabling auto-save, trigger an immediate sync + if (enabled) { + driveSaveNow(); + } + } + }); +} + +function driveSaveNow() { + updateDriveSyncStatusUI("syncing"); + $("#driveSaveBtn").prop('disabled', true); + chrome.runtime.sendMessage({ type: "driveSaveNow" }, function (response) { + $("#driveSaveBtn").prop('disabled', false); + if (response) { + updateDriveSyncStatusUI(response.syncStatus || "error"); + } + }); +} + +function driveShowLoadPanel() { + $("#driveLoadPanel").slideDown(); + $("#driveFileList").html('