Enhances Google Gemini with a configurable toolbar and sidebar folders to organize conversations.
// ==UserScript== // @name Google Gemini Mod (Toolbar, Folders & Download) // @namespace http://tampermonkey.net/ // @version 0.0.23 // @description Enhances Google Gemini with a configurable toolbar and sidebar folders to organize conversations. // @description[de] Verbessert Google Gemini mit einer konfigurierbaren Symbolleiste und Ordnern in der Seitenleiste, um Konversationen zu organisieren. // @author Adromir // @license MIT // @match https://gemini.google.com/* // @icon https://raw.githubusercontent.com/adromir/google-gemini-mod/refs/heads/main/icon.svg // @supportURL https://github.com/adromir/scripts/issues // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_xmlhttpRequest // @grant unsafeWindow // @require https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js // @require https://cdn.jsdelivr.net/gh/adromir/scripts@ef4eeb9853f8d32d5cff2f37133fe8bddfb19972/userscripts/gemini-snippets/gemini_mod_styles.js#sha256-bXQDm5Zj7+t4jYaFvTGvx/jUgo08EQmLdHve9CIRVoQ= // @require https://cdn.jsdelivr.net/gh/adromir/scripts@bba070d4de424d81a5d6df9211b28492553726f5/userscripts/gemini-snippets/gemini_mod_utils.js // @require https://cdn.jsdelivr.net/gh/adromir/scripts@ef4eeb9853f8d32d5cff2f37133fe8bddfb19972/userscripts/gemini-snippets/gemini_mod_drive.js#sha256-Sf+ByWwt60J4l8gAbxP7jzBd004RVfvwpWhkeyWUcmg= // ==/UserScript== (function () { 'use strict'; // Ensure Namespace exists window.GeminiMod = window.GeminiMod || {}; // =================================================================================== // I. CONFIGURATION SECTION // =================================================================================== // --- Storage Keys --- const STORAGE_KEY_TOOLBAR_ITEMS = "geminiModToolbarItems_v2"; const STORAGE_KEY_FOLDERS = 'gemini_folders'; const STORAGE_KEY_CONVO_FOLDERS = 'gemini_convo_folders'; const STORAGE_KEY_GDRIVE_TOKEN = 'gemini_gdrive_token'; // Kept for reference, used by Drive module const STORAGE_KEY_GDRIVE_CLIENT_ID = 'gemini_gdrive_client_id'; // Kept for reference, used by Drive module // --- Toolbar UI Labels --- const SETTINGS_BUTTON_LABEL = "⚙️ Settings"; // --- CSS Selectors --- const GEMINI_CODE_CANVAS_TITLE_SELECTOR = "code-immersive-panel h2.title-text"; const GEMINI_CODE_CANVAS_PANEL_SELECTOR = 'code-immersive-panel'; const GEMINI_CODE_CANVAS_SHARE_BUTTON_SELECTOR = "toolbar div.action-buttons share-button > button"; const GEMINI_CODE_CANVAS_COPY_BUTTON_SELECTOR = "copy-button[data-test-id='copy-button'] > button.copy-button"; const GEMINI_DOC_CANVAS_PANEL_SELECTOR = "immersive-panel"; const GEMINI_DOC_CANVAS_EDITOR_SELECTOR = ".ProseMirror"; const GEMINI_DOC_CANVAS_TITLE_SELECTOR = "h2.title-text"; const GEMINI_INPUT_FIELD_SELECTORS = ['div[role="textbox"]', '.ql-editor p', '.ql-editor', 'div[contenteditable="true"]']; const FOLDER_CHAT_ITEM_SELECTOR = 'div[data-test-id="conversation"]'; const FOLDER_CHAT_CONTAINER_SELECTOR = '.conversation-items-container'; const FOLDER_CHAT_LIST_CONTAINER_SELECTOR = 'conversations-list .conversations-container'; const FOLDER_INJECTION_POINT_SELECTOR = 'div.chat-history-list'; // --- Download Feature Configuration --- const DEFAULT_DOWNLOAD_EXTENSION = "txt"; // --- Filename Sanitization Regex --- // eslint-disable-next-line no-control-regex const INVALID_FILENAME_CHARS_REGEX = /[<>:"/\\|?*\x00-\x1F]/g; const RESERVED_WINDOWS_NAMES_REGEX = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i; const FILENAME_WITH_EXT_REGEX = /^(.+)\.([a-zA-Z0-9]{1,8})$/; // =================================================================================== // II. DEFAULT DEFINITIONS (Used if no custom config is saved) // =================================================================================== const defaultToolbarItems = [ { type: 'button', label: "Greeting", text: "Hello Gemini!" }, { type: 'button', label: "Explain", text: "Could you please explain ... in more detail?" }, { type: 'dropdown', placeholder: "Actions...", options: [ { label: "Summarize", text: "Please summarize the following text:\n" }, { label: "Ideas", text: "Give me 5 ideas for ..." }, ] }, { type: 'action', action: 'paste', label: "📋 Paste", title: "Paste from Clipboard" }, { type: 'action', action: 'copy', label: "📄 Copy", title: "Copy active canvas content" }, { type: 'action', action: 'download', label: "💾 Download", title: "Download active canvas content" }, { type: 'action', action: 'pdf', label: "📑 PDF", title: "Export active canvas content as PDF" } ]; // =================================================================================== // III. SCRIPT LOGIC // =================================================================================== // --- Global State --- let toolbarItems = []; let folders = []; let conversationFolders = {}; const FOLDER_COLORS = ['#370000', '#0D3800', '#001B38', '#383200', '#380031', '#7DAC89', '#7A82AF', '#AC7D98', '#7AA7AF', '#9CA881']; // --- Core Aliases (Helpers) --- const displayMessage = GeminiMod.utils.displayUserscriptMessage; const clearEl = GeminiMod.utils.clearElement; const showConfirm = GeminiMod.utils.showConfirmationDialog; const showPrompt = GeminiMod.utils.showCustomPromptDialog; const showColorPicker = GeminiMod.utils.showColorPickerDialog; const injectCSS = GeminiMod.utils.injectCustomCSS; const getReactProps = GeminiMod.utils.getReactProps; const getClassProperty = GeminiMod.utils.getClassProperty; // --- Text Insertion Logic --- function findTargetInputElement() { for (const selector of GEMINI_INPUT_FIELD_SELECTORS) { const element = document.querySelector(selector); if (element) { return element.classList.contains('ql-editor') ? (element.querySelector('p') || element) : element; } } return null; } function insertSnippetText(textToInsert) { const target = findTargetInputElement(); if (!target) { displayMessage("Could not find Gemini input field."); return; } target.focus(); // Force cursor to the end if no valid selection exists within the target const selection = window.getSelection(); if (selection.rangeCount === 0 || !target.contains(selection.anchorNode)) { const range = document.createRange(); range.selectNodeContents(target); range.collapse(false); // Collapse to end selection.removeAllRanges(); selection.addRange(range); } setTimeout(() => { try { document.execCommand('insertText', false, textToInsert); } catch (e) { console.warn("Gemini Mod: execCommand failed, falling back to textContent.", e); target.textContent += textToInsert; } target.dispatchEvent(new Event('input', { bubbles: true, cancelable: true })); }, 50); } // --- Configuration Management --- async function loadConfiguration() { try { // Toolbar items const savedToolbarItems = await GM_getValue(STORAGE_KEY_TOOLBAR_ITEMS); if (savedToolbarItems) { toolbarItems = JSON.parse(savedToolbarItems); // Migration: Check if actions are missing and append them if so (for existing users) const hasAction = (act) => toolbarItems.some(item => item.type === 'action' && item.action === act); if (!hasAction('paste')) toolbarItems.push({ type: 'action', action: 'paste', label: "📋 Paste", title: "Paste from Clipboard" }); if (!hasAction('copy')) toolbarItems.push({ type: 'action', action: 'copy', label: "📄 Copy", title: "Copy active canvas content" }); if (!hasAction('download')) toolbarItems.push({ type: 'action', action: 'download', label: "💾 Download", title: "Download active canvas content" }); if (!hasAction('pdf')) toolbarItems.push({ type: 'action', action: 'pdf', label: "📑 PDF", title: "Export active canvas content as PDF" }); } else { toolbarItems = defaultToolbarItems; } // Folder items folders = await GM_getValue(STORAGE_KEY_FOLDERS, []); conversationFolders = await GM_getValue(STORAGE_KEY_CONVO_FOLDERS, {}); } catch (e) { console.error("Gemini Mod: Error loading configuration, using defaults.", e); toolbarItems = defaultToolbarItems; folders = []; conversationFolders = {}; } } async function saveToolbarConfiguration() { const settingsPanel = document.getElementById('gemini-mod-settings-panel'); if (!settingsPanel) return; const newItems = []; settingsPanel.querySelectorAll('#toolbar-items-container > .item-group').forEach(group => { const type = group.dataset.type; const visible = group.querySelector('.visible-checkbox').checked; if (type === 'button') { const label = group.querySelector('.label-input').value.trim(); const text = group.querySelector('.text-input').value; if (label) newItems.push({ type, label, text, visible }); } else if (type === 'dropdown') { const placeholder = group.querySelector('.placeholder-input').value.trim(); const options = []; group.querySelectorAll('.option-item').forEach(opt => { const label = opt.querySelector('.label-input').value.trim(); const text = opt.querySelector('.text-input').value; if (label) options.push({ label, text }); }); if (placeholder && options.length > 0) { newItems.push({ type, placeholder, options, visible }); } } else if (type === 'action') { const action = group.dataset.action; const label = group.querySelector('.label-input').value.trim(); const title = group.dataset.title; if (label) newItems.push({ type, action, label, title, visible }); } }); try { await GM_setValue(STORAGE_KEY_TOOLBAR_ITEMS, JSON.stringify(newItems)); await loadConfiguration(); // Reload all configs rebuildToolbar(); toggleSettingsPanel(false); } catch (e) { displayMessage("Failed to save settings. See console for details."); console.error("Gemini Mod: Error saving settings:", e); } } async function saveFolderConfiguration() { await GM_setValue(STORAGE_KEY_FOLDERS, folders); await GM_setValue(STORAGE_KEY_CONVO_FOLDERS, conversationFolders); } // --- Setup Guide Modal --- function showSetupGuide() { const overlay = document.createElement('div'); overlay.className = 'custom-dialog-overlay'; overlay.id = 'setup-guide-overlay'; const dialogBox = document.createElement('div'); dialogBox.className = 'custom-dialog-box'; dialogBox.style.maxWidth = '600px'; const h3 = document.createElement('h3'); h3.textContent = 'Google Drive Sync Setup'; dialogBox.appendChild(h3); const p1 = document.createElement('p'); p1.innerHTML = ''; // Clear just in case, though new element is empty p1.appendChild(document.createTextNode('To sync explicitly via Google Drive, you need a ')); const b1 = document.createElement('b'); b1.textContent = 'Google Cloud Client ID'; p1.appendChild(b1); p1.appendChild(document.createTextNode('. This is required because this script runs privately in your browser.')); dialogBox.appendChild(p1); const ol = document.createElement('ol'); const steps = [ { html: false, text: 'Go to ', link: { href: 'https://console.cloud.google.com/apis/credentials', text: 'Google Cloud Console' } }, { html: false, text: 'Create a new project (or use existing).' }, { html: false, parts: [{ text: 'Enable the ' }, { tag: 'b', text: 'Google Drive API' }, { text: '.' }] }, { html: false, text: 'Create Credentials -> OAuth client ID.' }, { html: false, parts: [{ text: 'Application type: ' }, { tag: 'b', text: 'Web application' }, { text: '.' }] }, { html: false, parts: [{ text: 'Add authorized origins: ' }, { tag: 'code', text: 'https://gemini.google.com' }] }, { html: false, parts: [{ text: 'Copy the ' }, { tag: 'b', text: 'Client ID' }, { text: ' and paste it in the settings here.' }] } ]; const createStep = (step) => { const li = document.createElement('li'); if (step.link) { li.appendChild(document.createTextNode(step.text)); const a = document.createElement('a'); a.href = step.link.href; a.target = '_blank'; a.textContent = step.link.text; li.appendChild(a); li.appendChild(document.createTextNode('.')); } else if (step.parts) { step.parts.forEach(part => { if (part.tag) { const tag = document.createElement(part.tag); tag.textContent = part.text; li.appendChild(tag); } else { li.appendChild(document.createTextNode(part.text)); } }); } else { li.textContent = step.text; } return li; }; steps.forEach(step => ol.appendChild(createStep(step))); dialogBox.appendChild(ol); const p2 = document.createElement('p'); const i = document.createElement('i'); i.appendChild(document.createTextNode('Alternatively, use the ')); const b2 = document.createElement('b'); b2.textContent = 'File Backup'; i.appendChild(b2); i.appendChild(document.createTextNode(' option below to save/restore manually without setup.')); p2.appendChild(i); dialogBox.appendChild(p2); const closeBtn = document.createElement('button'); closeBtn.className = 'custom-dialog-btn dialog-btn-cancel'; closeBtn.textContent = 'Close'; closeBtn.onclick = () => overlay.remove(); closeBtn.style.marginTop = '20px'; dialogBox.appendChild(closeBtn); overlay.appendChild(dialogBox); document.body.appendChild(overlay); } // --- Toolbar Creation --- function createToolbar() { const toolbarId = 'gemini-snippet-toolbar-userscript'; let toolbar = document.getElementById(toolbarId); if (toolbar) { clearEl(toolbar); } else { toolbar = document.createElement('div'); toolbar.id = toolbarId; document.body.insertBefore(toolbar, document.body.firstChild); } toolbarItems.forEach(item => { if (item.visible === false) return; // Skip hidden items if (item.type === 'button') { const button = document.createElement('button'); button.textContent = item.label; button.title = item.text; button.addEventListener('click', () => insertSnippetText(item.text)); toolbar.appendChild(button); } else if (item.type === 'dropdown') { const select = document.createElement('select'); select.title = item.placeholder; const defaultOption = new Option(item.placeholder, "", true, true); defaultOption.disabled = true; select.appendChild(defaultOption); item.options.forEach(opt => select.appendChild(new Option(opt.label, opt.text))); select.addEventListener('change', (e) => { if (e.target.value) { insertSnippetText(e.target.value); e.target.selectedIndex = 0; } }); toolbar.appendChild(select); } else if (item.type === 'action') { const button = document.createElement('button'); button.textContent = item.label; button.title = item.title; if (item.action === 'paste') { button.addEventListener('click', async () => { try { const text = await navigator.clipboard.readText(); if (text) insertSnippetText(text); } catch (err) { displayMessage('Failed to read clipboard: ' + err.message); } }); } else if (item.action === 'download') { button.addEventListener('click', handleGlobalCanvasDownload); } else if (item.action === 'pdf') { button.addEventListener('click', handlePDFExport); } else if (item.action === 'copy') { button.addEventListener('click', handleCopy); } toolbar.appendChild(button); } }); const spacer = document.createElement('div'); spacer.className = 'userscript-toolbar-spacer'; toolbar.appendChild(spacer); const settingsButton = document.createElement('button'); settingsButton.textContent = SETTINGS_BUTTON_LABEL; settingsButton.title = "Open Userscript Settings"; settingsButton.addEventListener('click', () => toggleSettingsPanel()); toolbar.appendChild(settingsButton); } function rebuildToolbar() { const toolbar = document.getElementById('gemini-snippet-toolbar-userscript'); if (toolbar) createToolbar(); } // --- Settings Panel --- function toggleSettingsPanel(show = true) { let overlay = document.getElementById('gemini-mod-settings-overlay'); let panel = document.getElementById('gemini-mod-settings-panel'); if (!overlay) { createSettingsPanel(); overlay = document.getElementById('gemini-mod-settings-overlay'); panel = document.getElementById('gemini-mod-settings-panel'); } if (show) { populateSettingsPanel(panel); overlay.style.display = 'block'; } else { overlay.style.display = 'none'; } } async function updateSettingsPanelDriveStatus() { const statusText = document.getElementById('gdrive-status-text'); const connectBtn = document.getElementById('gdrive-connect-btn'); const saveBtn = document.getElementById('gdrive-save-btn'); const loadBtn = document.getElementById('gdrive-load-btn'); const backupContainer = document.getElementById('gdrive-backup-container'); if (statusText && connectBtn) { const token = await GeminiMod.drive.getGoogleDriveToken(); if (token) { statusText.textContent = "Status: Connected ✅"; statusText.style.color = "#8ab4f8"; connectBtn.style.display = 'none'; backupContainer.style.display = 'block'; } else { statusText.textContent = "Status: Not Connected"; statusText.style.color = "#aaa"; connectBtn.style.display = 'inline-block'; backupContainer.style.display = 'none'; } } } function createSettingsPanel() { if (document.getElementById('gemini-mod-settings-overlay')) return; const overlay = document.createElement('div'); overlay.id = 'gemini-mod-settings-overlay'; const panel = document.createElement('div'); panel.id = 'gemini-mod-settings-panel'; overlay.appendChild(panel); panel.appendChild(document.createElement('h2')).textContent = 'Gemini Mod Settings'; // Create Container for Tabbed Layout const container = document.createElement('div'); container.className = 'settings-container'; // --- SIDEBAR --- const sidebar = document.createElement('div'); sidebar.className = 'settings-sidebar'; const tabs = [ { id: 'tab-toolbar', label: '🛠️ Toolbar' }, { id: 'tab-drive', label: '☁️ Google Drive' }, { id: 'tab-reset', label: '⚠️ Danger Zone' } ]; tabs.forEach((tab, index) => { const btn = document.createElement('button'); btn.className = 'tab-btn' + (index === 0 ? ' active' : ''); btn.textContent = tab.label; btn.dataset.target = tab.id; btn.onclick = () => { document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); btn.classList.add('active'); document.getElementById(tab.id).classList.add('active'); }; sidebar.appendChild(btn); }); // Footer Buttons Container const sidebarFooter = document.createElement('div'); sidebarFooter.style.marginTop = 'auto'; // Pushes to bottom sidebarFooter.style.display = 'flex'; sidebarFooter.style.flexDirection = 'column'; sidebarFooter.style.gap = '10px'; sidebarFooter.style.width = '100%'; // Close Button const closeButton = document.createElement('button'); closeButton.textContent = 'Close'; closeButton.className = 'custom-dialog-btn dialog-btn-cancel'; closeButton.style.width = '100%'; closeButton.style.boxSizing = 'border-box'; // Ensure padding handles correctly closeButton.style.margin = '0'; // Override default class margin closeButton.addEventListener('click', () => toggleSettingsPanel(false)); sidebarFooter.appendChild(closeButton); // Save Button const saveBtnClose = document.createElement('button'); saveBtnClose.textContent = 'Save & Close'; saveBtnClose.className = 'custom-dialog-btn dialog-btn-confirm'; saveBtnClose.style.width = '100%'; saveBtnClose.style.boxSizing = 'border-box'; saveBtnClose.style.margin = '0'; // Override default class margin saveBtnClose.addEventListener('click', saveToolbarConfiguration); // Logic handles closing sidebarFooter.appendChild(saveBtnClose); sidebar.appendChild(sidebarFooter); container.appendChild(sidebar); // Content Area const content = document.createElement('div'); content.className = 'settings-content'; // --- TAB 1: TOOLBAR --- const tabToolbar = document.createElement('div'); tabToolbar.id = 'tab-toolbar'; tabToolbar.className = 'tab-pane active'; // Add Item Button const addItemBtn = document.createElement('button'); addItemBtn.textContent = '+ Add New Item'; addItemBtn.className = 'custom-dialog-btn dialog-btn-confirm'; addItemBtn.style.marginBottom = '20px'; addItemBtn.addEventListener('click', showAddItemModal); tabToolbar.appendChild(addItemBtn); const itemsContainer = document.createElement('div'); itemsContainer.id = 'toolbar-items-container'; tabToolbar.appendChild(itemsContainer); content.appendChild(tabToolbar); // --- TAB 2: GOOGLE DRIVE --- const tabDrive = document.createElement('div'); tabDrive.id = 'tab-drive'; tabDrive.className = 'tab-pane'; const driveHeader = document.createElement('h3'); driveHeader.textContent = 'Google Drive Sync'; driveHeader.style.marginTop = '0'; // Help Icon/Button const helpBtn = document.createElement('button'); helpBtn.textContent = '📖'; // Book icon helpBtn.title = "Show Setup Instructions"; helpBtn.style.marginLeft = '10px'; helpBtn.style.background = 'transparent'; helpBtn.style.border = '1px solid #5f6368'; helpBtn.onclick = showSetupGuide; driveHeader.appendChild(helpBtn); tabDrive.appendChild(driveHeader); // Client ID Input const clientIdLabel = document.createElement('label'); clientIdLabel.textContent = "Google Cloud Client ID:"; tabDrive.appendChild(clientIdLabel); const clientIdContainer = document.createElement('div'); clientIdContainer.style.display = 'flex'; clientIdContainer.style.alignItems = 'center'; clientIdContainer.style.gap = '5px'; const clientIdInput = document.createElement('input'); clientIdInput.id = 'gdrive-client-id-input'; clientIdInput.type = 'password'; clientIdInput.placeholder = "Enter your OAuth 2.0 Client ID"; clientIdInput.style.flexGrow = '1'; clientIdInput.value = ""; // Will be populated // Toggle Visibility const toggleVisBtn = document.createElement('button'); toggleVisBtn.textContent = '👁️'; toggleVisBtn.title = "Toggle Visibility"; toggleVisBtn.onclick = () => { clientIdInput.type = clientIdInput.type === 'password' ? 'text' : 'password'; }; // Help Link const helpLink = document.createElement('a'); helpLink.href = "https://console.cloud.google.com/apis/credentials"; helpLink.target = "_blank"; helpLink.textContent = "❓ Get ID"; helpLink.className = 'help-link'; clientIdContainer.appendChild(clientIdInput); clientIdContainer.appendChild(toggleVisBtn); clientIdContainer.appendChild(helpLink); tabDrive.appendChild(clientIdContainer); const saveClientIdBtn = document.createElement('button'); saveClientIdBtn.textContent = "Save Client ID"; saveClientIdBtn.style.marginTop = "10px"; saveClientIdBtn.addEventListener('click', async () => { const val = clientIdInput.value.trim(); if (val) { await GM_setValue(STORAGE_KEY_GDRIVE_CLIENT_ID, val); displayMessage("Client ID saved!", false); updateSettingsPanelDriveStatus(); } else { displayMessage("Please enter a Client ID."); } }); tabDrive.appendChild(saveClientIdBtn); // Connection Status const statusText = document.createElement('p'); statusText.id = 'gdrive-status-text'; statusText.textContent = "Status: Checking..."; statusText.style.marginTop = "20px"; statusText.style.fontWeight = "bold"; tabDrive.appendChild(statusText); // Connect Button const connectBtn = document.createElement('button'); connectBtn.id = 'gdrive-connect-btn'; connectBtn.textContent = "Connect Google Drive"; connectBtn.className = 'custom-dialog-btn dialog-btn-confirm'; connectBtn.style.display = 'none'; connectBtn.addEventListener('click', () => GeminiMod.drive.initiateGoogleDriveAuth()); tabDrive.appendChild(connectBtn); // Backup Controls (Hidden until connected) const backupContainer = document.createElement('div'); backupContainer.id = 'gdrive-backup-container'; backupContainer.style.display = 'none'; backupContainer.style.marginTop = '15px'; backupContainer.style.borderTop = '1px solid #444'; backupContainer.style.paddingTop = '15px'; const backupTitle = document.createElement('h4'); backupTitle.textContent = "Synchronization"; backupTitle.style.marginTop = '0'; backupContainer.appendChild(backupTitle); const saveBtn = document.createElement('button'); saveBtn.id = 'gdrive-save-btn'; saveBtn.textContent = "☁️ Save to Drive"; saveBtn.className = 'custom-dialog-btn'; saveBtn.style.marginRight = '10px'; saveBtn.title = "Overwrite the backup file on Google Drive with current settings"; saveBtn.onclick = () => { GeminiMod.drive.saveToDrive({ toolbarItems, folders, conversationFolders }); }; backupContainer.appendChild(saveBtn); const loadBtn = document.createElement('button'); loadBtn.id = 'gdrive-load-btn'; loadBtn.textContent = "☁️ Load from Drive"; loadBtn.className = 'custom-dialog-btn'; loadBtn.title = "Overwrite local settings with data from Google Drive"; loadBtn.onclick = () => { GeminiMod.drive.loadFromDrive(async (data) => { if (data && data.toolbarItems && data.folders) { await GM_setValue(STORAGE_KEY_TOOLBAR_ITEMS, JSON.stringify(data.toolbarItems)); await GM_setValue(STORAGE_KEY_FOLDERS, data.folders); await GM_setValue(STORAGE_KEY_CONVO_FOLDERS, data.conversationFolders || {}); displayMessage("Settings loaded from Drive! Reloading page...", false); setTimeout(() => location.reload(), 1500); } else { displayMessage("Invalid file format downloaded from Drive."); } }); }; backupContainer.appendChild(loadBtn); tabDrive.appendChild(backupContainer); // --- Manual Backup Section --- const manualBackupHeader = document.createElement('h3'); manualBackupHeader.textContent = 'Manual File Backup'; tabDrive.appendChild(manualBackupHeader); const manualDesc = document.createElement('p'); manualDesc.textContent = "No setup required. Save your settings to a local file."; manualDesc.style.fontSize = '0.9em'; manualDesc.style.color = '#aaa'; tabDrive.appendChild(manualDesc); const exportBtn = document.createElement('button'); exportBtn.textContent = "⬇️ Export to File"; exportBtn.className = 'custom-dialog-btn'; exportBtn.style.marginRight = '10px'; exportBtn.onclick = () => GeminiMod.drive.exportSettingsToFile({ toolbarItems, folders, conversationFolders }); tabDrive.appendChild(exportBtn); const importInput = document.createElement('input'); importInput.type = 'file'; importInput.accept = '.json'; importInput.style.display = 'none'; importInput.onchange = (e) => { if (e.target.files.length > 0) { GeminiMod.drive.importSettingsFromFile(e.target.files[0], async (data) => { await GM_setValue(STORAGE_KEY_TOOLBAR_ITEMS, JSON.stringify(data.toolbarItems)); await GM_setValue(STORAGE_KEY_FOLDERS, data.folders); await GM_setValue(STORAGE_KEY_CONVO_FOLDERS, data.conversationFolders || {}); if (data.gdriveClientId) await GM_setValue(STORAGE_KEY_GDRIVE_CLIENT_ID, data.gdriveClientId); displayMessage("Settings imported successfully! Reloading...", false); setTimeout(() => location.reload(), 1500); }); } }; const importBtn = document.createElement('button'); importBtn.textContent = "⬆️ Import from File"; importBtn.className = 'custom-dialog-btn'; importBtn.onclick = () => importInput.click(); tabDrive.appendChild(importBtn); tabDrive.appendChild(importInput); content.appendChild(tabDrive); // --- TAB 3: RESET --- const tabReset = document.createElement('div'); tabReset.id = 'tab-reset'; tabReset.className = 'tab-pane'; tabReset.appendChild(document.createElement('h3')).textContent = 'Danger Zone'; const resetFoldersBtn = document.createElement('button'); resetFoldersBtn.textContent = 'Reset Folders Only'; resetFoldersBtn.className = 'custom-dialog-btn dialog-btn-delete'; resetFoldersBtn.style.display = 'block'; resetFoldersBtn.style.marginBottom = '15px'; resetFoldersBtn.addEventListener('click', () => { showConfirm("Are you sure you want to delete all folders?", async () => { await GM_deleteValue(STORAGE_KEY_FOLDERS); await GM_deleteValue(STORAGE_KEY_CONVO_FOLDERS); location.reload(); }, "Delete Folders", "dialog-btn-delete"); }); tabReset.appendChild(resetFoldersBtn); const resetAllBtn = document.createElement('button'); resetAllBtn.textContent = 'Reset EVERYTHING (Factory Reset)'; resetAllBtn.className = 'custom-dialog-btn dialog-btn-delete'; resetAllBtn.style.backgroundColor = '#cc2929'; // Redder resetAllBtn.addEventListener('click', () => { showConfirm("Are you sure? This will execute a full factory reset of the userscript, including Toolbar items, Folders, and Google Drive connection.", async () => { await GM_deleteValue(STORAGE_KEY_TOOLBAR_ITEMS); await GM_deleteValue(STORAGE_KEY_FOLDERS); await GM_deleteValue(STORAGE_KEY_CONVO_FOLDERS); await GM_deleteValue(STORAGE_KEY_GDRIVE_TOKEN); await GM_deleteValue(STORAGE_KEY_GDRIVE_CLIENT_ID); location.reload(); }, "FACTORY RESET", "dialog-btn-delete"); }); tabReset.appendChild(resetAllBtn); content.appendChild(tabReset); container.appendChild(content); panel.appendChild(container); document.body.appendChild(overlay); // Initialize Sortable on the items container new Sortable(itemsContainer, { animation: 150, handle: '.item-group', ghostClass: 'sortable-ghost' }); } function populateSettingsPanel(panel) { const container = panel.querySelector('#toolbar-items-container'); if (!container) return; // Should not happen clearEl(container); toolbarItems.forEach(item => addItemToPanel(container, item)); // Populate Client ID if exists const clientIdInput = document.getElementById('gdrive-client-id-input'); if (clientIdInput) { GeminiMod.drive.getGoogleDriveClientId().then(id => { if (id) clientIdInput.value = id; }); } // Update Status updateSettingsPanelDriveStatus(); } function addItemToPanel(container, item) { const group = document.createElement('div'); group.className = 'item-group'; group.dataset.type = item.type; if (item.action) group.dataset.action = item.action; if (item.title) group.dataset.title = item.title; const visibleCheck = document.createElement('input'); visibleCheck.type = 'checkbox'; visibleCheck.className = 'visible-checkbox'; visibleCheck.checked = item.visible !== false; visibleCheck.title = 'Show in toolbar'; group.appendChild(visibleCheck); const contentDiv = document.createElement('div'); contentDiv.className = 'item-content'; const typeLabel = document.createElement('strong'); typeLabel.textContent = item.type.toUpperCase() + (item.action ? ` (${item.action})` : ''); typeLabel.style.display = 'block'; typeLabel.style.marginBottom = '5px'; typeLabel.style.fontSize = '0.7em'; typeLabel.style.color = '#888'; contentDiv.appendChild(typeLabel); // Dynamic fields based on type if (item.type === 'button') { contentDiv.appendChild(createInputRow("Label:", item.label, "label-input")); contentDiv.appendChild(createInputRow("Snippet:", item.text, "text-input", "textarea")); } else if (item.type === 'dropdown') { contentDiv.appendChild(createInputRow("Placeholder:", item.placeholder, "placeholder-input")); const optionsContainer = document.createElement('div'); optionsContainer.className = 'dropdown-options-container'; optionsContainer.appendChild(document.createElement('h4')).textContent = "Options:"; item.options.forEach(opt => addOptionToContainer(optionsContainer, opt)); const addOptBtn = document.createElement('button'); addOptBtn.textContent = '+ Add Option'; addOptBtn.style.marginTop = '5px'; addOptBtn.addEventListener('click', () => addOptionToContainer(optionsContainer, { label: '', text: '' })); optionsContainer.appendChild(addOptBtn); contentDiv.appendChild(optionsContainer); } else if (item.type === 'action') { contentDiv.appendChild(createInputRow("Label:", item.label, "label-input")); const descInfo = document.createElement('div'); descInfo.style.fontSize = '0.8em'; descInfo.style.color = '#aaa'; descInfo.textContent = "Functionality is built-in."; contentDiv.appendChild(descInfo); } group.appendChild(contentDiv); // Delete Button const deleteBtn = document.createElement('button'); deleteBtn.textContent = '🗑️'; deleteBtn.className = 'remove-btn'; deleteBtn.title = 'Remove Item'; deleteBtn.addEventListener('click', () => group.remove()); group.appendChild(deleteBtn); container.appendChild(group); } function createInputRow(labelText, value, className, inputType = 'text') { const wrapper = document.createElement('div'); wrapper.style.marginBottom = '5px'; const label = document.createElement('label'); label.textContent = labelText; let input; if (inputType === 'textarea') { input = document.createElement('textarea'); } else { input = document.createElement('input'); input.type = 'text'; } input.value = value || ''; input.className = className; wrapper.appendChild(label); wrapper.appendChild(input); return wrapper; } function addOptionToContainer(container, optionData) { const row = document.createElement('div'); row.className = 'option-item'; const labelInput = document.createElement('input'); labelInput.type = 'text'; labelInput.placeholder = 'Label'; labelInput.value = optionData.label; labelInput.className = 'custom-dialog-input label-input'; labelInput.style.marginBottom = '0'; const textInput = document.createElement('textarea'); textInput.placeholder = 'Snippet Text'; textInput.value = optionData.text; textInput.className = 'custom-dialog-input text-input'; textInput.style.marginBottom = '0'; textInput.style.minHeight = '40px'; const delBtn = document.createElement('button'); delBtn.textContent = 'x'; delBtn.className = 'remove-btn'; delBtn.addEventListener('click', () => row.remove()); row.appendChild(labelInput); row.appendChild(textInput); row.appendChild(delBtn); // Insert before the "Add Option" button container.insertBefore(row, container.lastElementChild); } function showAddItemModal() { // reuse existing modal if possible or create simple one const overlay = document.createElement('div'); overlay.className = 'custom-dialog-overlay'; overlay.id = 'gemini-mod-type-modal-overlay'; const modal = document.createElement('div'); modal.id = 'gemini-mod-type-modal'; modal.className = 'custom-dialog-box'; const h3 = document.createElement('h3'); h3.textContent = 'Select Item Type'; modal.appendChild(h3); const createTypeBtn = (type, label) => { const btn = document.createElement('button'); btn.textContent = label; btn.className = 'custom-dialog-btn dialog-btn-confirm'; btn.addEventListener('click', () => { let newItem; if (type === 'button') newItem = { type: 'button', label: 'New Button', text: '' }; else if (type === 'dropdown') newItem = { type: 'dropdown', placeholder: 'Select...', options: [] }; const container = document.getElementById('toolbar-items-container'); addItemToPanel(container, newItem); document.body.removeChild(overlay); }); return btn; }; modal.appendChild(createTypeBtn('button', 'Button')); modal.appendChild(createTypeBtn('dropdown', 'Dropdown')); const cancelBtn = document.createElement('button'); cancelBtn.textContent = 'Cancel'; cancelBtn.className = 'custom-dialog-btn dialog-btn-cancel'; cancelBtn.style.marginTop = '15px'; cancelBtn.addEventListener('click', () => document.body.removeChild(overlay)); modal.appendChild(cancelBtn); overlay.appendChild(modal); document.body.appendChild(overlay); } // --- Folder Logic --- function initializeFolders() { const chatHistoryList = document.querySelector(FOLDER_INJECTION_POINT_SELECTOR); if (!chatHistoryList) return false; const foldersContainerId = 'folder-ui-container'; if (!document.getElementById(foldersContainerId)) { const container = document.createElement('div'); container.id = foldersContainerId; chatHistoryList.parentNode.insertBefore(container, chatHistoryList); renderFolders(); } // Observe chat list changes to identify new conversations const observer = new MutationObserver(() => { processConversationItems(chatHistoryList); }); observer.observe(chatHistoryList, { childList: true, subtree: true }); // Initial process processConversationItems(chatHistoryList); return true; } function renderFolders() { const container = document.getElementById('folder-ui-container'); if (!container) return; // Remove existing folder-container if exists to re-render let folderWrapper = document.getElementById('folder-container'); if (folderWrapper) folderWrapper.remove(); folderWrapper = document.createElement('div'); folderWrapper.id = 'folder-container'; container.appendChild(folderWrapper); folders.forEach(folder => { folderWrapper.appendChild(createFolderElement(folder)); }); const addBtn = document.createElement('button'); addBtn.id = 'add-folder-btn'; addBtn.textContent = '+ New Folder'; addBtn.addEventListener('click', () => { showPrompt("New Folder Name:", "", async (name) => { if (name) { folders.push({ id: Date.now().toString(), name: name, color: FOLDER_COLORS[0], isOpen: true }); await saveFolderConfiguration(); renderFolders(); } }); }); folderWrapper.appendChild(addBtn); // Initialize Sortable for folders new Sortable(folderWrapper, { animation: 150, handle: '.folder-header', onEnd: async () => { const newOrder = []; folderWrapper.querySelectorAll('.folder').forEach(el => { const id = el.dataset.id; const folder = folders.find(f => f.id === id); if (folder) newOrder.push(folder); }); folders = newOrder; await saveFolderConfiguration(); } }); } function createFolderElement(folder) { const folderDiv = document.createElement('div'); folderDiv.className = `folder ${folder.isOpen ? '' : 'closed'}`; folderDiv.dataset.id = folder.id; const header = document.createElement('div'); header.className = 'folder-header'; header.style.borderLeft = `4px solid ${folder.color}`; // Folder Icon (Open/Closed) const iconWrapper = document.createElement('div'); iconWrapper.className = 'folder-icon-wrapper'; const createIcon = (isOpen, color) => { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.classList.add("folder-icon", isOpen ? "icon-open" : "icon-closed"); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("fill", color); const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", isOpen ? "M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" : "M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10z"); svg.appendChild(path); return svg; }; iconWrapper.appendChild(createIcon(true, folder.color)); iconWrapper.appendChild(createIcon(false, folder.color)); header.appendChild(iconWrapper); const nameSpan = document.createElement('span'); nameSpan.className = 'folder-name'; nameSpan.textContent = folder.name; header.appendChild(nameSpan); const controls = document.createElement('div'); controls.className = 'folder-controls'; const settingsBtn = document.createElement('button'); settingsBtn.className = 'folder-options-btn'; settingsBtn.textContent = '⋮'; settingsBtn.title = "Folder Options"; settingsBtn.addEventListener('click', (e) => { e.stopPropagation(); showFolderContextMenu(e, folder); }); controls.appendChild(settingsBtn); const toggleIcon = document.createElement('span'); toggleIcon.className = 'folder-toggle-icon'; toggleIcon.textContent = '▼'; controls.appendChild(toggleIcon); header.appendChild(controls); header.addEventListener('click', () => { folder.isOpen = !folder.isOpen; folderDiv.classList.toggle('closed', !folder.isOpen); saveFolderConfiguration(); }); folderDiv.appendChild(header); const contentDiv = document.createElement('div'); contentDiv.className = 'folder-content'; // Populate content const convoIds = Object.keys(conversationFolders).filter(k => conversationFolders[k] === folder.id); // Note: Actual conversation DOM elements are moved here by processConversationItems logic // We set a data attributes to help the processor know this is a drop target contentDiv.dataset.folderId = folder.id; folderDiv.appendChild(contentDiv); // Initialize Sortable for dragging conversations INTO this folder new Sortable(contentDiv, { group: 'conversations', // Share group with main list if possible, or just other folders animation: 150, onAdd: async (evt) => { const item = evt.item; const convoId = getConversationId(item); if (convoId) { conversationFolders[convoId] = folder.id; await saveFolderConfiguration(); } } }); return folderDiv; } function getConversationId(element) { // Extract ID from Gemini's DOM. Needs to be robust. // Usually in the link href or data-test-id const link = element.querySelector('a'); if (link) { const match = link.href.match(/\/app\/([a-zA-Z0-9]+)/); if (match) return match[1]; } return null; } function showFolderContextMenu(e, folder) { const existingMenu = document.getElementById('folder-context-menu'); if (existingMenu) existingMenu.remove(); const menu = document.createElement('div'); menu.id = 'folder-context-menu'; menu.className = 'folder-context-menu'; const renameItem = document.createElement('div'); renameItem.className = 'folder-context-menu-item'; renameItem.textContent = '✏️ Rename'; renameItem.onclick = () => { showPrompt("Rename Folder:", folder.name, async (newName) => { folder.name = newName; await saveFolderConfiguration(); renderFolders(); }); menu.remove(); }; menu.appendChild(renameItem); const colorItem = document.createElement('div'); colorItem.className = 'folder-context-menu-item'; colorItem.textContent = '🎨 Change Color'; colorItem.onclick = () => { showColorPicker(folder.color, async (newColor) => { folder.color = newColor; await saveFolderConfiguration(); renderFolders(); }); menu.remove(); }; menu.appendChild(colorItem); const deleteItem = document.createElement('div'); deleteItem.className = 'folder-context-menu-item delete'; deleteItem.textContent = '🗑️ Delete'; deleteItem.onclick = () => { showConfirm(`Delete folder "${folder.name}"? Conversations will return to the main list.`, async () => { folders = folders.filter(f => f.id !== folder.id); // Remove folder assignments for convos in this folder Object.keys(conversationFolders).forEach(k => { if (conversationFolders[k] === folder.id) delete conversationFolders[k]; }); await saveFolderConfiguration(); renderFolders(); // Trigger reprocessing of lists to move items back processConversationItems(document.querySelector(FOLDER_INJECTION_POINT_SELECTOR)); }, "Delete", "dialog-btn-delete"); menu.remove(); }; menu.appendChild(deleteItem); document.body.appendChild(menu); menu.style.display = 'block'; menu.style.left = e.pageX + 'px'; menu.style.top = e.pageY + 'px'; const closeMenu = () => { menu.remove(); document.removeEventListener('click', closeMenu); }; setTimeout(() => document.addEventListener('click', closeMenu), 0); } function processConversationItems(chatHistoryList) { if (!chatHistoryList) return; // 1. Identify Main Conversation Container (Gemini's list) // Usually it's a specific container inside chatHistoryList const mainList = chatHistoryList.querySelector(FOLDER_CHAT_LIST_CONTAINER_SELECTOR) || chatHistoryList; // 2. Find all conversation items const items = Array.from(document.querySelectorAll(FOLDER_CHAT_ITEM_SELECTOR)).filter(el => { // Filter out items that are already inside our folders to update them if state changed, // or items in the main list. return listContains(chatHistoryList, el) || listContains(document.getElementById('folder-container'), el); }); items.forEach(item => { // Ensure it has the sortable class/structure if (!item.parentNode.classList.contains('conversation-items-container')) { // Wrap if necessary or apply class (Gemini structure varies) // Note: Gemini usually has items directly in a container. // We might need to make sure the ITEM itself is draggable. item.classList.add('conversation-items-container'); // reuse class for styling } const convoId = getConversationId(item); if (!convoId) return; const assignedFolderId = conversationFolders[convoId]; if (assignedFolderId) { // Should be in a folder const folderContent = document.querySelector(`.folder-content[data-folder-id="${assignedFolderId}"]`); if (folderContent && !folderContent.contains(item)) { folderContent.appendChild(item); } } else { // Should be in the main list // This is tricky because Gemini renders this list dynamically. // If we moved it out, we might need to move it back to a specific place or just 'unhide' it if we hid it. // For now, appending to the main chat list container if found. if (mainList && !mainList.contains(item)) { // Try to place it back roughly where it belongs by date? Hard. // Just append to top or bottom? mainList.appendChild(item); } } }); // 3. Ensure Main List is Sortable (so items can be dragged FROM it) if (mainList && !mainList.classList.contains('gemini-mod-sortable-init')) { mainList.classList.add('gemini-mod-sortable-init'); new Sortable(mainList, { group: 'conversations', animation: 150, onAdd: async (evt) => { // Item dragged BACK to main list const item = evt.item; const convoId = getConversationId(item); if (convoId && conversationFolders[convoId]) { delete conversationFolders[convoId]; await saveFolderConfiguration(); } } }); } } function listContains(list, node) { return list && list.contains(node); } // --- Core Actions (Download, PDF, Copy) --- // kept as is, but ensuring they use displayUserscriptMessage via helper function getCanvasContent() { console.log("Gemini Mod: Starting Content Extraction (Accessing via unsafeWindow)..."); // Access raw DOM via unsafeWindow const rawDoc = unsafeWindow.document; // 1. Try Monaco Editor directly via Global API (Most Robust for Code) if (unsafeWindow.monaco && unsafeWindow.monaco.editor) { console.log("Gemini Mod: Found Global Monaco API. Checking editors..."); try { const editors = unsafeWindow.monaco.editor.getEditors(); // Priority 1: Editor inside code-immersive-panel (Code Canvas) let canvasEditor = editors.find(e => { let node = e.getContainerDomNode(); // Handle Xray wrapper if (node.wrappedJSObject) node = node.wrappedJSObject; return node.closest('code-immersive-panel') && rawDoc.body.contains(node) && node.offsetParent !== null; }); // Priority 2: Fallback to any visible editor if (!canvasEditor) { canvasEditor = editors.find(e => { let node = e.getContainerDomNode(); if (node.wrappedJSObject) node = node.wrappedJSObject; return rawDoc.body.contains(node) && node.offsetParent !== null; }); } if (canvasEditor) { console.log("Gemini Mod: Found Active Monaco Editor."); const model = canvasEditor.getModel(); if (model) { let title = "code_snippet"; // Retrieve title let node = canvasEditor.getContainerDomNode(); if (node.wrappedJSObject) node = node.wrappedJSObject; const parentPanel = node.closest('code-immersive-panel'); if (parentPanel) { const header = parentPanel.querySelector('h2, [data-test-id="canvas-title"], .title, .filename'); if (header && header.textContent.trim()) { title = header.textContent.trim(); } } if (title === "code_snippet") { const broadTitle = rawDoc.querySelector('code-immersive-panel h2'); if (broadTitle && broadTitle.textContent.trim()) { title = broadTitle.textContent.trim(); } } console.log(`Gemini Mod: Extracted ${model.getValue().length} chars from Monaco.`); return { type: 'code', text: model.getValue(), title: title }; } } } catch (e) { console.warn("Gemini Mod: Failed to access Monaco API", e); } } // 2. Try ProseMirror (Document Editor) const pmEditor = rawDoc.querySelector('.ProseMirror'); if (pmEditor) { console.log("Gemini Mod: Found ProseMirror editor (raw)."); if (pmEditor.pmView) { console.log("Gemini Mod: Found pmView. Extracting text..."); const titleEl = rawDoc.querySelector(GEMINI_DOC_CANVAS_TITLE_SELECTOR); const title = titleEl ? titleEl.textContent.trim() : "GEMINI_DOCUMENT"; try { const text = pmEditor.pmView.state.doc.textContent; return { type: 'text', text: text, title: title }; } catch (e) { console.warn("Gemini Mod: Failed to read ProseMirror state", e); } } } // 3. Fallback: DOM Text Extraction (standard document) console.log("Gemini Mod: Fallback to DOM text."); // Use standard document for fallback selectors as they might rely on standard DOM API behavior const panels = document.querySelectorAll('code-immersive-panel, immersive-panel, .immersive-panel-container'); for (const panel of panels) { const checkRoot = (root) => { if (!root) return null; const titleEl = root.querySelector('h2.title-text, .title'); const title = titleEl ? titleEl.textContent.trim() : "gemini_artifact"; const monacoEditor = root.querySelector('.monaco-editor'); if (monacoEditor) { const viewLines = monacoEditor.querySelector('.view-lines'); if (viewLines) return { type: 'code', text: viewLines.innerText, title: title }; } const codeBlock = root.querySelector('code, pre'); if (codeBlock) return { type: 'code', text: codeBlock.textContent, title: title }; const editor = root.querySelector(GEMINI_DOC_CANVAS_EDITOR_SELECTOR) || root.querySelector('[contenteditable="true"]'); if (editor) return { type: 'text', text: editor.innerText, title: title }; return null; }; // Check Shadow DOM if (panel.shadowRoot) { const res = checkRoot(panel.shadowRoot); if (res) return res; } const res = checkRoot(panel); if (res) return res; } return null; } async function handleCopy() { const content = getCanvasContent(); if (content) { try { await navigator.clipboard.writeText(content.text); displayMessage("Content copied to clipboard!", false); } catch (err) { displayMessage("Failed to copy: " + err.message); } } else { displayMessage("No active canvas content found to copy."); } } function handleGlobalCanvasDownload() { const content = getCanvasContent(); if (content) { let filename = (content.title || "gemini_export").replace(INVALID_FILENAME_CHARS_REGEX, "_"); // Only append extension if it doesn't look like a filename already if (!FILENAME_WITH_EXT_REGEX.test(filename)) { filename += "." + DEFAULT_DOWNLOAD_EXTENSION; } downloadString(content.text, filename); } else { displayMessage("No active canvas content found to download."); } } function downloadString(text, filename) { const blob = new Blob([text], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function handlePDFExport() { const content = getCanvasContent(); if (!content) { displayMessage("No content to export."); return; } try { const { jsPDF } = window.jspdf; // Use 'pt' units for consistency with bridge.js logic const doc = new jsPDF({ unit: 'pt', format: 'a4' }); const margins = { top: 40, bottom: 40, left: 40, right: 40 }; const pageWidth = doc.internal.pageSize.getWidth(); const pageHeight = doc.internal.pageSize.getHeight(); const maxLineWidth = pageWidth - margins.left - margins.right; const lineHeight = 12; // sanitize content: replace tabs with spaces for correct width calc const textContent = (content.text || "") .replace(/\t/g, ' ') .replace(/\u00A0/g, ' '); let title = content.title || "gemini_export"; // Title doc.setFont("helvetica", "bold"); doc.setFontSize(14); doc.text(title, margins.left, margins.top); let y = margins.top + 25; // Content doc.setFont("courier", "normal"); doc.setFontSize(10); // Split text to fit width const lines = doc.splitTextToSize(textContent, maxLineWidth); lines.forEach(line => { if (y > pageHeight - margins.bottom) { doc.addPage(); y = margins.top; } doc.text(line, margins.left, y); y += lineHeight; }); const filename = title.replace(INVALID_FILENAME_CHARS_REGEX, "_") + ".pdf"; doc.save(filename); } catch (e) { console.error("Gemini Mod: PDF Generation Failed", e); displayMessage("PDF Generation Failed: " + e.message); } } // --- Initialization --- async function init() { console.log("Gemini Mod Userscript: Initializing (Modular Version)..."); // Inject Styles (Using Module) injectCSS(); // Handle Drive Auth Callback (if this is a popup) GeminiMod.drive.handleAuthCallback(); // Setup Drive Message Listener (for main window) // Pass a callback to update UI if settings panel is open GeminiMod.drive.setupAuthMessageListener(() => { updateSettingsPanelDriveStatus(); }); await loadConfiguration(); setTimeout(() => { try { createToolbar(); createSettingsPanel(); // Start folder initialization loop const folderInitInterval = setInterval(() => { if (initializeFolders()) { clearInterval(folderInitInterval); } }, 500); } catch (e) { console.error("Gemini Mod: Error during delayed initialization:", e); displayMessage("Error initializing toolbar. See console."); } }, 1500); } // Run Init if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();