Google Gemini Mod (Toolbar & Download)

Enhances Google Gemini with a toolbar for snippets and canvas content download.

// ==UserScript==
// @name         Google Gemini Mod (Toolbar & Download)
// @namespace    http://tampermonkey.net/
// @version      0.0.7
// @description  Enhances Google Gemini with a toolbar for snippets and canvas content download.
// @description[de] Verbessert Google Gemini mit einer Symbolleiste für Snippets und dem Herunterladen von Canvas-Inhalten.
// @author       Adromir
// @match        https://gemini.google.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=gemini.google.com
// @license      MIT
// @licenseURL   https://opensource.org/licenses/MIT
// @homepageURL  https://github.com/adromir/scripts/tree/main/userscripts/gemini-snippets
// @supportURL   https://github.com/adromir/scripts/issues
// @grant        GM_addStyle
// @grant        GM_setClipboard
// @grant        GM_getClipboard
// ==/UserScript==

(function() {
    'use strict';

    // ===================================================================================
    // I. CONFIGURATION SECTION
    // ===================================================================================

    // --- Customizable Labels for Toolbar Buttons ---
    const PASTE_BUTTON_LABEL = "📋 Paste";
    const DOWNLOAD_BUTTON_LABEL = "💾 Download Canvas as File";

    // --- CSS Selectors for DOM Elements ---
    const GEMINI_CANVAS_TITLE_TEXT_SELECTOR = "code-immersive-panel > toolbar > div > div.left-panel > h2.title-text.gds-title-s.ng-star-inserted"; 
    
    // Selector for the "Share" button within the canvas's toolbar area.
    const GEMINI_CANVAS_SHARE_BUTTON_SELECTOR = "toolbar div.action-buttons share-button > button";

    // Selector for the "Copy to Clipboard" button, likely in a modal/overlay after share is clicked.
    // Using the more robust alternative focusing on data-test-id if the div structure is too volatile.
    const GEMINI_CANVAS_COPY_BUTTON_SELECTOR = "copy-button[data-test-id='copy-button'] > button.copy-button";
    // Fallback if the above is too specific or div structure changes often:
    // const GEMINI_CANVAS_COPY_BUTTON_SELECTOR_FALLBACK = "body > div:nth-child(n) > div:nth-child(n) > div > div > div > copy-button[data-test-id='copy-button'] > button.copy-button";


    const GEMINI_INPUT_FIELD_SELECTORS = [
        '.ql-editor p', 
        '.ql-editor',   
        'div[contenteditable="true"]' 
    ];

    // --- Download Feature Configuration ---
    const DEFAULT_DOWNLOAD_EXTENSION = "txt"; 

    // --- Regular Expressions for Filename Sanitization ---
    // 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})$/; 
    const SUBSTRING_FILENAME_REGEX = /([\w\s.,\-()[\\]{}'!~@#$%^&+=]+?\.([a-zA-Z0-9]{1,8}))(?=\s|$|[,.;:!?])/g;

    // ===================================================================================
    // II. TOOLBAR ELEMENT DEFINITIONS
    // ===================================================================================

    const buttonSnippets = [
        { label: "Greeting", text: "Hello Gemini!" },
        { label: "Explain", text: "Could you please explain ... in more detail?" },
    ];

    const dropdownConfigurations = [
        {
            placeholder: "Actions...",
            options: [
                { label: "Summarize", text: "Please summarize the following text:\n" },
                { label: "Ideas", text: "Give me 5 ideas for ..." },
                { label: "Code (JS)", text: "Give me a JavaScript code example for ..." },
            ]
        },
        {
            placeholder: "Translations",
            options: [
                { label: "DE -> EN", text: "Translate the following into English:\n" },
                { label: "EN -> DE", text: "Translate the following into German:\n" },
                { label: "Correct Text", text: "Please correct the grammar and spelling in the following text:\n" }
            ]
        },
    ];

    // ===================================================================================
    // III. SCRIPT LOGIC
    // ===================================================================================
    
    const embeddedCSS = `
        #gemini-snippet-toolbar-userscript { 
          position: fixed !important; top: 0 !important; left: 50% !important; 
          transform: translateX(-50%) !important; 
          width: auto !important; 
          max-width: 80% !important; 
          padding: 10px 15px !important; 
          z-index: 999999 !important; 
          display: flex !important; flex-wrap: wrap !important;
          gap: 8px !important; align-items: center !important; font-family: 'Roboto', 'Arial', sans-serif !important;
          box-sizing: border-box !important; background-color: rgba(40, 42, 44, 0.95) !important;
          border-radius: 0 0 16px 16px !important; 
          box-shadow: 0 4px 12px rgba(0,0,0,0.25);
        }
        #gemini-snippet-toolbar-userscript button, 
        #gemini-snippet-toolbar-userscript select {
          padding: 4px 10px !important; cursor: pointer !important; background-color: #202122 !important;
          color: #e3e3e3 !important; border-radius: 16px !important; font-size: 13px !important;
          font-family: inherit !important; font-weight: 500 !important; height: 28px !important;
          box-sizing: border-box !important; vertical-align: middle !important;
          transition: background-color 0.2s ease, transform 0.1s ease !important;
          border: none !important; flex-shrink: 0;
        }
        #gemini-snippet-toolbar-userscript select {
          padding-right: 25px !important;
          appearance: none !important;
          background-image: url('data:image/svg+xml;charset=US-ASCII,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="%23e3e3e3" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/></svg>') !important;
          background-repeat: no-repeat !important;
          background-position: right 8px center !important;
          background-size: 12px 12px !important;
        }
        #gemini-snippet-toolbar-userscript option {
          background-color: #2a2a2a !important;
          color: #e3e3e3 !important;
          font-weight: normal !important;
          padding: 5px 10px !important;
        }
        #gemini-snippet-toolbar-userscript button:hover,
        #gemini-snippet-toolbar-userscript select:hover {
          background-color: #4a4e51 !important;
        }
        #gemini-snippet-toolbar-userscript button:active {
          background-color: #5f6368 !important;
          transform: scale(0.98) !important;
        }
        .userscript-toolbar-spacer { 
            margin-left: auto !important;
        }
    `;

    function injectCustomCSS() {
        try {
            GM_addStyle(embeddedCSS);
            console.log("Gemini Mod Userscript: Custom CSS injected successfully.");
        } catch (error) {
            console.error("Gemini Mod Userscript: Failed to inject custom CSS:", error);
            const styleId = 'gemini-mod-userscript-styles';
            if (document.getElementById(styleId)) return;
            const style = document.createElement('style');
            style.id = styleId;
            style.textContent = embeddedCSS;
            document.head.appendChild(style);
        }
    }

    function displayUserscriptMessage(message, isError = true) {
        const prefix = "Gemini Mod Userscript: ";
        if (isError) console.error(prefix + message);
        else console.log(prefix + message);
        alert(prefix + message);
    }

    function moveCursorToEnd(element) {
        try {
            const range = document.createRange();
            const sel = window.getSelection();
            range.selectNodeContents(element);
            range.collapse(false);
            sel.removeAllRanges();
            sel.addRange(range);
            element.focus();
        } catch (e) {
            console.error("Gemini Mod Userscript: Error setting cursor position:", e);
        }
    }

    function findTargetInputElement() {
        let targetInputElement = null;
        for (const selector of GEMINI_INPUT_FIELD_SELECTORS) {
            const element = document.querySelector(selector);
            if (element) {
                if (element.classList.contains('ql-editor')) {
                    const pInEditor = element.querySelector('p');
                    targetInputElement = pInEditor || element;
                } else {
                    targetInputElement = element;
                }
                break;
            }
        }
        return targetInputElement;
    }

    function insertSnippetText(textToInsert) {
        let targetInputElement = findTargetInputElement();
        if (!targetInputElement) {
            displayUserscriptMessage("Could not find Gemini input field.");
            return;
        }
        let actualInsertionPoint = targetInputElement;
        if (targetInputElement.classList.contains('ql-editor')) {
            let p = targetInputElement.querySelector('p');
            if (!p) {
                p = document.createElement('p');
                targetInputElement.appendChild(p);
            }
            actualInsertionPoint = p;
        }
        actualInsertionPoint.focus();
        setTimeout(() => {
            moveCursorToEnd(actualInsertionPoint);
            let insertedViaExec = false;
            try {
                insertedViaExec = document.execCommand('insertText', false, textToInsert);
            } catch (e) {
                console.warn("Gemini Mod Userscript: execCommand('insertText') threw an error:", e);
            }
            if (!insertedViaExec) {
                if (actualInsertionPoint.innerHTML === '<br>') actualInsertionPoint.innerHTML = '';
                actualInsertionPoint.textContent += textToInsert;
                moveCursorToEnd(actualInsertionPoint);
            }
            const editorToDispatchOn = document.querySelector('.ql-editor') || targetInputElement;
            if (editorToDispatchOn) {
                editorToDispatchOn.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
                editorToDispatchOn.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
            }
            console.log("Gemini Mod Userscript: Snippet inserted.");
        }, 50);
    }

    async function handlePasteButtonClick() {
        try {
            if (!navigator.clipboard || !navigator.clipboard.readText) {
                displayUserscriptMessage("Clipboard access is not available or not permitted.");
                return;
            }
            const text = await navigator.clipboard.readText();
            if (text) insertSnippetText(text);
            else console.log("Gemini Mod Userscript: Clipboard is empty.");
        } catch (err) {
            console.error('Gemini Mod Userscript: Failed to read clipboard contents: ', err);
            displayUserscriptMessage(err.name === 'NotAllowedError' ? 'Permission to read clipboard was denied.' : 'Failed to paste from clipboard. See console.');
        }
    }

    function ensureLength(filename, maxLength = 255) {
        if (filename.length <= maxLength) {
            return filename;
        }
        const dotIndex = filename.lastIndexOf('.');
        if (dotIndex === -1 || dotIndex < filename.length - 10 ) { 
            return filename.substring(0, maxLength);
        }
        const base = filename.substring(0, dotIndex);
        const ext = filename.substring(dotIndex);
        const maxBaseLength = maxLength - ext.length;
        if (maxBaseLength <= 0) {
            return filename.substring(0, maxLength);
        }
        return base.substring(0, maxBaseLength) + ext;
    }

    function sanitizeBasename(baseName) {
        if (typeof baseName !== 'string' || baseName.trim() === "") return "downloaded_document";
        let sanitized = baseName.trim()
            .replace(INVALID_FILENAME_CHARS_REGEX, '_')
            .replace(/\s+/g, '_')
            .replace(/__+/g, '_')
            .replace(/^[_.-]+|[_.-]+$/g, '');
        if (!sanitized || RESERVED_WINDOWS_NAMES_REGEX.test(sanitized)) {
            sanitized = `_${sanitized || "file"}_`;
            sanitized = sanitized.replace(INVALID_FILENAME_CHARS_REGEX, '_').replace(/\s+/g, '_').replace(/__+/g, '_').replace(/^[_.-]+|[_.-]+$/g, '');
        }
        return sanitized || "downloaded_document";
    }

   function determineFilename(title, defaultExtension = "txt") {
        const logPrefix = "Gemini Mod Userscript: determineFilename - ";
        if (!title || typeof title !== 'string' || title.trim() === "") {
            console.log(`${logPrefix}Input title invalid or empty, defaulting to "downloaded_document.${defaultExtension}".`);
            return ensureLength(`downloaded_document.${defaultExtension}`);
        }

        let trimmedTitle = title.trim();
        let baseNamePart = "";
        let extensionPart = "";

        function stripPath(base) {
            if (typeof base !== 'string') return base;
            const lastSlash = Math.max(base.lastIndexOf('/'), base.lastIndexOf('\\'));
            return lastSlash !== -1 ? base.substring(lastSlash + 1) : base;
        }

        const fullTitleMatch = trimmedTitle.match(FILENAME_WITH_EXT_REGEX);
        if (fullTitleMatch) {
            let potentialBase = fullTitleMatch[1];
            const potentialExt = fullTitleMatch[2].toLowerCase();
            potentialBase = stripPath(potentialBase); 

            if (!INVALID_FILENAME_CHARS_REGEX.test(potentialBase.replace(/\s/g, '_')) && potentialBase.trim() !== "") {
                baseNamePart = potentialBase;
                extensionPart = potentialExt;
                console.log(`${logPrefix}Entire title "${trimmedTitle}" (path stripped) matches basename.ext. Base: "${baseNamePart}", Ext: "${extensionPart}"`);
            }
        }

        if (!extensionPart) { 
            let lastMatch = null;
            let currentMatch;
            SUBSTRING_FILENAME_REGEX.lastIndex = 0; 
            while ((currentMatch = SUBSTRING_FILENAME_REGEX.exec(trimmedTitle)) !== null) {
                lastMatch = currentMatch;
            }
            if (lastMatch) {
                const substringCandidate = lastMatch[1]; 
                const substringExtMatch = substringCandidate.match(FILENAME_WITH_EXT_REGEX);
                if (substringExtMatch) {
                    let potentialBaseFromSub = substringExtMatch[1];
                    const potentialExtFromSub = substringExtMatch[2].toLowerCase();
                    potentialBaseFromSub = stripPath(potentialBaseFromSub);
                    if (potentialBaseFromSub.trim() !== "") {
                         baseNamePart = potentialBaseFromSub;
                         extensionPart = potentialExtFromSub;
                         console.log(`${logPrefix}Found substring "${substringCandidate}" (path stripped) matching basename.ext. Base: "${baseNamePart}", Ext: "${extensionPart}"`);
                    }
                }
            }
        }

        if (extensionPart) { 
            const sanitizedBase = sanitizeBasename(baseNamePart);
            return ensureLength(`${sanitizedBase}.${extensionPart}`);
        } else {
            console.log(`${logPrefix}No basename.ext pattern found. Sanitizing full title (path stripped) "${trimmedTitle}" with default extension "${defaultExtension}".`);
            const baseForDefault = stripPath(trimmedTitle); 
            const sanitizedTitleBase = sanitizeBasename(baseForDefault);
            return ensureLength(`${sanitizedTitleBase}.${defaultExtension}`);
        }
    }

    function triggerDownload(filename, content) {
        try {
            const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
            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);
            console.log(`Gemini Mod Userscript: Download triggered for "${filename}".`);
        } catch (error) {
            console.error(`Gemini Mod Userscript: Failed to trigger download for "${filename}":`, error);
            displayUserscriptMessage(`Failed to download: ${error.message}`);
        }
    }

    async function handleGlobalCanvasDownload() {
        const titleTextElement = document.querySelector(GEMINI_CANVAS_TITLE_TEXT_SELECTOR);
        if (!titleTextElement) {
            console.warn("Gemini Mod Userscript: No active canvas title found. Selector:", GEMINI_CANVAS_TITLE_TEXT_SELECTOR);
            displayUserscriptMessage("No active canvas found to download.");
            return;
        }
        console.log("Gemini Mod Userscript: Found canvas title element:", titleTextElement);

        const codeImmersivePanelElement = titleTextElement.closest('code-immersive-panel');
        if (!codeImmersivePanelElement) {
            console.warn("Gemini Mod Userscript: Could not find parent 'code-immersive-panel' for the title element.");
            displayUserscriptMessage("Could not locate the main canvas panel for the active canvas.");
            return;
        }
        console.log("Gemini Mod Userscript: Found 'code-immersive-panel' element:", codeImmersivePanelElement);
        
        const shareButton = codeImmersivePanelElement.querySelector(GEMINI_CANVAS_SHARE_BUTTON_SELECTOR);
        if (!shareButton) {
          console.warn("Gemini Mod Userscript: 'Share' button not found within 'code-immersive-panel'. Selector used:", GEMINI_CANVAS_SHARE_BUTTON_SELECTOR);
          displayUserscriptMessage("Could not find the 'Share' button in the active canvas's panel.");
          return;
        }
        console.log("Gemini Mod Userscript: Found 'Share' button:", shareButton);
        shareButton.click();
        console.log("Gemini Mod Userscript: Programmatically clicked the 'Share' button.");

        // Wait for the copy button (potentially in a modal/overlay) to appear
        setTimeout(() => {
            const copyButton = document.querySelector(GEMINI_CANVAS_COPY_BUTTON_SELECTOR);
            if (!copyButton) {
              console.warn("Gemini Mod Userscript: 'Copy to Clipboard' button not found globally after clicking share. Selector used:", GEMINI_CANVAS_COPY_BUTTON_SELECTOR);
              displayUserscriptMessage("Could not find the 'Copy to Clipboard' button after clicking share.");
              return;
            }
            console.log("Gemini Mod Userscript: Found 'Copy to Clipboard' button globally:", copyButton);

            copyButton.click();
            console.log("Gemini Mod Userscript: Programmatically clicked the 'Copy to Clipboard' button.");

            setTimeout(async () => {
                try {
                    if (!navigator.clipboard || !navigator.clipboard.readText) {
                        displayUserscriptMessage("Clipboard access not available.");
                        return;
                    }
                    const clipboardContent = await navigator.clipboard.readText();
                    console.log("Gemini Mod Userscript: Successfully read from clipboard.");
                    if (!clipboardContent || clipboardContent.trim() === "") {
                        displayUserscriptMessage("Clipboard empty after copy. Nothing to download.");
                        return;
                    }
                    
                    const canvasTitle = (titleTextElement.textContent || "Untitled Canvas").trim();
                    const filename = determineFilename(canvasTitle); 
                    triggerDownload(filename, clipboardContent);
                    console.log("Gemini Mod Userscript: Global download initiated for canvas title:", canvasTitle, "using clipboard content. Filename:", filename);
                } catch (err) {
                    console.error('Gemini Mod Userscript: Error reading from clipboard:', err);
                    displayUserscriptMessage(err.name === 'NotAllowedError' ? 'Clipboard permission denied.' : 'Failed to read clipboard.');
                }
            }, 300); // Delay for clipboard write
        }, 500); // Delay for share menu to open and copy button to appear
    }

    function createToolbar() {
        const toolbarId = 'gemini-snippet-toolbar-userscript';
        if (document.getElementById(toolbarId)) {
            console.log("Gemini Mod Userscript: Toolbar already exists.");
            return;
        }
        console.log("Gemini Mod Userscript: Initializing toolbar...");
        const toolbar = document.createElement('div');
        toolbar.id = toolbarId;
        buttonSnippets.forEach(snippet => {
            const button = document.createElement('button');
            button.textContent = snippet.label;
            button.title = snippet.text;
            button.addEventListener('click', () => insertSnippetText(snippet.text));
            toolbar.appendChild(button);
        });
        dropdownConfigurations.forEach(config => {
            if (config.options && config.options.length > 0) {
                const select = document.createElement('select');
                select.title = config.placeholder || "Select snippet";
                const defaultOption = document.createElement('option');
                defaultOption.textContent = config.placeholder || "Select...";
                defaultOption.value = "";
                defaultOption.disabled = true;
                defaultOption.selected = true;
                select.appendChild(defaultOption);
                config.options.forEach(snippet => {
                    const option = document.createElement('option');
                    option.textContent = snippet.label;
                    option.value = snippet.text;
                    select.appendChild(option);
                });
                select.addEventListener('change', (event) => {
                    const selectedText = event.target.value;
                    if (selectedText) {
                        insertSnippetText(selectedText);
                        event.target.selectedIndex = 0;
                    }
                });
                toolbar.appendChild(select);
            }
        });
        const spacer = document.createElement('div');
        spacer.className = 'userscript-toolbar-spacer';
        toolbar.appendChild(spacer);
        const pasteButton = document.createElement('button');
        pasteButton.textContent = PASTE_BUTTON_LABEL;
        pasteButton.title = "Paste from Clipboard";
        pasteButton.addEventListener('click', handlePasteButtonClick);
        toolbar.appendChild(pasteButton);
        const globalDownloadButton = document.createElement('button');
        globalDownloadButton.textContent = DOWNLOAD_BUTTON_LABEL;
        globalDownloadButton.title = "Download active canvas content (uses canvas's copy button)";
        globalDownloadButton.addEventListener('click', handleGlobalCanvasDownload);
        toolbar.appendChild(globalDownloadButton);
        document.body.insertBefore(toolbar, document.body.firstChild);
        console.log("Gemini Mod Userscript: Toolbar inserted.");
    }

    function handleDarkModeForUserscript() {
        console.log("Gemini Mod Userscript: Dark mode handling is passive (toolbar is dark by default).");
    }

    // --- Initialization Logic ---
    function init() {
        console.log("Gemini Mod Userscript: Initializing...");
        injectCustomCSS();
        const M_INITIALIZATION_DELAY = 1500;
        setTimeout(() => {
            try {
                createToolbar();
                handleDarkModeForUserscript();
                 console.log("Gemini Mod Userscript: Fully initialized.");
            } catch(e) {
                console.error("Gemini Mod Userscript: Error during delayed initialization:", e);
                displayUserscriptMessage("Error initializing toolbar. See console.");
            }
        }, M_INITIALIZATION_DELAY);
    }

    if (document.readyState === 'loading') {
        window.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

})();