Gemini2Markdown

Exports Google Gemini chats to Markdown. Captures "Show Thinking", auto-detects model names, and loads full history.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==UserScript==
// @name         Gemini2Markdown
// @namespace    https://greasyfork.org/en/users/1552401-chipfin
// @version      1.6.6
// @description  Exports Google Gemini chats to Markdown. Captures "Show Thinking", auto-detects model names, and loads full history.
// @icon64       https://upload.wikimedia.org/wikipedia/commons/thumb/1/1d/Google_Gemini_icon_2025.svg/64px-Google_Gemini_icon_2025.svg.png
// @match        https://gemini.google.com/*
// @grant        none
// @license      MIT
// @author       Gemini 3 Pro, ChatGPT-5.2
// ==/UserScript==

(() => {
    'use strict';

    /* ---------------- Utilities ---------------- */

    const sleep = (ms) => new Promise(r => setTimeout(r, ms));
    const trim = s => (s || '').toString().replace(/\r/g, '').trim();

    function getFormattedTimestamp() {
        const now = new Date();
        const pad = (n) => n.toString().padStart(2, '0');
        const tzo = -now.getTimezoneOffset();
        const dif = tzo >= 0 ? '+' : '-';
        const offHour = pad(Math.floor(Math.abs(tzo) / 60));
        const offMin = pad(Math.abs(tzo) % 60);
        return `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())}T${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}${dif}${offHour}${offMin}`;
    }

    function cleanMarkdown(text) {
        if (!text) return '';
        text = text.replace(/https:\/\/[^ \n]+filename=([^& \n]+)[^ \n]*/g, (match, filename) => {
            try { return `[Uploaded File: ${decodeURIComponent(filename.replace(/\+/g, ' '))}]`; } catch (e) { return '[Uploaded File]'; }
        });
        text = text
            .replace(/https:\/\/drive\.google\.com\/viewerng\/thumb[^ \n]*/g, '')
            .replace(/https:\/\/contribution\.usercontent\.google\.com\/download[^ \n]*/g, '')
            .replace(/https:\/\/lh3\.googleusercontent\.com\/[^ \n]+/g, '[Image]')
            .replace(/\\(?![\\*_`])/g, '\\\\')
            .replace(/&lt;/g, '<')
            .replace(/&gt;/g, '>')
            .replace(/&amp;/g, '&');
        return text.replace(/\n\s*\n/g, '\n\n').trim();
    }

    /* ---------------- Actions ---------------- */

    function getChatScroller() {
        return document.querySelector('#chat-history.chat-history-scroll-container infinite-scroller.chat-history') ||
               document.querySelector('infinite-scroller.chat-history');
    }

    async function scrollChatToTop(statusCallback) {
        const scroller = getChatScroller();
        if (!scroller) return;
        let stableCount = 0;
        for (let i = 0; i < 55; i++) {
            scroller.scrollTop = 0;
            if (statusCallback) statusCallback(`⬆️ ${i}`);
            await sleep(1300);
            if (scroller.scrollTop !== 0) stableCount = 0;
            else stableCount++;
            if (stableCount >= 4) break;
        }
    }

    async function expandAllThoughts(statusCallback) {
        if (statusCallback) statusCallback("🧠");
        const buttons = document.querySelectorAll('model-thoughts .thoughts-header-button');
        for (const btn of buttons) {
            const container = btn.closest('model-thoughts');
            if (!container?.querySelector('.thoughts-content') || (btn.textContent && btn.textContent.includes('Show thinking'))) {
                btn.click();
                await sleep(100);
            }
        }
        await sleep(1000);
    }

    async function detectModelForContainer(container) {
        const menuBtn = container.querySelector('.more-menu-button, button[data-test-id="more-actions-button"]');
        if (!menuBtn) return 'Gemini';
        menuBtn.click();
        await sleep(300);
        const overlayItems = [...document.querySelectorAll('.cdk-overlay-pane .mat-mdc-menu-item-text')];
        const modelItem = overlayItems.find(el => el.textContent && el.textContent.trim().startsWith('Model:'));
        let model = 'Gemini';
        if (modelItem) model = modelItem.textContent.trim();
        document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, bubbles: true, cancelable: true }));
        await sleep(100);
        return model;
    }

    /* ---------------- Extraction ---------------- */

    function processElement(el) {
        if (!el) return '';
        const clone = el.cloneNode(true);
        clone.querySelectorAll('button, mat-icon, .action-bar, .feedback_buttons, .thoughts-header').forEach(e => e.remove());
        clone.querySelectorAll('b, strong').forEach(b => b.textContent = `**${b.textContent}**`);
        clone.querySelectorAll('i, em').forEach(i => i.textContent = `*${i.textContent}*`);
        clone.querySelectorAll('a').forEach(a => {
            const href = a.href;
            const text = a.innerText;
            if (href && text) a.textContent = `[${text}](${href})`;
        });
        clone.querySelectorAll('pre').forEach(pre => {
            const code = pre.innerText;
            const lang = pre.getAttribute('data-language') || '';
            pre.textContent = `\n\`\`\`${lang}\n${code}\n\`\`\`\n`;
        });
        const blockTags = ['p', 'div', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'tr'];
        blockTags.forEach(tag => { clone.querySelectorAll(tag).forEach(block => block.after('\n')); });
        clone.querySelectorAll('br').forEach(br => br.replaceWith('\n'));
        return cleanMarkdown(clone.textContent);
    }

    /* ---------------- Main Logic ---------------- */

    async function exportToMarkdown() {
        const btn = document.querySelector('#gemini-export-md-icon');
        const originalIcon = btn.innerHTML;
        const setStatus = (text) => { btn.innerHTML = `<span style="font-size:11px; font-weight:bold; color:#444;">${text}</span>`; };
        try {
            await scrollChatToTop(setStatus);
            await expandAllThoughts(setStatus);
            const containers = document.querySelectorAll('.conversation-container');
            if (containers.length === 0) throw new Error("No chat found.");
            const titleEl = document.querySelector('.conversation-title');
            let cleanTitle = trim(titleEl ? titleEl.innerText : document.title).replace(/[<>:"/\\|?*]/g, '').replace(/\s+/g, ' ');
            let displayTitle = cleanTitle.substring(0, 64).trim();
            const timestamp = getFormattedTimestamp();
            const toc = [];
            const turnBuffer = [];
            let globalModel = 'Gemini';
            let chatIndex = 1;
            for (let i = 0; i < containers.length; i++) {
                const container = containers[i];
                const userQuery = container.querySelector('user-query .query-content, .user-query');
                const modelResponse = container.querySelector('model-response .message-content, model-response');
                if (!userQuery && !modelResponse) continue;
                setStatus(`🔍 ${i+1}/${containers.length}`);
                const currentModel = modelResponse ? await detectModelForContainer(container) : 'User';
                if (i === 0 && currentModel !== 'User') globalModel = currentModel;
                let turnText = `### chat-${chatIndex}\n\n`;
                if (userQuery) {
                    const text = processElement(userQuery);
                    toc.push(`- [${chatIndex}: ${text.substring(0, 50).replace(/\n/g, ' ')}...](#chat-${chatIndex})`);
                    turnText += `####### User writes:\n\n${text}\n\n`;
                }
                if (modelResponse) {
                    turnText += `####### Gemini (${currentModel}) writes:\n\n`;
                    let hasThoughts = false;
                    const thoughtNode = container.querySelector('model-thoughts');
                    if (thoughtNode) {
                        const thoughtText = processElement(thoughtNode.querySelector('.thoughts-content'));
                        if (thoughtText) {
                            hasThoughts = true;
                            turnText += `**Shown Thinking (Gemini):**\n---\n\n${thoughtText}\n\n`;
                        }
                    }
                    const responseClone = modelResponse.cloneNode(true);
                    responseClone.querySelectorAll('model-thoughts, .thoughts-container').forEach(e => e.remove());
                    if (hasThoughts) turnText += `**Response (Gemini):**\n---\n\n`;
                    turnText += `${processElement(responseClone)}\n\n`;
                }
                turnText += `___\n###### [top](#table-of-contents)\n\n`;
                turnBuffer.push(turnText);
                chatIndex++;
            }
            const header = `---\ntitle: ${cleanTitle}\ndate: ${timestamp}\nurl: ${location.href}\nmodel: ${globalModel}\n---\n\n# ${cleanTitle}\n\n`;
            const finalContent = [header, `## Table of Contents\n${toc.join('\n')}\n\n---\n\n`, ...turnBuffer].join('');
            const blob = new Blob([finalContent], { type: 'text/markdown' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = `GEMINI_${displayTitle}_${timestamp}.md`;
            a.click();
            URL.revokeObjectURL(url);
        } catch (e) {
            console.error(e);
            alert("Export failed: " + e.message);
        } finally {
            btn.innerHTML = originalIcon;
        }
    }

    /* ---------------- UI Integration ---------------- */

    function addExportButton() {
        if (document.querySelector('#gemini-export-md-icon')) return;
        const anchor = document.querySelector('studio-sidebar-button, [data-test-id="studio-sidebar-button"], new-chat-button, [data-test-id="new-chat-button-container"]');
        if (!anchor) return;

        const exportBtn = document.createElement('button');
        exportBtn.id = 'gemini-export-md-icon';
        exportBtn.setAttribute('title', 'Export chat as Markdown');

        // CSS optimization for v1.6.5:
        // Changed margin to 3px 4px 0 0 to nudge the 15px icon down.
        // align-self: center ensures the 40px container is centered, then the 3px margin shifts the icon.
        exportBtn.style.cssText = `
            display: inline-flex;
            align-items: center;
            justify-content: center;
            align-self: center;
            width: 40px;
            height: 40px;
            background: transparent;
            border: none;
            border-radius: 50%;
            cursor: pointer;
            transition: background 0.2s;
            margin: 3px 4px 0 0;
            padding: 0;
            vertical-align: middle;
        `;

        exportBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="15" viewBox="0 0 208 128" style="opacity:0.75;"><rect width="198" height="118" x="5" y="5" ry="10" stroke="#000" stroke-width="10" fill="none"/><path d="M30 98V30h20l20 25 20-25h20v68H90V59L70 84 50 59v39zm125 0l-30-33h20V30h20v35h20z"/></svg>`;
        exportBtn.addEventListener('mouseenter', () => exportBtn.style.background = 'rgba(154, 160, 166, 0.1)');
        exportBtn.addEventListener('mouseleave', () => exportBtn.style.background = 'transparent');
        exportBtn.addEventListener('click', exportToMarkdown);

        anchor.parentNode.insertBefore(exportBtn, anchor);
    }

    setTimeout(addExportButton, 2000);
    new MutationObserver(addExportButton).observe(document.body, { childList: true, subtree: true });

})();