Gemini to Notion Exporter

Gemini 导出:智能图片归位 (支持 PicList/PicGo)+隐私开关+单个对话导出

目前為 2025-11-25 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Gemini to Notion Exporter
// @namespace    http://tampermonkey.net/
// @version      13.0
// @license      MIT
// @description  Gemini 导出:智能图片归位 (支持 PicList/PicGo)+隐私开关+单个对话导出
// @author       Wyih with Gemini Thought Partner
// @match        https://gemini.google.com/*
// @connect      api.notion.com
// @connect      127.0.0.1
// @connect      *
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_addStyle
// ==/UserScript==

(function() {
    'use strict';

    // --- 基础配置 ---
    const PICLIST_URL = "http://127.0.0.1:36677/upload";
    const ASSET_PLACEHOLDER_PREFIX = "PICLIST_WAITING::";
    const MAX_TEXT_LENGTH = 2000;

    // ------------------- 0. 环境自检 -------------------
    function checkPicListConnection() {
        GM_xmlhttpRequest({
            method: "GET", url: "http://127.0.0.1:36677/heartbeat", timeout: 2000,
            onload: (res) => { if(res.status===200) console.log("✅ PicList 连接正常"); },
            onerror: () => console.error("❌ 无法连接到 PicList")
        });
    }
    setTimeout(checkPicListConnection, 3000);

    // ------------------- 1. 配置管理 -------------------
    function getConfig() { return { token: GM_getValue('notion_token', ''), dbId: GM_getValue('notion_db_id', '') }; }
    function promptConfig() {
        const token = prompt('请输入 Notion Integration Secret:', GM_getValue('notion_token', ''));
        if (token) {
            const dbId = prompt('请输入 Notion Database ID:', GM_getValue('notion_db_id', ''));
            if (dbId) { GM_setValue('notion_token', token); GM_setValue('notion_db_id', dbId); alert('配置已保存'); }
        }
    }
    GM_registerMenuCommand("⚙️ 设置 Notion Token", promptConfig);

    // ------------------- 2. UI 样式 -------------------
    GM_addStyle(`
        /* 全量导出按钮 */
        #gemini-saver-btn {
            position: fixed; bottom: 20px; right: 20px; z-index: 9999;
            background-color: #0066CC; color: white; border: none; border-radius: 6px;
            padding: 10px 16px; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
            font-family: sans-serif; font-weight: 600; font-size: 14px; transition: all 0.2s;
        }
        #gemini-saver-btn:hover { background-color: #0052a3; transform: translateY(-2px); }
        #gemini-saver-btn.loading { background-color: #666; cursor: wait; }

        /* 局部工具栏 - 强力可见性 */
        .gemini-tool-group {
            position: absolute; z-index: 9500;
            display: flex; gap: 6px;
            opacity: 0.4; /* 默认半透明 */
            transition: all 0.2s;
            filter: grayscale(1);
            background: rgba(255,255,255,0.95);
            padding: 4px 8px; border-radius: 20px;
            box-shadow: 0 2px 8px rgba(0,0,0,0.15);
            border: 1px solid #ccc;
        }
        user-query:hover .gemini-tool-group, model-response:hover .gemini-tool-group, .gemini-tool-group:hover {
            opacity: 1 !important; filter: grayscale(0); transform: scale(1.05); background: white; border-color: #bbb;
        }

        user-query .gemini-tool-group { bottom: 8px; right: 8px; }
        model-response .gemini-tool-group { bottom: 15px; right: 0px; }

        /* 图标按钮 */
        .gemini-icon-btn {
            cursor: pointer; font-size: 18px; line-height: 24px; user-select: none;
            width: 28px; height: 28px; text-align: center;
            border-radius: 50%; transition: background 0.2s;
            display: flex; align-items: center; justify-content: center;
        }
        .gemini-icon-btn:hover { background: rgba(0,0,0,0.08); }

        /* 隐私激活 */
        .gemini-privacy-toggle[data-skip="true"] { color: #d93025; background: #fce8e6; opacity: 1; filter: none; }
        .gemini-tool-group:has(.gemini-privacy-toggle[data-skip="true"]) { opacity: 1; filter: none; border-color: #fce8e6; }

        /* --- 动画 --- */
        @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }

        .gemini-icon-btn.processing { cursor: wait; color: #1a73e8; background: #e8f0fe; }
        .gemini-icon-btn.processing span { display: block; animation: spin 1s linear infinite; }

        .gemini-icon-btn.success { color: #188038 !important; background: #e6f4ea; }
        .gemini-icon-btn.error { color: #d93025 !important; background: #fce8e6; }
    `);

    // ------------------- 3. UI 注入 (DOM 原生创建,拒绝 innerHTML) -------------------
    function injectPageControls() {
        const bubbles = document.querySelectorAll('user-query, model-response');
        bubbles.forEach(bubble => {
            if (bubble.querySelector('.gemini-tool-group')) return;

            // 强制定位
            if (getComputedStyle(bubble).position === 'static') bubble.style.position = 'relative';

            const group = document.createElement('div');
            group.className = 'gemini-tool-group';

            // --- 隐私按钮 ---
            const privacyBtn = document.createElement('div');
            privacyBtn.className = 'gemini-icon-btn gemini-privacy-toggle';
            privacyBtn.title = "点击切换:是否导出此条内容";
            privacyBtn.setAttribute('data-skip', 'false');

            // 使用 span 包裹以便旋转
            const privacyIcon = document.createElement('span');
            privacyIcon.textContent = '👁️';
            privacyBtn.appendChild(privacyIcon);

            privacyBtn.onclick = (e) => {
                e.stopPropagation();
                const isSkipping = privacyBtn.getAttribute('data-skip') === 'true';
                if (isSkipping) {
                    privacyBtn.setAttribute('data-skip', 'false'); privacyIcon.textContent = '👁️'; bubble.setAttribute('data-privacy-skip', 'false');
                } else {
                    privacyBtn.setAttribute('data-skip', 'true'); privacyIcon.textContent = '🚫'; bubble.setAttribute('data-privacy-skip', 'true');
                }
            };

            // --- 单条导出按钮 ---
            const singleExportBtn = document.createElement('div');
            singleExportBtn.className = 'gemini-icon-btn';
            singleExportBtn.title = "仅导出此条对话";

            const exportIcon = document.createElement('span');
            exportIcon.textContent = '📤';
            singleExportBtn.appendChild(exportIcon);

            singleExportBtn.onclick = (e) => {
                e.stopPropagation();
                handleSingleExport(bubble, singleExportBtn, exportIcon); // 传icon进去以便控制文字
            };

            group.appendChild(privacyBtn);
            group.appendChild(singleExportBtn);
            bubble.appendChild(group);
        });
    }

    // ------------------- 4. 资源处理 -------------------
    function convertBlobImageToBuffer(blobUrl) {
        return new Promise((resolve, reject) => {
            const img = document.querySelector(`img[src="${blobUrl}"]`);
            if (!img || !img.complete || img.naturalWidth === 0) return reject("图片加载失败");
            try {
                const canvas = document.createElement('canvas');
                canvas.width = img.naturalWidth; canvas.height = img.naturalHeight;
                canvas.getContext('2d').drawImage(img, 0, 0);
                canvas.toBlob(b => b ? b.arrayBuffer().then(buf => resolve({ buffer: buf, type: b.type })) : reject("Canvas失败"), 'image/png');
            } catch (e) { reject(e.message); }
        });
    }

    function fetchAssetAsArrayBuffer(url) {
        return new Promise((resolve, reject) => {
            if (url.startsWith('blob:')) {
                convertBlobImageToBuffer(url).then(resolve).catch(() => {
                    GM_xmlhttpRequest({ method: "GET", url, responseType: 'arraybuffer', onload: r => r.status===200?resolve({buffer:r.response,type:'application/octet-stream'}):reject() });
                });
            } else {
                GM_xmlhttpRequest({ method: "GET", url, responseType: 'arraybuffer', onload: r => r.status===200?resolve({buffer:r.response,type:r.responseHeaders.match(/content-type:\s*(.*)/i)?.[1]}):reject() });
            }
        });
    }

    function uploadToPicList(arrayBufferObj, filename) {
        return new Promise((resolve, reject) => {
            if (!arrayBufferObj.buffer) return reject("空文件");
            let finalFilename = filename.split('?')[0];
            const mime = (arrayBufferObj.type||'').split(';')[0].trim().toLowerCase();
            if (!finalFilename.includes('.') || finalFilename.length - finalFilename.lastIndexOf('.') > 6) {
                const mimeMap = { 'application/pdf':'.pdf', 'application/msword':'.doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':'.docx', 'image/png':'.png', 'image/jpeg':'.jpg', 'image/webp':'.webp' };
                if(mimeMap[mime]) finalFilename += mimeMap[mime];
            }
            const boundary = "----GeminiSaverBoundary" + Math.random().toString(36).substring(2);
            const preData = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${finalFilename.replace(/"/g,'')}"\r\nContent-Type: ${mime||'application/octet-stream'}\r\n\r\n`;
            const combinedBlob = new Blob([preData, arrayBufferObj.buffer, `\r\n--${boundary}--\r\n`]);

            GM_xmlhttpRequest({
                method: "POST", url: PICLIST_URL, headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` }, data: combinedBlob,
                onload: (res) => { try { const r=JSON.parse(res.responseText); r.success?resolve(r.result[0]):reject(r.message); } catch(e){reject(e.message);} },
                onerror: () => reject("网络错误")
            });
        });
    }

    async function processAssets(blocks, statusCallback) {
        const tasks = []; const map = new Map();
        blocks.forEach((b, i) => {
            let urlObj = null;
            if (b.type === 'image' && b.image?.external?.url?.startsWith(ASSET_PLACEHOLDER_PREFIX)) { urlObj = b.image.external; }
            else if (b.type === 'file' && b.file?.external?.url?.startsWith(ASSET_PLACEHOLDER_PREFIX)) { urlObj = b.file.external; }

            if (urlObj) {
                const [_, name, realUrl] = urlObj.url.split('::');
                if (realUrl.startsWith('blob:') && b.type === 'file') {
                    b.type = "paragraph"; b.paragraph = { rich_text: [{ type: "text", text: { content: `📄 [本地文件未上传] ${name}` }, annotations: { color: "gray", italic: true } }] };
                    delete b.file; return;
                }
                const task = fetchAssetAsArrayBuffer(realUrl).then(buf => uploadToPicList(buf, name)).then(u => ({i, url: u, name, ok: true})).catch(e => ({i, err: e, name, ok: false}));
                tasks.push(task); map.set(i, b);
            }
        });

        if (tasks.length) {
            statusCallback(`⏳ Uploading ${tasks.length}...`);
            const res = await Promise.all(tasks);
            res.forEach(r => {
                const blk = map.get(r.i);
                if (r.ok) { blk.type==='image'?blk.image.external.url=r.url : (blk.file.external.url=r.url, blk.file.name=r.name||"File"); }
                else {
                    console.error(`Upload Fail: ${r.name}`, r.err);
                    blk.type="paragraph"; blk.paragraph={rich_text:[{type:"text",text:{content:`⚠️ Upload Failed: ${r.name}`},annotations:{color:"red"}}]};
                    delete blk.file; delete blk.image;
                }
            });
        }
        return blocks;
    }

    // ------------------- 5. DOM 解析 (代码高亮 + 切分) -------------------
    const NOTION_LANGUAGES = new Set(["bash","c","c++","css","go","html","java","javascript","json","kotlin","markdown","php","python","ruby","rust","shell","sql","swift","typescript","yaml","r","plain text"]);
    function mapLanguageToNotion(lang) {
        if (!lang) return "plain text"; lang = lang.toLowerCase().trim();
        if (lang === "js") return "javascript"; if (lang === "py") return "python";
        if (NOTION_LANGUAGES.has(lang)) return lang; return "plain text";
    }
    function detectLanguageRecursive(preNode) {
        let c = preNode; for(let i=0;i<3;i++) { if(!c)break; const h=c.previousElementSibling; if(h&&NOTION_LANGUAGES.has(h.innerText.toLowerCase()))return mapLanguageToNotion(h.innerText); c=c.parentElement; }
        const code = preNode.querySelector('code'); return code && code.className.match(/language-([\w-]+)/) ? mapLanguageToNotion(code.className.match(/language-([\w-]+)/)[1]) : "plain text";
    }

    // 🌟 核心修复版:代码切分逻辑
    function splitCodeSafe(code) {
        const chunks = [];
        let remaining = code;
        while (remaining.length > 0) {
            // 如果剩余长度小于等于 MAX,直接结束
            if (remaining.length <= MAX_TEXT_LENGTH) {
                chunks.push(remaining);
                break;
            }

            // 搜索换行符。关键修正:使用 MAX_TEXT_LENGTH - 1 作为搜索边界。
            // 这样找到的 index 最大为 1999。
            // 下一步 splitIndex += 1 后,最大为 2000,完美避开 Notion 的 2000 限制。
            let splitIndex = remaining.lastIndexOf('\n', MAX_TEXT_LENGTH - 1);

            if (splitIndex === -1) {
                // 没找到换行符,强制切分
                splitIndex = MAX_TEXT_LENGTH;
            } else {
                // 找到了,包含换行符
                splitIndex += 1;
            }

            chunks.push(remaining.slice(0, splitIndex));
            remaining = remaining.slice(splitIndex);
        }
        return chunks;
    }

    function parseInlineNodes(nodes) {
        const rt=[];
        function tr(n,s={}){
            if(n.nodeType===3){
                const fullText = n.textContent;
                if (!fullText) return;
                for (let i = 0; i < fullText.length; i += MAX_TEXT_LENGTH) {
                    rt.push({ type: "text", text: { content: fullText.slice(i, i + MAX_TEXT_LENGTH), link: s.link }, annotations: {bold:!!s.bold,italic:!!s.italic,code:!!s.code,color:"default"} });
                }
            }
            else if(n.nodeType===1){
                const ns={...s};
                if(['B','STRONG'].includes(n.tagName))ns.bold=true; if(['I','EM'].includes(n.tagName))ns.italic=true;
                if(n.tagName==='CODE')ns.code=true; if(n.tagName==='A')ns.link={url:n.href};
                n.childNodes.forEach(c=>tr(c,ns));
            }
        } nodes.forEach(n=>tr(n)); return rt;
    }

    function processNodesToBlocks(nodes) {
        const blocks=[], buf=[];
        const flush=()=>{ if(buf.length){ const rt=parseInlineNodes(buf); for(let i=0;i<rt.length;i+=90) blocks.push({object:"block",type:"paragraph",paragraph:{rich_text:rt.slice(i,i+90)}}); buf.length=0; } };
        const fileExtRegex = /\.(pdf|zip|docx?|xlsx?|pptx?|csv|txt|md|html?|rar|7z|tar|gz|iso|exe|apk|dmg|json|xml|epub|R|Rmd|qmd)(\?|$)/i;

        Array.from(nodes).forEach(n=>{
            if(['SCRIPT','STYLE','SVG'].includes(n.nodeName)) return;
            if(n.nodeType===3||['B','I','CODE','SPAN','A'].includes(n.nodeName)) {
                if(n.nodeName==='A' && (n.hasAttribute('download') || n.href.includes('blob:') || fileExtRegex.test(n.href))) {
                    flush();
                    const fn=(n.innerText||'file').trim();
                    blocks.push({ object:"block", type:"file", file:{ type:"external", name:fn.slice(0,60), external:{url:`${ASSET_PLACEHOLDER_PREFIX}${fn}::${n.href}`} } });
                    return;
                }
                buf.push(n); return;
            }
            if(n.nodeType===1) {
                flush(); const t=n.tagName;
                if(t==='P') blocks.push(...processNodesToBlocks(n.childNodes));
                else if(t==='IMG' && !n.className.includes('avatar')) blocks.push({object:"block",type:"image",image:{type:"external",external:{url:`${ASSET_PLACEHOLDER_PREFIX}image.png::${n.src}`}}});
                else if(t==='PRE') {
                    const fullCode = n.textContent;
                    const lang = detectLanguageRecursive(n);
                    const rawChunks = splitCodeSafe(fullCode);
                    // 核心修复:代码块只保留纯文本,去除 annotations,确保高亮生效
                    const codeRichText = rawChunks.map(chunk => ({ type: "text", text: { content: chunk } }));
                    blocks.push({ object:"block", type:"code", code:{ rich_text: codeRichText, language: lang } });
                }
                else if(/^H[1-6]$/.test(t)) blocks.push({object:"block",type:`heading_${t[1]<4?t[1]:3}`,[`heading_${t[1]<4?t[1]:3}`]:{rich_text:parseInlineNodes(n.childNodes)}});
                else if(t==='UL'||t==='OL') { const tp=t==='UL'?'bulleted_list_item':'numbered_list_item'; Array.from(n.children).forEach(li=>{if(li.tagName==='LI')blocks.push({object:"block",type:tp,[tp]:{rich_text:parseInlineNodes(li.childNodes)}})}); }
                else if(t==='TABLE') {
                    const rows=Array.from(n.querySelectorAll('tr')); if(rows.length) {
                        const tb={object:"block",type:"table",table:{table_width:1,children:[]}}; let max=0;
                        rows.forEach(r=>{ const cs=Array.from(r.querySelectorAll('td,th')); max=Math.max(max,cs.length); tb.table.children.push({object:"block",type:"table_row",table_row:{cells:cs.map(c=>[{type:"text",text:{content:c.innerText.trim().slice(0,1000)}}])}}); });
                        tb.table.table_width=max; blocks.push(tb);
                    }
                } else blocks.push(...processNodesToBlocks(n.childNodes));
            }
        }); flush(); return blocks;
    }

    // ------------------- 6. 抓取逻辑 -------------------
    function buildUploadedImageMap() {
        const map = new Map();
        const imgs = document.querySelectorAll('img[data-test-id="uploaded-img"], img.preview-image');
        const bubbles = Array.from(document.querySelectorAll('user-query'));
        imgs.forEach(img => {
            let p = img.parentElement; while(p&&p!==document.body){if(p.tagName==='USER-QUERY'||p.querySelector('user-query'))break;p=p.parentElement;}
            const owner = p && (p.tagName==='USER-QUERY'?p:p.querySelector('user-query')) || bubbles[bubbles.length-1];
            if(owner) { if(!map.has(owner))map.set(owner,[]); map.get(owner).push(img); }
        });
        return map;
    }

    function getChatBlocks(targetBubbles = null) {
        const allBubbles = document.querySelectorAll('user-query, model-response');
        const bubblesToProcess = targetBubbles || Array.from(allBubbles);
        const children = [];
        const uploadMap = buildUploadedImageMap();

        if (bubblesToProcess.length > 0) {
            bubblesToProcess.forEach(bubble => {
                const isUser = bubble.tagName.toLowerCase() === 'user-query';
                const role = isUser ? "User" : "Gemini";
                if (bubble.getAttribute('data-privacy-skip') === 'true') {
                    children.push({ object: "block", type: "callout", callout: { rich_text: [{ type: "text", text: { content: `🚫 此 ${role} 内容已标记为隐私,未导出。` }, annotations: { color: "gray", italic: true } }], icon: { emoji: "🔒" }, color: "gray_background" } });
                    return;
                }
                children.push({ object: "block", type: "heading_3", heading_3: { rich_text: [{ type: "text", text: { content: role } }], color: isUser ? "default" : "blue_background" } });
                const clone = bubble.cloneNode(true);
                ['.gemini-tool-group', 'mat-icon', '.response-footer', '.message-actions'].forEach(s => clone.querySelectorAll(s).forEach(e => e.remove()));
                if (isUser && uploadMap.has(bubble)) {
                    const d = document.createElement("div");
                    uploadMap.get(bubble).forEach(img => d.appendChild(img.cloneNode(true)));
                    clone.appendChild(d);
                }
                children.push(...processNodesToBlocks(clone.childNodes));
                children.push({ object: "block", type: "divider", divider: {} });
            });
        }
        return children;
    }

    // ------------------- 7. Notion 上传 -------------------
    function getChatTitle(specificBubble = null) {
        if (specificBubble) return specificBubble.innerText.replace(/\n/g, ' ').slice(0, 50) + "...";
        const q = document.querySelector('user-query'); return q ? q.innerText.replace(/\n/g, ' ').slice(0,60) : "Gemini Chat";
    }

    function appendBlocksBatch(pageId, blocks, token, statusCallback) {
        if(!blocks.length) { statusCallback('✅ Saved!'); setTimeout(()=>statusCallback(null), 3000); return; }
        GM_xmlhttpRequest({
            method: "PATCH", url: `https://api.notion.com/v1/blocks/${pageId}/children`,
            headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json", "Notion-Version": "2022-06-28" },
            data: JSON.stringify({ children: blocks.slice(0,90) }),
            onload: (res) => res.status===200 ? appendBlocksBatch(pageId, blocks.slice(90), token, statusCallback) : console.error(res.responseText)
        });
    }

    function createPageAndUpload(title, blocks, token, dbId, statusCallback) {
        GM_xmlhttpRequest({
            method: "POST", url: "https://api.notion.com/v1/pages",
            headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json", "Notion-Version": "2022-06-28" },
            data: JSON.stringify({ parent: { database_id: dbId }, properties: { "Name": { title: [{ text: { content: title } }] }, "Date": { date: { start: new Date().toISOString() } }, "URL": { url: location.href } }, children: blocks.slice(0,90) }),
            onload: (res) => { res.status===200 ? appendBlocksBatch(JSON.parse(res.responseText).id, blocks.slice(90), token, statusCallback) : (statusCallback('❌ Fail'), alert(res.responseText)); },
            onerror: () => statusCallback('❌ Net Error')
        });
    }

    // ------------------- 8. 主逻辑 & 状态控制 -------------------
    async function executeExport(blocks, title, btnOrLabelUpdater, iconElem) {
        const { token, dbId } = getConfig(); if (!token) return promptConfig();

        const updateStatus = (msg) => {
            // 单条导出按钮处理
            if (btnOrLabelUpdater.classList && btnOrLabelUpdater.classList.contains('gemini-icon-btn') && iconElem) {
                if (msg && msg.includes('Saved')) {
                    btnOrLabelUpdater.classList.remove('processing');
                    btnOrLabelUpdater.classList.add('success');
                    iconElem.textContent = '✅';
                    setTimeout(() => {
                        btnOrLabelUpdater.classList.remove('success');
                        iconElem.textContent = '📤';
                    }, 2500);
                } else if (msg && (msg.includes('Fail') || msg.includes('Error'))) {
                    btnOrLabelUpdater.classList.remove('processing');
                    btnOrLabelUpdater.classList.add('error');
                    iconElem.textContent = '❌';
                } else if (msg) {
                    btnOrLabelUpdater.classList.add('processing');
                    btnOrLabelUpdater.classList.remove('success', 'error');
                    iconElem.textContent = '⏳';
                }
            }
            // 全局按钮处理
            else if (btnOrLabelUpdater.id === 'gemini-saver-btn') {
                if (msg === null) btnOrLabelUpdater.textContent = '📥 Save to Notion';
                else btnOrLabelUpdater.textContent = msg;
            }
        };

        if (btnOrLabelUpdater.id === 'gemini-saver-btn') {
            btnOrLabelUpdater.classList.add('loading');
            btnOrLabelUpdater.textContent = '🕵️ Processing...';
        } else {
            updateStatus('Processing...');
        }

        try {
            blocks = await processAssets(blocks, updateStatus);
            if (btnOrLabelUpdater.id === 'gemini-saver-btn') btnOrLabelUpdater.textContent = '💾 Saving...';
            createPageAndUpload(title, blocks, token, dbId, updateStatus);
        } catch (e) {
            console.error(e);
            if (btnOrLabelUpdater.id === 'gemini-saver-btn') btnOrLabelUpdater.textContent = '❌ Error';
            updateStatus('❌ Fail');
            alert(e.message);
        } finally {
            if (btnOrLabelUpdater.id === 'gemini-saver-btn') btnOrLabelUpdater.classList.remove('loading');
        }
    }

    function handleFullExport() {
        const btn = document.getElementById('gemini-saver-btn');
        const blocks = getChatBlocks(null);
        executeExport(blocks, getChatTitle(), btn);
    }

    function handleSingleExport(bubble, iconBtn, iconElem) {
        const targets = [bubble];
        if (bubble.tagName.toLowerCase() === 'user-query') {
            const next = bubble.nextElementSibling;
            if (next && next.tagName.toLowerCase() === 'model-response' && next.getAttribute('data-privacy-skip') !== 'true') {
                targets.push(next);
            }
        }
        const blocks = getChatBlocks(targets);
        const title = getChatTitle(bubble);
        executeExport(blocks, title, iconBtn, iconElem);
    }

    function tryInit() {
        if (!document.getElementById('gemini-saver-btn')) {
            const btn = document.createElement('button');
            btn.id = 'gemini-saver-btn'; btn.textContent = '📥 Save to Notion'; btn.onclick = handleFullExport;
            document.body.appendChild(btn);
        }
        injectPageControls();
    }
    setInterval(tryInit, 1500);

})();