Created
March 21, 2026 15:58
-
-
Save camillanapoles/4613516917c0e51706a1a3d54deae726 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Claude Project Export 2026 | |
| // @namespace https://tampermonkey.net/ | |
| // @version 1.0.2 | |
| // @description One-click export of ALL Claude project files + project instructions into a single ZIP. Downloads real PDF attachments when available, falls back to text only when download isn't available, normalizes filenames, handles collisions, and writes rich metadata. | |
| // @author sharmanhall | |
| // @match https://claude.ai/* | |
| // @require https://unpkg.com/fflate/umd/index.js | |
| // @require https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js | |
| // @grant GM_addStyle | |
| // @connect * | |
| // @license MIT | |
| // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMxMTExMTEiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMjEgMTV2NGEyIDIgMCAwIDEtMiAySDVhMiAyIDAgMCAxLTItMnYtNCIvPjxwb2x5bGluZSBwb2ludHM9IjcgMTAgMTIgMTUgMTcgMTAiLz48bGluZSB4MT0iMTIiIHkxPSIxNSIgeDI9IjEyIiB5Mj0iMyIvPjwvc3ZnPg== | |
| // @downloadURL https://update.greasyfork.org/scripts/566000/Claude%20Project%20Export%202026.user.js | |
| // @updateURL https://update.greasyfork.org/scripts/566000/Claude%20Project%20Export%202026.meta.js | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // Prevent double-injection across SPA navigations | |
| if (window.__CLAUDE_PROJECT_EXPORT_2026__) return; | |
| window.__CLAUDE_PROJECT_EXPORT_2026__ = true; | |
| // --------------------------- | |
| // CONFIG | |
| // --------------------------- | |
| const CONFIG = { | |
| UI: { | |
| cornerZ: 9998, | |
| modalZ: 9999, | |
| showOnProjectOnly: true | |
| }, | |
| Timing: { | |
| openModalWaitMs: 12000, | |
| afterOpenDelayMs: 450, | |
| afterCloseDelayMs: 250, | |
| betweenFilesDelayMs: 300, | |
| scrollWaitMs: 600, | |
| maxScrollAttempts: 22 | |
| }, | |
| Content: { | |
| minTextChars: 10, | |
| maxInstructionChars: 200000 | |
| }, | |
| Zip: { | |
| level: 6 | |
| } | |
| }; | |
| const LOG_PREFIX = '[Claude Project Export 2026]'; | |
| const log = { | |
| info: (msg, ...args) => console.log(`${LOG_PREFIX} ℹ️ ${msg}`, ...args), | |
| ok: (msg, ...args) => console.log(`${LOG_PREFIX} ✅ ${msg}`, ...args), | |
| warn: (msg, ...args) => console.warn(`${LOG_PREFIX} ⚠️ ${msg}`, ...args), | |
| err: (msg, ...args) => console.error(`${LOG_PREFIX} ❌ ${msg}`, ...args), | |
| dbg: (msg, ...args) => console.log(`${LOG_PREFIX} 🔍 ${msg}`, ...args), | |
| file: (status, name, meta) => { | |
| const emoji = status === 'success' ? '✅' : status === 'skipped' ? '⚠️' : '❌'; | |
| console.log(`${LOG_PREFIX} ${emoji} ${name}`, meta || ''); | |
| } | |
| }; | |
| // --------------------------- | |
| // DOM UTILS | |
| // --------------------------- | |
| const q = (sel, root = document) => root.querySelector(sel); | |
| const qa = (sel, root = document) => Array.from(root.querySelectorAll(sel)); | |
| const sleep = (ms) => new Promise(r => setTimeout(r, ms)); | |
| function nowISO() { | |
| return new Date().toISOString(); | |
| } | |
| function resolveUrl(href) { | |
| try { | |
| return new URL(href, window.location.origin).toString(); | |
| } catch { | |
| return href; | |
| } | |
| } | |
| function isVisible(el) { | |
| if (!el) return false; | |
| const rect = el.getBoundingClientRect(); | |
| return rect.width > 0 && rect.height > 0; | |
| } | |
| function waitForAny(selectors, timeout = 15000, root = document) { | |
| return new Promise((resolve, reject) => { | |
| const start = performance.now(); | |
| const tick = () => { | |
| for (const sel of selectors) { | |
| const el = q(sel, root); | |
| if (el) return resolve({ el, selector: sel }); | |
| } | |
| if (performance.now() - start >= timeout) { | |
| return reject(new Error(`Timed out waiting for any selector: ${selectors.join(', ')}`)); | |
| } | |
| requestAnimationFrame(tick); | |
| }; | |
| tick(); | |
| }); | |
| } | |
| function waitUntilGone(selector, timeout = 10000) { | |
| return new Promise((resolve, reject) => { | |
| const start = performance.now(); | |
| const tick = () => { | |
| if (!q(selector)) return resolve(); | |
| if (performance.now() - start >= timeout) return reject(new Error(`"${selector}" did not disappear`)); | |
| requestAnimationFrame(tick); | |
| }; | |
| tick(); | |
| }); | |
| } | |
| async function waitForDialog(timeoutMs = CONFIG.Timing.openModalWaitMs) { | |
| const start = performance.now(); | |
| while (performance.now() - start < timeoutMs) { | |
| const d = q('div[role="dialog"]'); | |
| if (d && isVisible(d)) { | |
| await sleep(CONFIG.Timing.afterOpenDelayMs); | |
| return d; | |
| } | |
| await sleep(60); | |
| } | |
| return null; | |
| } | |
| function clickBest(el) { | |
| if (!el) return; | |
| const btn = el.closest('button') || el; | |
| try { | |
| btn.scrollIntoView({ block: 'center', inline: 'center' }); | |
| } catch {} | |
| btn.click(); | |
| } | |
| async function closeDialog() { | |
| const dialog = q('div[role="dialog"]'); | |
| if (!dialog) return true; | |
| // Try explicit close buttons | |
| const closeSelectors = [ | |
| 'button[aria-label="Close"]', | |
| 'button[aria-label*="close" i]', | |
| 'button[aria-label*="dismiss" i]', | |
| 'div[role="dialog"] button[type="button"]', | |
| 'div[role="dialog"] button' | |
| ]; | |
| // Prefer the first button that looks like an X / Close | |
| for (const sel of closeSelectors) { | |
| const btns = qa(sel).filter(b => b && b.closest('div[role="dialog"]')); | |
| for (const b of btns) { | |
| const aria = (b.getAttribute('aria-label') || '').toLowerCase(); | |
| const txt = (b.textContent || '').toLowerCase(); | |
| const hasSvg = !!q('svg', b); | |
| if (aria.includes('close') || txt.includes('close') || hasSvg) { | |
| try { b.click(); } catch {} | |
| await sleep(CONFIG.Timing.afterCloseDelayMs); | |
| if (!q('div[role="dialog"]')) return true; | |
| } | |
| } | |
| } | |
| // Escape fallback | |
| for (let i = 0; i < 3; i++) { | |
| document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); | |
| await sleep(140); | |
| if (!q('div[role="dialog"]')) return true; | |
| } | |
| // Overlay click fallback (best-effort) | |
| const overlay = q('.fixed.z-modal.inset-0'); | |
| if (overlay) { | |
| try { overlay.click(); } catch {} | |
| await sleep(180); | |
| } | |
| return !q('div[role="dialog"]'); | |
| } | |
| // --------------------------- | |
| // FILENAME NORMALIZATION + COLLISIONS | |
| // --------------------------- | |
| const VALID_EXTS = [ | |
| 'md','txt','csv','json','xml','pdf','docx','doc','xlsx','xls','srt','html','htm', | |
| 'js','ts','jsx','tsx','py','css','scss','yml','yaml','ini','log','url' | |
| ]; | |
| function getExtension(name) { | |
| const m = (name || '').match(/\.([a-zA-Z0-9]+)$/); | |
| return m ? m[1].toLowerCase() : null; | |
| } | |
| function hasValidExtension(name) { | |
| const ext = getExtension(name); | |
| return !!(ext && VALID_EXTS.includes(ext)); | |
| } | |
| function collapseDuplicateExtensions(filename) { | |
| const known = [...new Set(VALID_EXTS)]; | |
| let out = filename; | |
| for (const ext of known) { | |
| // .ext.ext.ext -> .ext | |
| const rep = new RegExp(`(\\.${ext})+$`, 'gi'); | |
| out = out.replace(rep, `.${ext}`); | |
| // .ext + ext garbage (e.g., .csvcsv____) -> .ext | |
| const garb = new RegExp(`\\.${ext}${ext}[A-Za-z0-9_\\-]*$`, 'gi'); | |
| out = out.replace(garb, `.${ext}`); | |
| } | |
| // "txt9_.csv" style trailing garbage | |
| out = out.replace(/\d+_\.(csv|txt|md|json|xml|pdf)$/i, '.$1'); | |
| return out; | |
| } | |
| function normalizeFilename(rawName) { | |
| if (!rawName || typeof rawName !== 'string') return 'unnamed_file'; | |
| let name = rawName.trim(); | |
| // Illegal characters + control chars | |
| name = name.replace(/[\\/:*?"<>|]/g, '_').replace(/[\x00-\x1F]/g, ''); | |
| // Collapse whitespace/underscores | |
| name = name.replace(/\s+/g, ' ').replace(/_+/g, '_').replace(/[ _]+/g, '_'); | |
| // Windows trailing dots/spaces, leading dots/spaces | |
| name = name.replace(/[. ]+$/, '').replace(/^[. ]+/, ''); | |
| name = collapseDuplicateExtensions(name); | |
| if (!name || name === '_') name = 'unnamed_file'; | |
| return name; | |
| } | |
| function ensureExtension(name, detectedType, fallbackExt = 'txt') { | |
| if (hasValidExtension(name)) return name; | |
| const type = (detectedType || '').toLowerCase(); | |
| const map = { | |
| md: 'md', | |
| markdown: 'md', | |
| txt: 'txt', | |
| text: 'txt', | |
| csv: 'csv', | |
| pdf: 'pdf', | |
| docx: 'docx', | |
| doc: 'doc', | |
| xlsx: 'xlsx', | |
| xls: 'xls', | |
| json: 'json', | |
| xml: 'xml', | |
| html: 'html', | |
| htm: 'htm', | |
| srt: 'srt', | |
| url: 'url' | |
| }; | |
| const ext = map[type] || fallbackExt; | |
| return `${name}.${ext}`; | |
| } | |
| function handleCollision(filename, usedLower) { | |
| const lower = filename.toLowerCase(); | |
| if (!usedLower.has(lower)) { | |
| usedLower.add(lower); | |
| return filename; | |
| } | |
| const ext = getExtension(filename); | |
| const base = ext ? filename.slice(0, -(ext.length + 1)) : filename; | |
| let n = 2; | |
| let candidate; | |
| do { | |
| candidate = ext ? `${base}__${n}.${ext}` : `${base}__${n}`; | |
| n++; | |
| } while (usedLower.has(candidate.toLowerCase())); | |
| usedLower.add(candidate.toLowerCase()); | |
| return candidate; | |
| } | |
| function safeProjectName(name) { | |
| const cleaned = (name || 'Claude_Project') | |
| .replace(/[\\/:*?"<>|]/g, '_') | |
| .replace(/\s+/g, ' ') | |
| .trim() | |
| .slice(0, 120); | |
| return cleaned || 'Claude_Project'; | |
| } | |
| // --------------------------- | |
| // NETWORK HELPERS | |
| // --------------------------- | |
| async function fetchBytes(url) { | |
| const u = resolveUrl(url); | |
| const res = await fetch(u, { credentials: 'include' }); | |
| if (!res.ok) throw new Error(`Download failed (${res.status})`); | |
| const buf = await res.arrayBuffer(); | |
| return new Uint8Array(buf); | |
| } | |
| async function fetchText(url) { | |
| const u = resolveUrl(url); | |
| const res = await fetch(u, { credentials: 'include' }); | |
| if (!res.ok) throw new Error(`Download failed (${res.status})`); | |
| return await res.text(); | |
| } | |
| // --------------------------- | |
| // PROJECT METADATA (NAME + INSTRUCTIONS) | |
| // --------------------------- | |
| function getProjectName() { | |
| const h1 = q('h1'); | |
| if (h1) { | |
| const text = (h1.textContent || '').trim(); | |
| if (text && text.length < 200 && text !== 'Claude') return text; | |
| } | |
| const match = window.location.pathname.match(/\/project\/([^/]+)/); | |
| if (match) return decodeURIComponent(match[1]).replace(/-/g, ' '); | |
| return 'Untitled Project'; | |
| } | |
| function getProjectInstructions() { | |
| log.dbg('Searching for project instructions...'); | |
| // Heuristic: right sidebar often contains "Instructions" label | |
| const candidates = qa('div, span, h2, h3, h4, p') | |
| .filter(el => (el.textContent || '').trim() === 'Instructions'); | |
| for (const header of candidates) { | |
| const probes = []; | |
| if (header.nextElementSibling) probes.push(header.nextElementSibling); | |
| if (header.parentElement?.nextElementSibling) probes.push(header.parentElement.nextElementSibling); | |
| if (header.parentElement) probes.push(header.parentElement); | |
| for (const p of probes) { | |
| if (!p) continue; | |
| const txt = (p.textContent || '').trim(); | |
| if (txt.length > 20 && txt.length < CONFIG.Content.maxInstructionChars) { | |
| if (/^\s*(files|memory)\b/i.test(txt)) continue; | |
| if (txt.startsWith('#') || txt.includes('##') || txt.includes('\n')) { | |
| log.dbg('Instructions found via header adjacency.'); | |
| return txt; | |
| } | |
| } | |
| } | |
| } | |
| // Fallback: look for a large markdown block that is NOT in a dialog and NOT a chat message | |
| const blocks = qa('div, p, span') | |
| .filter(el => !el.closest('div[role="dialog"]')) | |
| .filter(el => !el.closest('[data-testid*="message" i]')) | |
| .map(el => ((el.textContent || '').trim())) | |
| .filter(t => t.length > 80 && t.length < CONFIG.Content.maxInstructionChars) | |
| .filter(t => (t.startsWith('#') && t.includes('##')) || (t.toLowerCase().includes('purpose') && t.includes('\n'))); | |
| if (blocks.length) { | |
| blocks.sort((a, b) => b.length - a.length); | |
| log.dbg('Instructions found via large markdown block fallback.'); | |
| return blocks[0]; | |
| } | |
| log.dbg('No instructions found.'); | |
| return null; | |
| } | |
| function generateInstructionsMarkdown(projectName, instructions) { | |
| const ts = nowISO(); | |
| const header = `# ${projectName}\n\n`; | |
| const meta = `> Exported on: ${ts}\n\n---\n\n`; | |
| if (instructions && instructions.trim()) { | |
| return header + meta + `## Project Instructions\n\n${instructions.trim()}\n`; | |
| } | |
| return header + meta + `## Project Instructions\n\n*No custom instructions set for this project.*\n`; | |
| } | |
| // --------------------------- | |
| // FILE LIST DISCOVERY (DEDUPED) | |
| // --------------------------- | |
| function extractTypeFromCard(card) { | |
| // Badge like "MD", "TXT", "PDF", "CSV" | |
| const badge = q('p.uppercase', card) || qa('p', card).find(p => (p.className || '').includes('uppercase')); | |
| const badgeTxt = (badge?.textContent || '').trim().toLowerCase(); | |
| if (badgeTxt && badgeTxt.length <= 8) return badgeTxt; | |
| // Infer from filename | |
| const h3 = q('h3', card); | |
| const name = (h3?.textContent || '').trim(); | |
| const ext = getExtension(name); | |
| return ext || 'txt'; | |
| } | |
| function extractNameFromCard(card) { | |
| const h3 = q('h3', card); | |
| if (h3) return (h3.textContent || '').trim(); | |
| const pdfImg = q('img[alt$=".pdf"]', card); | |
| if (pdfImg) return (pdfImg.getAttribute('alt') || '').trim(); | |
| const pdfTest = q('div[data-testid$=".pdf"]', card); | |
| if (pdfTest) return (pdfTest.getAttribute('data-testid') || '').trim(); | |
| const txt = (card.textContent || '').trim().split('\n').map(s => s.trim()).filter(Boolean)[0]; | |
| return txt || null; | |
| } | |
| function isLikelyPdfCard(card, name, type) { | |
| if ((type || '').toLowerCase() === 'pdf') return true; | |
| if ((name || '').toLowerCase().endsWith('.pdf')) return true; | |
| return !!(q('img[alt$=".pdf"]', card) || q('div[data-testid$=".pdf"]', card)); | |
| } | |
| function findFileCardClickable(card) { | |
| const b = q('button', card); | |
| return b || card.closest('button') || card; | |
| } | |
| async function discoverAllFileCards(uiStatus, isCancelledFn) { | |
| uiStatus('Scanning files…', 5); | |
| const found = []; | |
| const grid = q('ul.grid'); | |
| if (grid) { | |
| log.dbg('Strategy B: ul.grid found, attempting lazy-load scroll.'); | |
| await scrollToLoadAllGridCards(grid, uiStatus, isCancelledFn); | |
| const gridCards = qa(':scope > div', grid); | |
| log.dbg(`Strategy B: grid cards after scroll: ${gridCards.length}`); | |
| for (const container of gridCards) { | |
| const clickEl = findFileCardClickable(container); | |
| const domName = extractNameFromCard(container) || extractNameFromCard(clickEl); | |
| if (!domName) continue; | |
| const type = extractTypeFromCard(container); | |
| found.push({ | |
| element: clickEl, | |
| domFilename: domName, | |
| detectedType: type, | |
| isPdf: isLikelyPdfCard(container, domName, type), | |
| discovery: 'strategyB_ul.grid' | |
| }); | |
| } | |
| } else { | |
| const v1Buttons = qa('button.rounded-lg').filter(btn => q('h3', btn)); | |
| if (v1Buttons.length) { | |
| log.dbg(`Strategy A: found ${v1Buttons.length} rounded-lg file buttons.`); | |
| for (const btn of v1Buttons) { | |
| const domName = extractNameFromCard(btn); | |
| if (!domName) continue; | |
| const type = extractTypeFromCard(btn); | |
| found.push({ | |
| element: btn, | |
| domFilename: domName, | |
| detectedType: type, | |
| isPdf: isLikelyPdfCard(btn, domName, type), | |
| discovery: 'strategyA_button.rounded-lg' | |
| }); | |
| } | |
| } else { | |
| const broad = qa('button').filter(b => q('h3', b)); | |
| log.dbg(`Strategy C: broad scan found ${broad.length} buttons w/ h3.`); | |
| for (const b of broad) { | |
| const domName = extractNameFromCard(b); | |
| if (!domName) continue; | |
| const type = extractTypeFromCard(b); | |
| found.push({ | |
| element: b, | |
| domFilename: domName, | |
| detectedType: type, | |
| isPdf: isLikelyPdfCard(b, domName, type), | |
| discovery: 'strategyC_broad' | |
| }); | |
| } | |
| } | |
| } | |
| // De-dupe by canonical name + type + pdfness (NOT by discovery) | |
| const seen = new Set(); | |
| const deduped = []; | |
| for (const f of found) { | |
| const canonName = normalizeFilename((f.domFilename || '').trim()).toLowerCase(); | |
| const canonType = (f.detectedType || '').trim().toLowerCase(); | |
| const key = `${canonName}|${canonType}|${f.isPdf ? 'pdf' : 'n'}`; | |
| if (seen.has(key)) continue; | |
| seen.add(key); | |
| deduped.push(f); | |
| } | |
| log.ok(`Discovered ${deduped.length} file card(s).`); | |
| return deduped; | |
| } | |
| async function scrollToLoadAllGridCards(grid, uiStatus, isCancelledFn) { | |
| let lastCount = -1; | |
| let stable = 0; | |
| for (let attempt = 1; attempt <= CONFIG.Timing.maxScrollAttempts; attempt++) { | |
| if (isCancelledFn()) throw new Error('cancelled'); | |
| try { grid.scrollTop = grid.scrollHeight; } catch {} | |
| try { window.scrollTo(0, document.body.scrollHeight); } catch {} | |
| await sleep(CONFIG.Timing.scrollWaitMs); | |
| const count = qa(':scope > div', grid).length; | |
| uiStatus(`Loading files… (${count})`, Math.min(20, 5 + attempt)); | |
| log.dbg(`Grid scroll attempt ${attempt}: ${count} card(s).`); | |
| if (count === lastCount) { | |
| stable++; | |
| if (stable >= 3) break; | |
| } else { | |
| stable = 0; | |
| } | |
| lastCount = count; | |
| } | |
| } | |
| // --------------------------- | |
| // MODAL CONTENT EXTRACTION | |
| // --------------------------- | |
| function isTextExtractedPdf(dialogText) { | |
| return /Formatting may be inconsistent from source/i.test(dialogText || ''); | |
| } | |
| function getTextExtractedContent(dialog) { | |
| const selectors = [ | |
| 'div[class*="whitespace-pre"]', | |
| 'div[class*="font-mono"]', | |
| 'div[class*="overflow-y-auto"]', | |
| 'div[class*="overflow-auto"] div[class*="whitespace"]', | |
| 'pre', | |
| 'code' | |
| ]; | |
| for (const sel of selectors) { | |
| const el = q(sel, dialog); | |
| if (el) { | |
| const t = (el.textContent || '').trim(); | |
| if (t.length > 150) return t; | |
| } | |
| } | |
| const all = qa('div', dialog); | |
| let best = ''; | |
| for (const d of all) { | |
| const t = (d.textContent || '').trim(); | |
| if (t.length < 500) continue; | |
| if (/Export|Download|Close|Formatting may be inconsistent/i.test(t)) continue; | |
| if (t.length > best.length) best = t; | |
| } | |
| return best || null; | |
| } | |
| function extractTextPreview(dialog) { | |
| const selectors = [ | |
| 'div.whitespace-pre-wrap.break-all.font-mono', | |
| 'div.whitespace-pre-wrap', | |
| 'pre code', | |
| 'pre', | |
| 'code[class*="language-"]', | |
| 'div[class*="whitespace-pre-wrap"]' | |
| ]; | |
| for (const sel of selectors) { | |
| const el = q(sel, dialog); | |
| if (!el) continue; | |
| const t = (el.textContent || '').trim(); | |
| if (t.length >= CONFIG.Content.minTextChars) return t; | |
| } | |
| const raw = (dialog.textContent || '').split('\n').map(s => s.trim()).filter(Boolean); | |
| const filtered = raw | |
| .filter(line => line.length >= 3) | |
| .filter(line => !/^(Close|Download|Export|Cancel|OK)$/i.test(line)) | |
| .filter(line => !/Formatting may be inconsistent/i.test(line)) | |
| .join('\n'); | |
| return filtered.length >= CONFIG.Content.minTextChars ? filtered : null; | |
| } | |
| function findPdfDownloadUrl(dialog) { | |
| const a1 = q('a[href*="/document_pdf"]', dialog); | |
| if (a1?.href) return a1.href; | |
| const anchors = qa('a[href]', dialog); | |
| const pdfA = anchors.find(a => { | |
| const href = a.getAttribute('href') || ''; | |
| return /document_pdf/i.test(href) || /\.pdf(\?|$)/i.test(href); | |
| }); | |
| if (pdfA) return resolveUrl(pdfA.getAttribute('href') || pdfA.href); | |
| return null; | |
| } | |
| function findGenericDownloadUrl(dialog) { | |
| const anchors = qa('a[href]', dialog); | |
| const a = anchors.find(x => { | |
| const txt = (x.textContent || '').trim(); | |
| const href = x.getAttribute('href') || ''; | |
| const hasDownloadAttr = x.getAttribute('download') !== null; | |
| return ( | |
| /download|save|export/i.test(txt) || | |
| hasDownloadAttr || | |
| /\.([a-z0-9]{2,6})(\?|$)/i.test(href) || | |
| /\/api\//i.test(href) | |
| ); | |
| }); | |
| if (a) return resolveUrl(a.getAttribute('href') || a.href); | |
| const btns = qa('button, [role="button"]', dialog); | |
| const b = btns.find(x => /download|save|export/i.test((x.textContent || x.getAttribute('aria-label') || ''))); | |
| if (b) { | |
| const nested = q('a[href]', b); | |
| if (nested) return resolveUrl(nested.getAttribute('href') || nested.href); | |
| } | |
| return null; | |
| } | |
| function tryReconstructCsvFromTable(dialog) { | |
| const table = q('table', dialog); | |
| if (!table) return null; | |
| const rows = []; | |
| qa('tr', table).forEach(tr => { | |
| const cells = qa('td,th', tr).map(cell => { | |
| return (cell.textContent || '').trim().replace(/,/g, ';'); | |
| }); | |
| if (cells.length) rows.push(cells.join(',')); | |
| }); | |
| return rows.length ? rows.join('\n') : null; | |
| } | |
| // --------------------------- | |
| // EXPORT ONE FILE | |
| // --------------------------- | |
| async function exportOneFile(fileCard, usedNamesLower, uiStatus, isCancelledFn) { | |
| const { element, domFilename, detectedType, isPdf, discovery } = fileCard; | |
| const rawName = (domFilename || 'unnamed_file').trim(); | |
| const typeGuess = (detectedType || '').toLowerCase() || 'txt'; | |
| let baseName = normalizeFilename(rawName); | |
| baseName = ensureExtension(baseName, typeGuess, 'txt'); | |
| baseName = handleCollision(baseName, usedNamesLower); | |
| const outputs = []; | |
| const metaBase = { | |
| originalDomFilename: rawName, | |
| normalizedFilename: baseName, | |
| detectedType: typeGuess, | |
| isPdf: !!isPdf, | |
| discovery, | |
| openedAt: nowISO(), | |
| sourceUrl: null, | |
| exportMethod: null, | |
| status: 'pending', | |
| bytes: null, | |
| error: null, | |
| notes: null | |
| }; | |
| uiStatus(`Opening: ${rawName}`, null); | |
| clickBest(element); | |
| const dialog = await waitForDialog(); | |
| if (!dialog) { | |
| const m = { ...metaBase, status: 'failed', exportMethod: 'open_failed', error: 'Modal did not open' }; | |
| log.file('failed', baseName, m); | |
| return { outputs: [], meta: m }; | |
| } | |
| if (isCancelledFn()) throw new Error('cancelled'); | |
| const dialogText = (dialog.textContent || ''); | |
| const hasPdfBanner = isTextExtractedPdf(dialogText); | |
| const pdfUrl = findPdfDownloadUrl(dialog); | |
| const genericUrl = findGenericDownloadUrl(dialog); | |
| let downloadedPrimary = false; | |
| try { | |
| if (isPdf || typeGuess === 'pdf' || rawName.toLowerCase().endsWith('.pdf')) { | |
| // Try to download real PDF bytes FIRST | |
| if (pdfUrl) { | |
| uiStatus(`Downloading PDF: ${rawName}`, null); | |
| const bytes = await fetchBytes(pdfUrl); | |
| const pdfName = handleCollision( | |
| ensureExtension(normalizeFilename(rawName), 'pdf', 'pdf'), | |
| usedNamesLower | |
| ); | |
| outputs.push({ | |
| name: pdfName, | |
| bytes, | |
| meta: { | |
| ...metaBase, | |
| normalizedFilename: pdfName, | |
| sourceUrl: pdfUrl, | |
| exportMethod: 'pdf_download_url', | |
| status: 'success', | |
| bytes: bytes.length | |
| } | |
| }); | |
| downloadedPrimary = true; | |
| log.file('success', pdfName, { method: 'pdf_download_url', bytes: bytes.length }); | |
| } else if (genericUrl && /\.pdf(\?|$)/i.test(genericUrl)) { | |
| uiStatus(`Downloading PDF: ${rawName}`, null); | |
| const bytes = await fetchBytes(genericUrl); | |
| const pdfName = handleCollision( | |
| ensureExtension(normalizeFilename(rawName), 'pdf', 'pdf'), | |
| usedNamesLower | |
| ); | |
| outputs.push({ | |
| name: pdfName, | |
| bytes, | |
| meta: { | |
| ...metaBase, | |
| normalizedFilename: pdfName, | |
| sourceUrl: genericUrl, | |
| exportMethod: 'pdf_generic_download', | |
| status: 'success', | |
| bytes: bytes.length | |
| } | |
| }); | |
| downloadedPrimary = true; | |
| log.file('success', pdfName, { method: 'pdf_generic_download', bytes: bytes.length }); | |
| } | |
| // ONLY write extracted text if we could NOT download the real PDF | |
| if (hasPdfBanner && !downloadedPrimary) { | |
| const extracted = getTextExtractedContent(dialog); | |
| if (extracted && extracted.trim().length >= 40) { | |
| const extractedHeader = | |
| `[Text extracted from PDF by Claude — formatting may be inconsistent from source]\n` + | |
| `[Original file: ${rawName}]\n` + | |
| (pdfUrl || genericUrl ? `[Download URL (if present): ${resolveUrl(pdfUrl || genericUrl)}]\n` : '') + | |
| `\n`; | |
| const txtNameBase = normalizeFilename(rawName); | |
| const txtName = handleCollision( | |
| ensureExtension(`${txtNameBase}.pdf_extracted`, 'txt', 'txt'), | |
| usedNamesLower | |
| ); | |
| outputs.push({ | |
| name: txtName, | |
| text: extractedHeader + extracted.trim(), | |
| meta: { | |
| ...metaBase, | |
| normalizedFilename: txtName, | |
| sourceUrl: pdfUrl || genericUrl || null, | |
| exportMethod: 'pdf_text_extracted', | |
| status: 'success', | |
| bytes: (extractedHeader.length + extracted.length) | |
| } | |
| }); | |
| downloadedPrimary = true; | |
| log.file('success', txtName, { method: 'pdf_text_extracted', chars: extracted.length }); | |
| } else { | |
| log.warn(`PDF extraction banner detected but no extracted text found for "${rawName}".`); | |
| } | |
| } | |
| // If nothing could be exported, fall back to URL shortcut or a single failure note | |
| if (!outputs.length) { | |
| if (pdfUrl || genericUrl) { | |
| const urlTxt = `[InternetShortcut]\nURL=${resolveUrl(pdfUrl || genericUrl)}\n`; | |
| const urlName = handleCollision( | |
| ensureExtension(normalizeFilename(rawName), 'url', 'url'), | |
| usedNamesLower | |
| ); | |
| outputs.push({ | |
| name: urlName, | |
| text: urlTxt, | |
| meta: { | |
| ...metaBase, | |
| normalizedFilename: urlName, | |
| sourceUrl: pdfUrl || genericUrl, | |
| exportMethod: 'url_shortcut', | |
| status: 'skipped', | |
| notes: 'No downloadable PDF bytes detected; saved URL shortcut.' | |
| } | |
| }); | |
| log.file('skipped', urlName, { method: 'url_shortcut' }); | |
| } else { | |
| const noteName = handleCollision( | |
| ensureExtension(normalizeFilename(rawName), 'txt', 'txt'), | |
| usedNamesLower | |
| ); | |
| outputs.push({ | |
| name: noteName, | |
| text: `No preview and no downloadable link detected for "${rawName}".`, | |
| meta: { | |
| ...metaBase, | |
| normalizedFilename: noteName, | |
| exportMethod: 'unexportable', | |
| status: 'failed', | |
| error: 'No PDF URL and no extracted text.' | |
| } | |
| }); | |
| log.file('failed', noteName, { method: 'unexportable' }); | |
| } | |
| } | |
| } else { | |
| // Non-PDF handling: prefer preview text (best representation in Claude UI) | |
| let contentText = null; | |
| if (typeGuess === 'csv') { | |
| const tableCsv = tryReconstructCsvFromTable(dialog); | |
| if (tableCsv && tableCsv.trim().length >= CONFIG.Content.minTextChars) { | |
| contentText = tableCsv; | |
| } | |
| } | |
| if (!contentText) contentText = extractTextPreview(dialog); | |
| if (contentText && contentText.trim().length >= CONFIG.Content.minTextChars) { | |
| const outName = baseName; | |
| outputs.push({ | |
| name: outName, | |
| text: contentText, | |
| meta: { | |
| ...metaBase, | |
| normalizedFilename: outName, | |
| exportMethod: typeGuess === 'csv' ? 'text_or_table_scrape' : 'text_scrape', | |
| status: 'success', | |
| bytes: contentText.length | |
| } | |
| }); | |
| downloadedPrimary = true; | |
| log.file('success', outName, { method: outputs[0].meta.exportMethod, chars: contentText.length }); | |
| } else { | |
| // If no preview text, try download URL | |
| const dlUrl = genericUrl; | |
| if (dlUrl) { | |
| uiStatus(`Downloading: ${rawName}`, null); | |
| const ext = getExtension(baseName) || getExtension(rawName) || ''; | |
| const treatAsText = ['md','txt','csv','json','xml','html','htm','js','ts','py','css','scss','yml','yaml','ini','log'].includes(ext); | |
| if (treatAsText) { | |
| const t = await fetchText(dlUrl); | |
| const outName = baseName; | |
| outputs.push({ | |
| name: outName, | |
| text: t, | |
| meta: { | |
| ...metaBase, | |
| normalizedFilename: outName, | |
| sourceUrl: dlUrl, | |
| exportMethod: 'download_text', | |
| status: 'success', | |
| bytes: t.length | |
| } | |
| }); | |
| downloadedPrimary = true; | |
| log.file('success', outName, { method: 'download_text', chars: t.length }); | |
| } else { | |
| const bytes = await fetchBytes(dlUrl); | |
| const outName = baseName; | |
| outputs.push({ | |
| name: outName, | |
| bytes, | |
| meta: { | |
| ...metaBase, | |
| normalizedFilename: outName, | |
| sourceUrl: dlUrl, | |
| exportMethod: 'download_bytes', | |
| status: 'success', | |
| bytes: bytes.length | |
| } | |
| }); | |
| downloadedPrimary = true; | |
| log.file('success', outName, { method: 'download_bytes', bytes: bytes.length }); | |
| } | |
| } else { | |
| const outName = baseName; | |
| outputs.push({ | |
| name: outName, | |
| text: `No preview and no downloadable link were detected for "${rawName}".`, | |
| meta: { | |
| ...metaBase, | |
| normalizedFilename: outName, | |
| exportMethod: 'unexportable', | |
| status: 'failed', | |
| error: 'No preview content and no download link detected.' | |
| } | |
| }); | |
| log.file('failed', outName, { method: 'unexportable' }); | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| const errMsg = e?.message || String(e); | |
| // If we already got at least one output, DON'T create another duplicate "error file". | |
| if (outputs.length) { | |
| const last = outputs[outputs.length - 1]; | |
| if (last?.meta) { | |
| last.meta.partialError = errMsg; | |
| last.meta.notes = (last.meta.notes ? `${last.meta.notes}\n` : '') + `Non-fatal error after partial export: ${errMsg}`; | |
| } | |
| log.warn(`Non-fatal export error after partial output for "${rawName}": ${errMsg}`); | |
| } else { | |
| const outName = baseName; | |
| outputs.push({ | |
| name: outName, | |
| text: `Export failed for "${rawName}".\n\nError: ${errMsg}`, | |
| meta: { | |
| ...metaBase, | |
| normalizedFilename: outName, | |
| exportMethod: 'error', | |
| status: 'failed', | |
| error: errMsg | |
| } | |
| }); | |
| log.file('failed', outName, { error: errMsg }); | |
| } | |
| } finally { | |
| await closeDialog(); | |
| await sleep(CONFIG.Timing.betweenFilesDelayMs); | |
| } | |
| return { outputs }; | |
| } | |
| // --------------------------- | |
| // ZIP BUILD | |
| // --------------------------- | |
| function pad2(n) { return String(n).padStart(2, '0'); } | |
| function zipTimestamp() { | |
| const d = new Date(); | |
| return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}_${pad2(d.getHours())}${pad2(d.getMinutes())}`; | |
| } | |
| function encodeText(s) { | |
| return new TextEncoder().encode(s || ''); | |
| } | |
| // --------------------------- | |
| // UI | |
| // --------------------------- | |
| function initializeUI() { | |
| if (typeof fflate === 'undefined' || typeof saveAs === 'undefined') { | |
| log.err('fflate or FileSaver missing. @require failed?'); | |
| return null; | |
| } | |
| const existing = q('#cpe2026-corner'); | |
| if (existing) existing.remove(); | |
| const existingModal = q('#cpe2026-modal'); | |
| if (existingModal) existingModal.remove(); | |
| const ICONS = { | |
| DOWNLOAD: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" | |
| stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> | |
| <polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`, | |
| SPINNER: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" | |
| stroke-linecap="round" stroke-linejoin="round" class="cpe2026-spin"> | |
| <line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/> | |
| <line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/> | |
| <line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/> | |
| <line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/></svg>`, | |
| SUCCESS: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" | |
| stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`, | |
| ERROR: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" | |
| stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/> | |
| <line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>`, | |
| CANCEL: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" | |
| stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/> | |
| <line x1="6" y1="6" x2="18" y2="18"/></svg>` | |
| }; | |
| const corner = document.createElement('div'); | |
| corner.id = 'cpe2026-corner'; | |
| corner.innerHTML = ` | |
| <button id="cpe2026-start" class="cpe2026-btn"> | |
| <span class="cpe2026-ico">${ICONS.DOWNLOAD}</span> | |
| <span>Export project</span> | |
| </button> | |
| `; | |
| document.body.appendChild(corner); | |
| const modal = document.createElement('div'); | |
| modal.id = 'cpe2026-modal'; | |
| modal.innerHTML = ` | |
| <div id="cpe2026-card"> | |
| <div id="cpe2026-main"><span class="cpe2026-ico"></span><span class="cpe2026-text"></span></div> | |
| <div id="cpe2026-bar"><div id="cpe2026-barfill"></div></div> | |
| <div id="cpe2026-sub"></div> | |
| <button id="cpe2026-cancel">Cancel</button> | |
| </div> | |
| `; | |
| document.body.appendChild(modal); | |
| GM_addStyle(` | |
| @keyframes cpe2026spin{from{transform:rotate(0)}to{transform:rotate(360deg)}} | |
| .cpe2026-spin{animation:cpe2026spin 1s linear infinite} | |
| #cpe2026-corner{position:fixed;bottom:25px;right:25px;z-index:${CONFIG.UI.cornerZ};display:none} | |
| #cpe2026-corner.visible{display:block} | |
| .cpe2026-btn{display:flex;align-items:center;gap:12px;border:1px solid rgba(255,255,255,.15); | |
| border-radius:12px;background:rgba(30,30,30,.85);backdrop-filter:blur(10px); | |
| color:#fff;padding:0 24px;cursor:pointer;height:54px;transition:all .25s cubic-bezier(.2,.8,.2,1)} | |
| .cpe2026-btn:hover{transform:translateY(-3px);background:rgba(40,40,40,.95)} | |
| .cpe2026-ico{display:flex;align-items:center;width:20px;height:20px} | |
| #cpe2026-modal{position:fixed;inset:0;z-index:${CONFIG.UI.modalZ};display:flex;justify-content:center;align-items:center; | |
| background:rgba(10,10,10,.75);backdrop-filter:blur(8px);opacity:0;pointer-events:none; | |
| transition:opacity .35s cubic-bezier(.2,.8,.2,1)} | |
| #cpe2026-modal.active{opacity:1;pointer-events:auto} | |
| #cpe2026-card{display:flex;flex-direction:column;align-items:center;gap:18px;background:#111; | |
| padding:40px 56px;border-radius:18px;width:460px;border:1px solid rgba(255,255,255,.15)} | |
| #cpe2026-main{display:flex;align-items:center;gap:16px;font-size:20px;font-weight:650;color:#fff} | |
| #cpe2026-bar{width:100%;height:8px;background:rgba(255,255,255,.08);border-radius:4px;overflow:hidden} | |
| #cpe2026-barfill{width:0%;height:100%;background:#fff;transition:width .22s ease-out} | |
| #cpe2026-sub{height:20px;font-size:14px;color:rgba(255,255,255,.75);text-align:center;width:100%; | |
| white-space:nowrap;overflow:hidden;text-overflow:ellipsis} | |
| #cpe2026-cancel{border:none;background:transparent;color:rgba(255,255,255,.7);padding:8px 16px;border-radius:8px;cursor:pointer} | |
| #cpe2026-cancel:hover{background:rgba(255,255,255,.1);color:#fff} | |
| `); | |
| const startBtn = q('#cpe2026-start', corner); | |
| const iconEl = q('#cpe2026-main .cpe2026-ico', modal); | |
| const mainText = q('#cpe2026-main .cpe2026-text', modal); | |
| const subText = q('#cpe2026-sub', modal); | |
| const barFill = q('#cpe2026-barfill', modal); | |
| const cancelBtn = q('#cpe2026-cancel', modal); | |
| let isCancelled = false; | |
| let closeTimer = null; | |
| function setUI(state, main = '', sub = '', pct = 0) { | |
| clearTimeout(closeTimer); | |
| if (state === 'idle') { | |
| modal.classList.remove('active'); | |
| return; | |
| } | |
| modal.classList.add('active'); | |
| const icons = { | |
| processing: ICONS.SPINNER, | |
| zipping: ICONS.SPINNER, | |
| success: ICONS.SUCCESS, | |
| error: ICONS.ERROR, | |
| cancelled: ICONS.CANCEL | |
| }; | |
| iconEl.innerHTML = icons[state] || ''; | |
| mainText.textContent = main; | |
| subText.textContent = sub; | |
| barFill.style.width = `${Math.max(0, Math.min(100, pct || 0))}%`; | |
| cancelBtn.style.display = (state === 'processing') ? 'block' : 'none'; | |
| if (state === 'success') closeTimer = setTimeout(() => setUI('idle'), 2400); | |
| if (state === 'cancelled') closeTimer = setTimeout(() => setUI('idle'), 1500); | |
| if (state === 'error') closeTimer = setTimeout(() => setUI('idle'), 4000); | |
| } | |
| cancelBtn.addEventListener('click', () => { isCancelled = true; }); | |
| function updateStatus(main, sub, pct) { | |
| if (!modal.classList.contains('active')) setUI('processing', main || 'Working…', sub || '', pct || 0); | |
| else { | |
| mainText.textContent = main || mainText.textContent; | |
| subText.textContent = sub || subText.textContent; | |
| if (typeof pct === 'number') barFill.style.width = `${Math.max(0, Math.min(100, pct))}%`; | |
| } | |
| log.info(`${main}${sub ? ` — ${sub}` : ''}`); | |
| } | |
| function showButtonIfRelevant() { | |
| const isProjectPage = window.location.pathname.includes('/project/'); | |
| const hasFiles = !!(q('ul.grid') || q('button.rounded-lg h3') || q('button h3')); | |
| const shouldShow = CONFIG.UI.showOnProjectOnly ? isProjectPage : (isProjectPage || hasFiles); | |
| corner.classList.toggle('visible', shouldShow); | |
| } | |
| return { | |
| startBtn, | |
| setUI, | |
| updateStatus, | |
| showButtonIfRelevant, | |
| isCancelled: () => isCancelled, | |
| resetCancelled: () => { isCancelled = false; } | |
| }; | |
| } | |
| // --------------------------- | |
| // MAIN EXPORT PIPELINE | |
| // --------------------------- | |
| async function runExport(ui) { | |
| ui.resetCancelled(); | |
| const projectName = getProjectName(); | |
| const instructions = getProjectInstructions(); | |
| const instructionsMd = generateInstructionsMarkdown(projectName, instructions); | |
| ui.setUI('processing', 'Preparing…', 'Collecting project metadata…', 0); | |
| const usedNamesLower = new Set(); | |
| const allCollected = []; | |
| allCollected.push({ | |
| name: '_meta/PROJECT_INSTRUCTIONS.md', | |
| text: instructionsMd, | |
| meta: { | |
| kind: 'project_instructions', | |
| status: 'success', | |
| exportMethod: 'sidebar_scrape', | |
| bytes: instructionsMd.length | |
| } | |
| }); | |
| ui.updateStatus('Scanning…', 'Discovering project files…', 5); | |
| const fileCards = await discoverAllFileCards( | |
| (sub, pct) => ui.updateStatus('Scanning…', sub, typeof pct === 'number' ? pct : undefined), | |
| ui.isCancelled | |
| ); | |
| if (!fileCards.length) { | |
| ui.setUI('error', 'No files found', 'Could not detect any file tiles in this project.', 100); | |
| log.err('No file cards found. If Claude changed DOM, we’ll need new selectors.'); | |
| return; | |
| } | |
| const total = fileCards.length; | |
| let success = 0, failed = 0, skipped = 0; | |
| let pdfBytesCount = 0, pdfExtractedCount = 0; | |
| for (let i = 0; i < total; i++) { | |
| if (ui.isCancelled()) throw new Error('cancelled'); | |
| const f = fileCards[i]; | |
| const pct = Math.round(8 + (i / total) * 84); | |
| ui.updateStatus('Exporting…', `${i + 1}/${total}: ${f.domFilename}`, pct); | |
| const { outputs } = await exportOneFile( | |
| f, | |
| usedNamesLower, | |
| (sub) => ui.updateStatus('Exporting…', sub, pct), | |
| ui.isCancelled | |
| ); | |
| for (const out of outputs) { | |
| allCollected.push(out); | |
| const st = out?.meta?.status || 'failed'; | |
| if (st === 'success') success++; | |
| else if (st === 'skipped') skipped++; | |
| else failed++; | |
| const method = out?.meta?.exportMethod || ''; | |
| if (/pdf_download_url|pdf_generic_download/i.test(method)) pdfBytesCount++; | |
| if (method === 'pdf_text_extracted') pdfExtractedCount++; | |
| } | |
| await sleep(CONFIG.Timing.betweenFilesDelayMs); | |
| } | |
| if (ui.isCancelled()) throw new Error('cancelled'); | |
| ui.setUI('zipping', 'Zipping…', 'Building ZIP archive…', 95); | |
| const exportedAt = nowISO(); | |
| const zipName = `${safeProjectName(projectName)}_export_${zipTimestamp()}.zip`; | |
| const exportMetadata = { | |
| exporter: 'Claude Project Export 2026', | |
| exporterVersion: '1.0.2', | |
| exportedAt, | |
| projectName, | |
| url: window.location.href, | |
| summary: { | |
| fileTilesDetected: total, | |
| outputsInZip: allCollected.length, | |
| success, | |
| skipped, | |
| failed, | |
| pdfBytesExported: pdfBytesCount, | |
| pdfTextExtracted: pdfExtractedCount | |
| }, | |
| outputs: allCollected.map(o => ({ | |
| name: o.name, | |
| meta: o.meta || null | |
| })) | |
| }; | |
| allCollected.push({ | |
| name: '_meta/_export_metadata.json', | |
| text: JSON.stringify(exportMetadata, null, 2), | |
| meta: { kind: 'export_metadata', status: 'success', exportMethod: 'generated', bytes: 0 } | |
| }); | |
| const filesToZip = {}; | |
| for (const item of allCollected) { | |
| if (!item || !item.name) continue; | |
| if (item.bytes instanceof Uint8Array) filesToZip[item.name] = item.bytes; | |
| else filesToZip[item.name] = encodeText(item.text || ''); | |
| } | |
| const zipBytes = fflate.zipSync(filesToZip, { level: CONFIG.Zip.level }); | |
| const blob = new Blob([zipBytes], { type: 'application/zip' }); | |
| saveAs(blob, zipName); | |
| ui.setUI('success', 'Done', `${success} exported • ${pdfBytesCount} PDFs • + instructions`, 100); | |
| log.ok('='.repeat(60)); | |
| log.ok('EXPORT COMPLETE'); | |
| log.ok(`Project: ${projectName}`); | |
| log.ok(`ZIP: ${zipName}`); | |
| log.ok(`Tiles detected: ${total}`); | |
| log.ok(`Outputs in ZIP: ${allCollected.length}`); | |
| log.ok(`Success: ${success} | Skipped: ${skipped} | Failed: ${failed}`); | |
| log.ok(`PDF bytes: ${pdfBytesCount} | PDF text-extracted: ${pdfExtractedCount}`); | |
| log.ok('='.repeat(60)); | |
| } | |
| // --------------------------- | |
| // INIT / SPA SENTINEL | |
| // --------------------------- | |
| const ui = initializeUI(); | |
| if (!ui) return; | |
| ui.startBtn.addEventListener('click', async () => { | |
| try { | |
| await runExport(ui); | |
| } catch (e) { | |
| if (String(e || '').toLowerCase().includes('cancelled')) { | |
| ui.setUI('cancelled', 'Cancelled', 'Operation aborted', 100); | |
| log.warn('User cancelled export.'); | |
| } else { | |
| ui.setUI('error', 'Error', e?.message || String(e), 100); | |
| log.err('Export error:', e); | |
| } | |
| } | |
| }); | |
| let lastHref = location.href; | |
| const observer = new MutationObserver(() => { | |
| if (location.href !== lastHref) { | |
| lastHref = location.href; | |
| setTimeout(() => ui.showButtonIfRelevant(), 700); | |
| } else { | |
| ui.showButtonIfRelevant(); | |
| } | |
| }); | |
| observer.observe(document.documentElement, { childList: true, subtree: true }); | |
| ui.showButtonIfRelevant(); | |
| log.ok('Initialized.'); | |
| })(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Claude Project Files Extractor | |
| // @namespace http://tampermonkey.net/ | |
| // @version 4.0.0 | |
| // @description Download/extract all files from a Claude project as a single ZIP - Fixed filenames, PDF support, CSV handling | |
| // @author sharmanhall | |
| // @match https://claude.ai/* | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=claude.ai | |
| // @grant GM_download | |
| // @grant GM_xmlhttpRequest | |
| // @license MIT | |
| // @downloadURL https://update.greasyfork.org/scripts/541467/Claude%20Project%20Files%20Extractor.user.js | |
| // @updateURL https://update.greasyfork.org/scripts/541467/Claude%20Project%20Files%20Extractor.meta.js | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| // ============================================================ | |
| // CHANGELOG v4.0.0 | |
| // ============================================================ | |
| // - Complete rewrite of filename extraction logic | |
| // - Fixed duplicate extension bug (.md.md.md -> .md) | |
| // - Added PDF export via modal download link | |
| // - Added CSV handling with fallback to unexportable status | |
| // - Clean collision handling with __2, __3 suffixes | |
| // - Comprehensive metadata with export method tracking | |
| // - Verbose logging throughout | |
| // ============================================================ | |
| // ============================================================ | |
| // SELECTOR MAP (matched to provided DOM snippets) | |
| // ============================================================ | |
| // FILE_GRID_CONTAINER: ul.grid - contains all file cards | |
| // TEXT_FILE_CARD: [data-testid="file-thumbnail"] button - clickable text file card | |
| // PDF_FILE_CARD: div[data-testid$=".pdf"] button, .group\/thumbnail div[data-testid] button - PDF thumbnails | |
| // FILENAME_H3: h3.text-\[12px\] - filename text in text cards | |
| // TYPE_BADGE: p.uppercase.truncate - file type indicator (md, txt, pdf, csv) | |
| // LINE_COUNT: p.text-\[10px\] - shows "X lines" | |
| // PDF_IMG_ALT: img[alt$=".pdf"] - PDF thumbnail image with filename in alt | |
| // MODAL_DIALOG: [role="dialog"] - opened modal | |
| // PDF_DOWNLOAD_LINK: a[href*="/document_pdf"] - PDF download link in modal | |
| // MODAL_CONTENT: pre code, pre, .whitespace-pre-wrap - text content in modal | |
| // MODAL_CLOSE: button with X icon, first button in modal header | |
| // ============================================================ | |
| const CONFIG = { | |
| SCROLL_WAIT_MS: 1500, | |
| MODAL_WAIT_MS: 2000, | |
| MODAL_CONTENT_WAIT_MS: 1000, | |
| BETWEEN_FILES_MS: 800, | |
| MAX_SCROLL_ATTEMPTS: 20, | |
| MIN_CONTENT_LENGTH: 10 | |
| }; | |
| const LOG_PREFIX = '[Claude Exporter]'; | |
| // Logging utilities | |
| const log = { | |
| info: (msg, ...args) => console.log(`${LOG_PREFIX} ℹ️ ${msg}`, ...args), | |
| success: (msg, ...args) => console.log(`${LOG_PREFIX} ✅ ${msg}`, ...args), | |
| warn: (msg, ...args) => console.warn(`${LOG_PREFIX} ⚠️ ${msg}`, ...args), | |
| error: (msg, ...args) => console.error(`${LOG_PREFIX} ❌ ${msg}`, ...args), | |
| debug: (msg, ...args) => console.log(`${LOG_PREFIX} 🔍 ${msg}`, ...args), | |
| file: (domName, normalizedName, type, strategy, status) => { | |
| const emoji = status === 'success' ? '✅' : status === 'failed' ? '❌' : '⚠️'; | |
| console.log(`${LOG_PREFIX} ${emoji} FILE: "${domName}" → "${normalizedName}" [${type}] via ${strategy} = ${status}`); | |
| } | |
| }; | |
| // ============================================================ | |
| // FILENAME NORMALIZATION | |
| // ============================================================ | |
| /** | |
| * Normalize a filename for cross-platform compatibility | |
| * - Removes illegal characters for Windows/macOS | |
| * - Collapses duplicate extensions | |
| * - Handles collision suffixes properly | |
| */ | |
| function normalizeFilename(rawName) { | |
| if (!rawName || typeof rawName !== 'string') { | |
| return 'unnamed_file'; | |
| } | |
| let name = rawName.trim(); | |
| // Step 1: Remove illegal characters (Windows/macOS) | |
| // Illegal: \ / : * ? " < > | and control chars (0x00-0x1F) | |
| name = name.replace(/[\\/:*?"<>|]/g, '_'); | |
| name = name.replace(/[\x00-\x1F]/g, ''); | |
| // Step 2: Collapse multiple spaces/underscores | |
| name = name.replace(/\s+/g, ' '); | |
| name = name.replace(/_+/g, '_'); | |
| name = name.replace(/[ _]+/g, '_'); | |
| // Step 3: Trim trailing dots and spaces (Windows issue) | |
| name = name.replace(/[. ]+$/, ''); | |
| name = name.replace(/^[. ]+/, ''); | |
| // Step 4: Fix duplicate extensions | |
| name = collapseDuplicateExtensions(name); | |
| // Step 5: Ensure we have something | |
| if (!name || name === '_') { | |
| name = 'unnamed_file'; | |
| } | |
| return name; | |
| } | |
| /** | |
| * Collapse duplicate extensions like .md.md.md -> .md | |
| * Also handles cases like .csvcsv -> .csv | |
| */ | |
| function collapseDuplicateExtensions(filename) { | |
| // Known extensions to check for duplicates | |
| const extensions = ['md', 'txt', 'csv', 'json', 'xml', 'pdf', 'docx', 'doc', 'xlsx', 'xls', 'srt', 'html', 'htm']; | |
| let result = filename; | |
| for (const ext of extensions) { | |
| // Pattern: .ext.ext.ext... at end of filename -> .ext | |
| const repeatedExtPattern = new RegExp(`(\\.${ext})+$`, 'gi'); | |
| result = result.replace(repeatedExtPattern, `.${ext}`); | |
| // Pattern: extextSelect_file or similar garbage | |
| const garbagePattern = new RegExp(`\\.${ext}${ext}[A-Za-z_]*`, 'gi'); | |
| result = result.replace(garbagePattern, `.${ext}`); | |
| } | |
| // Handle weird patterns like "txt9_.csv" -> remove the garbage | |
| result = result.replace(/\d+_\.(csv|txt|md)$/i, '.$1'); | |
| return result; | |
| } | |
| /** | |
| * Get the extension from a filename (lowercase, without dot) | |
| */ | |
| function getExtension(filename) { | |
| const match = filename.match(/\.([a-zA-Z0-9]+)$/); | |
| return match ? match[1].toLowerCase() : null; | |
| } | |
| /** | |
| * Check if filename already has a valid extension | |
| */ | |
| function hasValidExtension(filename) { | |
| const validExts = ['md', 'txt', 'csv', 'json', 'xml', 'pdf', 'docx', 'doc', 'xlsx', 'xls', 'srt', 'html', 'htm', 'js', 'py', 'ts', 'jsx', 'tsx', 'css', 'scss']; | |
| const ext = getExtension(filename); | |
| return ext && validExts.includes(ext); | |
| } | |
| /** | |
| * Add extension only if needed | |
| */ | |
| function ensureExtension(filename, detectedType) { | |
| if (hasValidExtension(filename)) { | |
| return filename; | |
| } | |
| // Map type badge to extension | |
| const typeToExt = { | |
| 'md': 'md', | |
| 'txt': 'txt', | |
| 'text': 'txt', | |
| 'csv': 'csv', | |
| 'pdf': 'pdf', | |
| 'docx': 'docx', | |
| 'doc': 'doc', | |
| 'xlsx': 'xlsx', | |
| 'xls': 'xls', | |
| 'json': 'json', | |
| 'xml': 'xml', | |
| 'html': 'html' | |
| }; | |
| const ext = typeToExt[detectedType?.toLowerCase()] || 'txt'; | |
| return `${filename}.${ext}`; | |
| } | |
| // ============================================================ | |
| // COLLISION HANDLING | |
| // ============================================================ | |
| /** | |
| * Handle filename collisions by adding __2, __3, etc. BEFORE extension | |
| */ | |
| function handleCollision(filename, usedNames) { | |
| if (!usedNames.has(filename.toLowerCase())) { | |
| usedNames.add(filename.toLowerCase()); | |
| return filename; | |
| } | |
| const ext = getExtension(filename); | |
| const base = ext ? filename.slice(0, -(ext.length + 1)) : filename; | |
| let counter = 2; | |
| let newName; | |
| do { | |
| newName = ext ? `${base}__${counter}.${ext}` : `${base}__${counter}`; | |
| counter++; | |
| } while (usedNames.has(newName.toLowerCase())); | |
| usedNames.add(newName.toLowerCase()); | |
| return newName; | |
| } | |
| // ============================================================ | |
| // JSZip LOADER | |
| // ============================================================ | |
| function loadJSZip() { | |
| return new Promise((resolve, reject) => { | |
| if (typeof JSZip !== 'undefined') { | |
| log.debug('JSZip already loaded'); | |
| resolve(); | |
| return; | |
| } | |
| log.info('Loading JSZip from CDN...'); | |
| const script = document.createElement('script'); | |
| script.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js'; | |
| script.onload = () => { | |
| setTimeout(() => { | |
| if (typeof JSZip !== 'undefined') { | |
| log.success('JSZip loaded'); | |
| resolve(); | |
| } else { | |
| reject(new Error('JSZip loaded but not available')); | |
| } | |
| }, 300); | |
| }; | |
| script.onerror = () => reject(new Error('Failed to load JSZip')); | |
| document.head.appendChild(script); | |
| }); | |
| } | |
| // ============================================================ | |
| // DOM UTILITIES | |
| // ============================================================ | |
| function sleep(ms) { | |
| return new Promise(resolve => setTimeout(resolve, ms)); | |
| } | |
| async function waitForElement(selector, parent = document, timeout = 5000) { | |
| const startTime = Date.now(); | |
| while (Date.now() - startTime < timeout) { | |
| const el = parent.querySelector(selector); | |
| if (el) return el; | |
| await sleep(100); | |
| } | |
| return null; | |
| } | |
| async function waitForModal(timeout = CONFIG.MODAL_WAIT_MS) { | |
| const startTime = Date.now(); | |
| while (Date.now() - startTime < timeout) { | |
| const modal = document.querySelector('[role="dialog"]'); | |
| if (modal && modal.offsetHeight > 0) { | |
| await sleep(CONFIG.MODAL_CONTENT_WAIT_MS); | |
| return modal; | |
| } | |
| await sleep(100); | |
| } | |
| return null; | |
| } | |
| async function closeModal() { | |
| log.debug('Closing modal...'); | |
| // Method 1: Find close button (X button in header) | |
| const closeSelectors = [ | |
| '[role="dialog"] button[type="button"]:first-of-type', | |
| '[role="dialog"] button svg[viewBox="0 0 20 20"]', | |
| 'button[aria-label*="close" i]', | |
| 'button[aria-label*="Close" i]' | |
| ]; | |
| for (const selector of closeSelectors) { | |
| try { | |
| const btn = document.querySelector(selector); | |
| if (btn) { | |
| const clickTarget = btn.closest('button') || btn; | |
| clickTarget.click(); | |
| await sleep(300); | |
| if (!document.querySelector('[role="dialog"]')) { | |
| log.debug('Modal closed via button'); | |
| return true; | |
| } | |
| } | |
| } catch (e) { /* continue */ } | |
| } | |
| // Method 2: Escape key | |
| for (let i = 0; i < 3; i++) { | |
| document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); | |
| await sleep(200); | |
| if (!document.querySelector('[role="dialog"]')) { | |
| log.debug('Modal closed via Escape'); | |
| return true; | |
| } | |
| } | |
| // Method 3: Click overlay | |
| const overlay = document.querySelector('.fixed.z-modal.inset-0'); | |
| if (overlay) { | |
| const rect = overlay.getBoundingClientRect(); | |
| overlay.click(); | |
| await sleep(300); | |
| } | |
| const closed = !document.querySelector('[role="dialog"]'); | |
| log.debug(closed ? 'Modal closed' : 'Failed to close modal'); | |
| return closed; | |
| } | |
| // ============================================================ | |
| // FILE DISCOVERY | |
| // ============================================================ | |
| /** | |
| * Find all file cards in the project, handling lazy loading | |
| */ | |
| async function discoverAllFiles(statusCallback) { | |
| log.info('Discovering files...'); | |
| statusCallback?.('Scanning for files...'); | |
| const fileGrid = document.querySelector('ul.grid'); | |
| if (!fileGrid) { | |
| log.error('File grid not found'); | |
| return []; | |
| } | |
| let lastCount = 0; | |
| let stableCount = 0; | |
| // Scroll to load all files | |
| for (let attempt = 0; attempt < CONFIG.MAX_SCROLL_ATTEMPTS; attempt++) { | |
| fileGrid.scrollTop = fileGrid.scrollHeight; | |
| window.scrollTo(0, document.body.scrollHeight); | |
| await sleep(CONFIG.SCROLL_WAIT_MS); | |
| const currentCount = fileGrid.querySelectorAll(':scope > div').length; | |
| log.debug(`Scroll attempt ${attempt + 1}: found ${currentCount} file cards`); | |
| if (currentCount === lastCount) { | |
| stableCount++; | |
| if (stableCount >= 3) { | |
| log.info(`File count stable at ${currentCount}`); | |
| break; | |
| } | |
| } else { | |
| stableCount = 0; | |
| } | |
| lastCount = currentCount; | |
| } | |
| // Now collect all file cards | |
| const fileCards = []; | |
| const cardContainers = fileGrid.querySelectorAll(':scope > div'); | |
| for (const container of cardContainers) { | |
| const fileInfo = extractFileInfo(container); | |
| if (fileInfo) { | |
| fileCards.push({ element: container, ...fileInfo }); | |
| } | |
| } | |
| log.success(`Discovered ${fileCards.length} files`); | |
| return fileCards; | |
| } | |
| /** | |
| * Extract file information from a card element | |
| */ | |
| function extractFileInfo(cardContainer) { | |
| // Check for PDF first (has data-testid on inner div or img with .pdf alt) | |
| const pdfTestId = cardContainer.querySelector('div[data-testid$=".pdf"]'); | |
| const pdfImg = cardContainer.querySelector('img[alt$=".pdf"]'); | |
| if (pdfTestId || pdfImg) { | |
| // It's a PDF | |
| let filename; | |
| if (pdfTestId) { | |
| filename = pdfTestId.getAttribute('data-testid'); | |
| } else if (pdfImg) { | |
| filename = pdfImg.getAttribute('alt'); | |
| } | |
| if (filename) { | |
| return { | |
| domFilename: filename, | |
| type: 'pdf', | |
| isPdf: true, | |
| lineCount: null | |
| }; | |
| } | |
| } | |
| // Check for text-based file (has [data-testid="file-thumbnail"]) | |
| const fileThumbnail = cardContainer.querySelector('[data-testid="file-thumbnail"]'); | |
| if (fileThumbnail) { | |
| const h3 = fileThumbnail.querySelector('h3'); | |
| const typeBadge = fileThumbnail.querySelector('p.uppercase'); | |
| const lineCountEl = fileThumbnail.querySelector('p.text-\\[10px\\]'); | |
| if (h3) { | |
| const filename = h3.textContent.trim(); | |
| const type = typeBadge?.textContent?.trim()?.toLowerCase() || 'txt'; | |
| const lineCount = lineCountEl?.textContent?.trim() || null; | |
| return { | |
| domFilename: filename, | |
| type: type, | |
| isPdf: false, | |
| lineCount: lineCount | |
| }; | |
| } | |
| } | |
| // Fallback: try to find any filename | |
| const anyH3 = cardContainer.querySelector('h3'); | |
| const anyTypeBadge = cardContainer.querySelector('p.uppercase'); | |
| if (anyH3) { | |
| return { | |
| domFilename: anyH3.textContent.trim(), | |
| type: anyTypeBadge?.textContent?.trim()?.toLowerCase() || 'txt', | |
| isPdf: false, | |
| lineCount: null | |
| }; | |
| } | |
| return null; | |
| } | |
| // ============================================================ | |
| // CONTENT EXTRACTION | |
| // ============================================================ | |
| /** | |
| * Extract text content from an open modal | |
| */ | |
| function extractTextContent(modal) { | |
| const contentSelectors = [ | |
| 'pre code', | |
| 'pre', | |
| '.whitespace-pre-wrap', | |
| '.font-mono', | |
| '.overflow-auto pre', | |
| '[class*="content"]' | |
| ]; | |
| for (const selector of contentSelectors) { | |
| const el = modal.querySelector(selector); | |
| if (el && el.textContent.trim().length > CONFIG.MIN_CONTENT_LENGTH) { | |
| return el.textContent; | |
| } | |
| } | |
| // Fallback: get modal body text, filtering UI elements | |
| const allText = modal.textContent || ''; | |
| const lines = allText.split('\n') | |
| .map(l => l.trim()) | |
| .filter(l => l.length > 3) | |
| .filter(l => !l.match(/^(Close|Download|Export|PDF|Select|Cancel|OK|\d+\s*lines?|View|Edit|pages?)$/i)) | |
| .filter(l => !l.includes('claude.ai')) | |
| .filter(l => l.length < 500); | |
| return lines.join('\n'); | |
| } | |
| /** | |
| * Extract PDF download URL from modal | |
| */ | |
| function extractPdfUrl(modal) { | |
| // Look for the download link | |
| const downloadLink = modal.querySelector('a[href*="/document_pdf"]'); | |
| if (downloadLink) { | |
| return downloadLink.href; | |
| } | |
| // Try to find any API file link | |
| const anyApiLink = modal.querySelector('a[href*="/api/"][href*="/files/"]'); | |
| if (anyApiLink) { | |
| return anyApiLink.href; | |
| } | |
| return null; | |
| } | |
| /** | |
| * Extract CSV content - try download URL first, then table reconstruction | |
| */ | |
| async function extractCsvContent(modal, fileInfo) { | |
| // Check if there's a download link (similar to PDF) | |
| const downloadLink = modal.querySelector('a[href*="/files/"]'); | |
| if (downloadLink && downloadLink.href) { | |
| return { method: 'download_url', url: downloadLink.href }; | |
| } | |
| // Try to find table content | |
| const table = modal.querySelector('table'); | |
| if (table) { | |
| const rows = []; | |
| table.querySelectorAll('tr').forEach(tr => { | |
| const cells = []; | |
| tr.querySelectorAll('td, th').forEach(cell => { | |
| cells.push(cell.textContent.trim().replace(/,/g, ';')); | |
| }); | |
| if (cells.length > 0) { | |
| rows.push(cells.join(',')); | |
| } | |
| }); | |
| if (rows.length > 0) { | |
| return { method: 'table_reconstruction', content: rows.join('\n') }; | |
| } | |
| } | |
| // Try text content that looks like CSV | |
| const textContent = extractTextContent(modal); | |
| if (textContent && textContent.includes(',')) { | |
| return { method: 'text_scrape', content: textContent }; | |
| } | |
| return { method: 'unexportable', reason: 'No download URL, table, or CSV-like content found' }; | |
| } | |
| // ============================================================ | |
| // FILE EXPORT | |
| // ============================================================ | |
| /** | |
| * Export a single file and return metadata | |
| */ | |
| async function exportFile(fileCard, usedNames, statusCallback) { | |
| const { element, domFilename, type, isPdf, lineCount } = fileCard; | |
| log.debug(`Processing: "${domFilename}" (type: ${type}, isPdf: ${isPdf})`); | |
| // Normalize the filename | |
| let normalizedName = normalizeFilename(domFilename); | |
| normalizedName = ensureExtension(normalizedName, type); | |
| normalizedName = handleCollision(normalizedName, usedNames); | |
| const metadata = { | |
| originalDomFilename: domFilename, | |
| normalizedFilename: normalizedName, | |
| detectedType: type, | |
| sourceUrl: null, | |
| exportMethod: null, | |
| status: 'pending', | |
| error: null, | |
| lineCount: lineCount | |
| }; | |
| statusCallback?.(`Exporting: ${domFilename}`); | |
| try { | |
| // Click to open the file | |
| const clickTarget = element.querySelector('button') || element; | |
| clickTarget.scrollIntoView({ behavior: 'instant', block: 'center' }); | |
| await sleep(200); | |
| clickTarget.click(); | |
| const modal = await waitForModal(); | |
| if (!modal) { | |
| throw new Error('Modal did not open'); | |
| } | |
| let content = null; | |
| if (isPdf || type === 'pdf') { | |
| // Handle PDF | |
| const pdfUrl = extractPdfUrl(modal); | |
| if (pdfUrl) { | |
| metadata.sourceUrl = pdfUrl; | |
| metadata.exportMethod = 'pdf_download_url'; | |
| // Fetch the PDF | |
| const response = await fetch(pdfUrl, { credentials: 'include' }); | |
| if (!response.ok) { | |
| throw new Error(`PDF fetch failed: ${response.status}`); | |
| } | |
| const blob = await response.blob(); | |
| content = blob; | |
| metadata.status = 'success'; | |
| log.file(domFilename, normalizedName, 'pdf', 'download_url', 'success'); | |
| } else { | |
| throw new Error('Could not find PDF download URL'); | |
| } | |
| } else if (type === 'csv') { | |
| // Handle CSV | |
| const csvResult = await extractCsvContent(modal, fileCard); | |
| metadata.exportMethod = csvResult.method; | |
| if (csvResult.method === 'download_url') { | |
| metadata.sourceUrl = csvResult.url; | |
| const response = await fetch(csvResult.url, { credentials: 'include' }); | |
| if (!response.ok) { | |
| throw new Error(`CSV fetch failed: ${response.status}`); | |
| } | |
| content = await response.text(); | |
| metadata.status = 'success'; | |
| log.file(domFilename, normalizedName, 'csv', 'download_url', 'success'); | |
| } else if (csvResult.method === 'table_reconstruction' || csvResult.method === 'text_scrape') { | |
| content = csvResult.content; | |
| metadata.status = 'success'; | |
| log.file(domFilename, normalizedName, 'csv', csvResult.method, 'success'); | |
| } else { | |
| metadata.status = 'unexportable'; | |
| metadata.error = csvResult.reason; | |
| metadata.exportMethod = 'unexportable'; | |
| log.file(domFilename, normalizedName, 'csv', 'unexportable', 'failed'); | |
| } | |
| } else { | |
| // Handle text-based files (md, txt, docx, etc.) | |
| content = extractTextContent(modal); | |
| if (content && content.length > CONFIG.MIN_CONTENT_LENGTH) { | |
| metadata.exportMethod = 'text_scrape'; | |
| metadata.status = 'success'; | |
| log.file(domFilename, normalizedName, type, 'text_scrape', 'success'); | |
| } else { | |
| throw new Error(`Content too short (${content?.length || 0} chars)`); | |
| } | |
| } | |
| await closeModal(); | |
| await sleep(CONFIG.BETWEEN_FILES_MS); | |
| return { metadata, content, filename: normalizedName }; | |
| } catch (error) { | |
| metadata.status = 'failed'; | |
| metadata.error = error.message; | |
| metadata.exportMethod = metadata.exportMethod || 'failed'; | |
| log.file(domFilename, normalizedName, type, 'error', 'failed'); | |
| log.error(`Failed to export "${domFilename}": ${error.message}`); | |
| await closeModal(); | |
| await sleep(CONFIG.BETWEEN_FILES_MS); | |
| return { metadata, content: null, filename: normalizedName }; | |
| } | |
| } | |
| // ============================================================ | |
| // ZIP CREATION | |
| // ============================================================ | |
| async function createAndDownloadZip(exportedFiles, projectName, statusCallback) { | |
| log.info('Creating ZIP archive...'); | |
| statusCallback?.('Creating ZIP...'); | |
| const zip = new JSZip(); | |
| const allMetadata = []; | |
| let successCount = 0; | |
| let failedCount = 0; | |
| let pdfCount = 0; | |
| let csvCount = 0; | |
| let unexportableCount = 0; | |
| for (const { metadata, content, filename } of exportedFiles) { | |
| allMetadata.push(metadata); | |
| if (content !== null) { | |
| if (content instanceof Blob) { | |
| zip.file(filename, content); | |
| pdfCount++; | |
| } else { | |
| zip.file(filename, content); | |
| if (metadata.detectedType === 'csv') csvCount++; | |
| } | |
| successCount++; | |
| } else { | |
| if (metadata.status === 'unexportable') { | |
| unexportableCount++; | |
| } else { | |
| failedCount++; | |
| } | |
| } | |
| } | |
| // Add metadata JSON | |
| const metadataJson = { | |
| exportDate: new Date().toISOString(), | |
| projectTitle: projectName, | |
| url: window.location.href, | |
| exporterVersion: '4.0.0', | |
| summary: { | |
| total: exportedFiles.length, | |
| exported: successCount, | |
| failed: failedCount, | |
| unexportable: unexportableCount, | |
| pdfExported: pdfCount, | |
| csvExported: csvCount | |
| }, | |
| files: allMetadata | |
| }; | |
| zip.file('_export_metadata.json', JSON.stringify(metadataJson, null, 2)); | |
| // Generate and download | |
| log.info('Generating ZIP blob...'); | |
| const zipBlob = await zip.generateAsync({ | |
| type: 'blob', | |
| compression: 'DEFLATE', | |
| compressionOptions: { level: 6 } | |
| }); | |
| const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 16); | |
| const safeName = projectName.replace(/[^a-zA-Z0-9]/g, '_'); | |
| const zipFilename = `${safeName}_export_${timestamp}.zip`; | |
| const url = URL.createObjectURL(zipBlob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.download = zipFilename; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| URL.revokeObjectURL(url); | |
| // Final summary | |
| log.success('='.repeat(50)); | |
| log.success('EXPORT COMPLETE'); | |
| log.success('='.repeat(50)); | |
| log.info(`Total files: ${exportedFiles.length}`); | |
| log.info(`Exported: ${successCount}`); | |
| log.info(`Failed: ${failedCount}`); | |
| log.info(`Unexportable: ${unexportableCount}`); | |
| log.info(`PDFs exported: ${pdfCount}`); | |
| log.info(`CSVs exported: ${csvCount}`); | |
| log.success('='.repeat(50)); | |
| return { zipFilename, ...metadataJson.summary }; | |
| } | |
| // ============================================================ | |
| // MAIN EXPORT FUNCTION | |
| // ============================================================ | |
| async function exportProject() { | |
| const button = document.querySelector('#claude-export-btn'); | |
| const updateStatus = (msg) => { | |
| if (button) button.textContent = `🔄 ${msg}`; | |
| log.info(msg); | |
| }; | |
| try { | |
| updateStatus('Loading JSZip...'); | |
| await loadJSZip(); | |
| updateStatus('Discovering files...'); | |
| const fileCards = await discoverAllFiles(updateStatus); | |
| if (fileCards.length === 0) { | |
| updateStatus('No files found!'); | |
| log.error('No files found in project'); | |
| setTimeout(() => { | |
| if (button) button.textContent = '📁 Export Project Files'; | |
| }, 3000); | |
| return; | |
| } | |
| updateStatus(`Found ${fileCards.length} files, exporting...`); | |
| const usedNames = new Set(); | |
| const exportedFiles = []; | |
| for (let i = 0; i < fileCards.length; i++) { | |
| updateStatus(`Exporting ${i + 1}/${fileCards.length}: ${fileCards[i].domFilename}`); | |
| const result = await exportFile(fileCards[i], usedNames, updateStatus); | |
| exportedFiles.push(result); | |
| } | |
| // Get project name | |
| const projectName = getProjectTitle(); | |
| updateStatus('Creating ZIP...'); | |
| const summary = await createAndDownloadZip(exportedFiles, projectName, updateStatus); | |
| updateStatus(`✅ Exported ${summary.exported}/${summary.total} files`); | |
| setTimeout(() => { | |
| if (button) button.textContent = '📁 Export Project Files'; | |
| }, 5000); | |
| } catch (error) { | |
| log.error('Export failed:', error); | |
| updateStatus('❌ Export failed'); | |
| setTimeout(() => { | |
| if (button) button.textContent = '📁 Export Project Files'; | |
| }, 3000); | |
| } | |
| } | |
| function getProjectTitle() { | |
| // Try various title selectors | |
| const selectors = ['h1', '[data-testid*="title"]', '.text-xl', '.text-2xl']; | |
| for (const sel of selectors) { | |
| const el = document.querySelector(sel); | |
| if (el && el.textContent.trim() && el.textContent.trim() !== 'Claude') { | |
| return el.textContent.trim(); | |
| } | |
| } | |
| // Fallback to URL | |
| const urlMatch = window.location.pathname.match(/\/project\/([^\/]+)/); | |
| if (urlMatch) return urlMatch[1]; | |
| return 'Claude_Project'; | |
| } | |
| // ============================================================ | |
| // UI BUTTON | |
| // ============================================================ | |
| function addExportButton() { | |
| const existing = document.querySelector('#claude-export-btn'); | |
| if (existing) existing.remove(); | |
| const button = document.createElement('button'); | |
| button.id = 'claude-export-btn'; | |
| button.textContent = '📁 Export Project Files'; | |
| button.style.cssText = ` | |
| position: fixed; | |
| bottom: 20px; | |
| right: 20px; | |
| padding: 12px 20px; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| border: none; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| z-index: 10000; | |
| font-size: 14px; | |
| font-weight: 600; | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.2); | |
| transition: all 0.3s ease; | |
| min-width: 200px; | |
| text-align: center; | |
| `; | |
| button.addEventListener('mouseenter', () => { | |
| button.style.transform = 'translateY(-2px)'; | |
| button.style.boxShadow = '0 6px 20px rgba(0,0,0,0.3)'; | |
| }); | |
| button.addEventListener('mouseleave', () => { | |
| button.style.transform = 'translateY(0)'; | |
| button.style.boxShadow = '0 4px 15px rgba(0,0,0,0.2)'; | |
| }); | |
| button.addEventListener('click', exportProject); | |
| document.body.appendChild(button); | |
| log.success('Export button added'); | |
| } | |
| // ============================================================ | |
| // INITIALIZATION | |
| // ============================================================ | |
| function init() { | |
| log.info('Claude Project Files Exporter v4.0.0 initialized'); | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', addExportButton); | |
| } else { | |
| addExportButton(); | |
| } | |
| // Re-add button on navigation | |
| let currentUrl = location.href; | |
| const observer = new MutationObserver(() => { | |
| if (location.href !== currentUrl) { | |
| currentUrl = location.href; | |
| setTimeout(addExportButton, 1000); | |
| } | |
| }); | |
| observer.observe(document.body, { childList: true, subtree: true }); | |
| } | |
| init(); | |
| })(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Loominary (One-Click AI Chat Backup) | |
| // @name:zh-CN 支持Claude、ChatGPT、Grok、Gemini等多平台的全功能AI对话跨分支全局搜索文档PDF长截图导出管理工具 | |
| // @name:zh-TW Loominary (一鍵 AI 對話備份) | |
| // @name:ja Loominary (ワンクリック AI チャットバックアップ) | |
| // @name:ko Loominary (원클릭 AI 채팅 백업) | |
| // @name:es Loominary (Backup de Chat AI con Un Clic) | |
| // @name:pt Loominary (Backup de Chat AI com Um Clique) | |
| // @name:fr Loominary (Sauvegarde de Chat AI en Un Clic) | |
| // @name:de Loominary (Ein-Klick AI-Chat-Backup) | |
| // @namespace https://github.com/Laumss/loominary | |
| // @version 26.3.0 | |
| // @description One-click export for Claude, ChatGPT, Grok, Gemini , Google AI Studio. Backups all chat branches, artifacts, and attachments. Exports to JSON/Markdown/PDF/Editable Screenshots. The ultimate companion for Lyra Exporter to build your local AI knowledge base. | |
| // @description:zh-CN 一键导出 Claude/ChatGPT/Gemini/Grok/Google AI Studio 对话记录(支持分支、PDF、长截图)。保留完整对话分支、附加图片、LaTeX 公式、Artifacts、附件与思考过程。Lyra Exporter 的最佳搭档,打造您的本地 AI 知识库。 | |
| // @description:zh-TW 一鍵匯出 Claude、ChatGPT、Grok、Gemini、Google AI Studio 的對話。備份所有聊天分支、Artifacts 和附件。匯出為 JSON/Markdown/PDF/可編輯截圖。Lyra Exporter 的終極配套工具,用於建構本地 AI 知識庫。 | |
| // @description:ja Claude、ChatGPT、Grok、Gemini、Google AI Studio のワンクリックエクスポート。すべてのチャットブランチ、アーティファクト、添付ファイルをバックアップ。JSON/Markdown/PDF/編集可能なスクリーンショットにエクスポート。ローカル AI ナレッジベース構築のための Lyra Exporter の究極のコンパニオン。 | |
| // @description:ko Claude, ChatGPT, Grok, Gemini, Google AI Studio 원클릭 내보내기. 모든 채팅 브랜치, 아티팩트 및 첨부 파일 백업. JSON/Markdown/PDF/편집 가능한 스크린샷으로 내보내기. 로컬 AI 지식 베이스 구축을 위한 Lyra Exporter의 궁극적인 동반자. | |
| // @description:es Exportación con un clic para Claude, ChatGPT, Grok, Gemini, Google AI Studio. Respalda todas las ramas de chat, artefactos y adjuntos. Exporta a JSON/Markdown/PDF/Capturas editables. El compañero definitivo de Lyra Exporter para construir tu base de conocimiento de IA local. | |
| // @description:pt Exportação com um clique para Claude, ChatGPT, Grok, Gemini, Google AI Studio. Faz backup de todas as ramificações de chat, artefatos e anexos. Exporta para JSON/Markdown/PDF/Capturas editáveis. O companheiro definitivo do Lyra Exporter para construir sua base de conhecimento de IA local. | |
| // @description:fr Exportation en un clic pour Claude, ChatGPT, Grok, Gemini, Google AI Studio. Sauvegarde toutes les branches de chat, artefacts et pièces jointes. Exporte vers JSON/Markdown/PDF/Captures modifiables. Le compagnon ultime de Lyra Exporter pour construire votre base de connaissances IA locale. | |
| // @description:de Ein-Klick-Export für Claude, ChatGPT, Grok, Gemini, Google AI Studio. Sichert alle Chat-Branches, Artefakte und Anhänge. Exportiert nach JSON/Markdown/PDF/Bearbeitbare Screenshots. Der ultimative Begleiter für Lyra Exporter zum Aufbau Ihrer lokalen AI-Wissensdatenbank. | |
| // @author Laumss | |
| // @homepage https://laumss.github.io/react/welcome | |
| // @supportURL https://github.com/Laumss/loominary/issues | |
| // @match https://claude.ai/* | |
| // @match https://chatgpt.com/* | |
| // @match https://chat.openai.com/* | |
| // @match https://grok.com/* | |
| // @match https://gemini.google.com/* | |
| // @match https://aistudio.google.com/* | |
| // @grant GM_addStyle | |
| // @grant GM_xmlhttpRequest | |
| // @grant unsafeWindow | |
| // @run-at document-start | |
| // @license MIT | |
| // @downloadURL https://update.greasyfork.org/scripts/539579/Loominary%20%28One-Click%20AI%20Chat%20Backup%29.user.js | |
| // @updateURL https://update.greasyfork.org/scripts/539579/Loominary%20%28One-Click%20AI%20Chat%20Backup%29.meta.js | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| if (window.loominaryFetchInitialized) return; | |
| window.loominaryFetchInitialized = true; | |
| // Inline fflate (bundled from node_modules) | |
| !function(f){typeof module!='undefined'&&typeof exports=='object'?module.exports=f():typeof define!='undefined'&&define.amd?define(f):(typeof self!='undefined'?self:this).fflate=f()}(function(){var _e={};"use strict";var t=(typeof module!='undefined'&&typeof exports=='object'?function(_f){"use strict";var e,t=";var __w=require('worker_threads');__w.parentPort.on('message',function(m){onmessage({data:m})}),postMessage=function(m,t){__w.parentPort.postMessage(m,t)},close=process.exit;self=global";try{e=require("worker_threads").Worker}catch(e){}exports.default=e?function(r,n,o,a,s){var u=!1,i=new e(r+t,{eval:!0}).on("error",(function(e){return s(e,null)})).on("message",(function(e){return s(null,e)})).on("exit",(function(e){e&&!u&&s(Error("exited with code "+e),null)}));return i.postMessage(o,a),i.terminate=function(){return u=!0,e.prototype.terminate.call(i)},i}:function(e,t,r,n,o){setImmediate((function(){return o(Error("async operations unsupported - update to Node 12+ (or Node 10-11 with the --experimental-worker CLI flag)"),null)}));var a=function(){};return{terminate:a,postMessage:a}};return _f}:function(_f){"use strict";var e={};_f.default=function(r,t,s,a,n){var o=new Worker(e[t]||(e[t]=URL.createObjectURL(new Blob([r+';addEventListener("error",function(e){e=e.error;postMessage({$e$:[e.message,e.code,e.stack]})})'],{type:"text/javascript"}))));return o.onmessage=function(e){var r=e.data,t=r.$e$;if(t){var s=Error(t[0]);s.code=t[1],s.stack=t[2],n(s,null)}else n(null,r)},o.postMessage(s,a),o};return _f})({}),n=Uint8Array,r=Uint16Array,e=Int32Array,i=new n([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),o=new n([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),s=new n([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),a=function(t,n){for(var i=new r(31),o=0;o<31;++o)i[o]=n+=1<<t[o-1];var s=new e(i[30]);for(o=1;o<30;++o)for(var a=i[o];a<i[o+1];++a)s[a]=a-i[o]<<5|o;return{b:i,r:s}},u=a(i,2),h=u.b,f=u.r;h[28]=258,f[258]=28;for(var l=a(o,0),c=l.b,p=l.r,v=new r(32768),d=0;d<32768;++d){var g=(43690&d)>>1|(21845&d)<<1;v[d]=((65280&(g=(61680&(g=(52428&g)>>2|(13107&g)<<2))>>4|(3855&g)<<4))>>8|(255&g)<<8)>>1}var y=function(t,n,e){for(var i=t.length,o=0,s=new r(n);o<i;++o)t[o]&&++s[t[o]-1];var a,u=new r(n);for(o=1;o<n;++o)u[o]=u[o-1]+s[o-1]<<1;if(e){a=new r(1<<n);var h=15-n;for(o=0;o<i;++o)if(t[o])for(var f=o<<4|t[o],l=n-t[o],c=u[t[o]-1]++<<l,p=c|(1<<l)-1;c<=p;++c)a[v[c]>>h]=f}else for(a=new r(i),o=0;o<i;++o)t[o]&&(a[o]=v[u[t[o]-1]++]>>15-t[o]);return a},m=new n(288);for(d=0;d<144;++d)m[d]=8;for(d=144;d<256;++d)m[d]=9;for(d=256;d<280;++d)m[d]=7;for(d=280;d<288;++d)m[d]=8;var b=new n(32);for(d=0;d<32;++d)b[d]=5;var w=y(m,9,0),x=y(m,9,1),z=y(b,5,0),k=y(b,5,1),M=function(t){for(var n=t[0],r=1;r<t.length;++r)t[r]>n&&(n=t[r]);return n},S=function(t,n,r){var e=n/8|0;return(t[e]|t[e+1]<<8)>>(7&n)&r},A=function(t,n){var r=n/8|0;return(t[r]|t[r+1]<<8|t[r+2]<<16)>>(7&n)},T=function(t){return(t+7)/8|0},D=function(t,r,e){return(null==r||r<0)&&(r=0),(null==e||e>t.length)&&(e=t.length),new n(t.subarray(r,e))};_e.FlateErrorCode={UnexpectedEOF:0,InvalidBlockType:1,InvalidLengthLiteral:2,InvalidDistance:3,StreamFinished:4,NoStreamHandler:5,InvalidHeader:6,NoCallback:7,InvalidUTF8:8,ExtraFieldTooLong:9,InvalidDate:10,FilenameTooLong:11,StreamFinishing:12,InvalidZipData:13,UnknownCompressionMethod:14};var C=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],I=function(t,n,r){var e=Error(n||C[t]);if(e.code=t,Error.captureStackTrace&&Error.captureStackTrace(e,I),!r)throw e;return e},U=function(t,r,e,a){var u=t.length,f=a?a.length:0;if(!u||r.f&&!r.l)return e||new n(0);var l=!e,p=l||2!=r.i,v=r.i;l&&(e=new n(3*u));var d=function(t){var r=e.length;if(t>r){var i=new n(Math.max(2*r,t));i.set(e),e=i}},g=r.f||0,m=r.p||0,b=r.b||0,w=r.l,z=r.d,C=r.m,U=r.n,F=8*u;do{if(!w){g=S(t,m,1);var E=S(t,m+1,3);if(m+=3,!E){var Z=t[(J=T(m)+4)-4]|t[J-3]<<8,q=J+Z;if(q>u){v&&I(0);break}p&&d(b+Z),e.set(t.subarray(J,q),b),r.b=b+=Z,r.p=m=8*q,r.f=g;continue}if(1==E)w=x,z=k,C=9,U=5;else if(2==E){var O=S(t,m,31)+257,G=S(t,m+10,15)+4,L=O+S(t,m+5,31)+1;m+=14;for(var H=new n(L),j=new n(19),N=0;N<G;++N)j[s[N]]=S(t,m+3*N,7);m+=3*G;var P=M(j),B=(1<<P)-1,Y=y(j,P,1);for(N=0;N<L;){var J,K=Y[S(t,m,B)];if(m+=15&K,(J=K>>4)<16)H[N++]=J;else{var Q=0,R=0;for(16==J?(R=3+S(t,m,3),m+=2,Q=H[N-1]):17==J?(R=3+S(t,m,7),m+=3):18==J&&(R=11+S(t,m,127),m+=7);R--;)H[N++]=Q}}var V=H.subarray(0,O),W=H.subarray(O);C=M(V),U=M(W),w=y(V,C,1),z=y(W,U,1)}else I(1);if(m>F){v&&I(0);break}}p&&d(b+131072);for(var X=(1<<C)-1,$=(1<<U)-1,_=m;;_=m){var tt=(Q=w[A(t,m)&X])>>4;if((m+=15&Q)>F){v&&I(0);break}if(Q||I(2),tt<256)e[b++]=tt;else{if(256==tt){_=m,w=null;break}var nt=tt-254;tt>264&&(nt=S(t,m,(1<<(it=i[N=tt-257]))-1)+h[N],m+=it);var rt=z[A(t,m)&$],et=rt>>4;if(rt||I(3),m+=15&rt,W=c[et],et>3){var it=o[et];W+=A(t,m)&(1<<it)-1,m+=it}if(m>F){v&&I(0);break}p&&d(b+131072);var ot=b+nt;if(b<W){var st=f-W,at=Math.min(W,ot);for(st+b<0&&I(3);b<at;++b)e[b]=a[st+b]}for(;b<ot;++b)e[b]=e[b-W]}}r.l=w,r.p=_,r.b=b,r.f=g,w&&(g=1,r.m=C,r.d=z,r.n=U)}while(!g);return b!=e.length&&l?D(e,0,b):e.subarray(0,b)},F=function(t,n,r){var e=n/8|0;t[e]|=r<<=7&n,t[e+1]|=r>>8},E=function(t,n,r){var e=n/8|0;t[e]|=r<<=7&n,t[e+1]|=r>>8,t[e+2]|=r>>16},Z=function(t,e){for(var i=[],o=0;o<t.length;++o)t[o]&&i.push({s:o,f:t[o]});var s=i.length,a=i.slice();if(!s)return{t:N,l:0};if(1==s){var u=new n(i[0].s+1);return u[i[0].s]=1,{t:u,l:1}}i.sort((function(t,n){return t.f-n.f})),i.push({s:-1,f:25001});var h=i[0],f=i[1],l=0,c=1,p=2;for(i[0]={s:-1,f:h.f+f.f,l:h,r:f};c!=s-1;)h=i[i[l].f<i[p].f?l++:p++],f=i[l!=c&&i[l].f<i[p].f?l++:p++],i[c++]={s:-1,f:h.f+f.f,l:h,r:f};var v=a[0].s;for(o=1;o<s;++o)a[o].s>v&&(v=a[o].s);var d=new r(v+1),g=q(i[c-1],d,0);if(g>e){o=0;var y=0,m=g-e,b=1<<m;for(a.sort((function(t,n){return d[n.s]-d[t.s]||t.f-n.f}));o<s;++o){var w=a[o].s;if(!(d[w]>e))break;y+=b-(1<<g-d[w]),d[w]=e}for(y>>=m;y>0;){var x=a[o].s;d[x]<e?y-=1<<e-d[x]++-1:++o}for(;o>=0&&y;--o){var z=a[o].s;d[z]==e&&(--d[z],++y)}g=e}return{t:new n(d),l:g}},q=function(t,n,r){return-1==t.s?Math.max(q(t.l,n,r+1),q(t.r,n,r+1)):n[t.s]=r},O=function(t){for(var n=t.length;n&&!t[--n];);for(var e=new r(++n),i=0,o=t[0],s=1,a=function(t){e[i++]=t},u=1;u<=n;++u)if(t[u]==o&&u!=n)++s;else{if(!o&&s>2){for(;s>138;s-=138)a(32754);s>2&&(a(s>10?s-11<<5|28690:s-3<<5|12305),s=0)}else if(s>3){for(a(o),--s;s>6;s-=6)a(8304);s>2&&(a(s-3<<5|8208),s=0)}for(;s--;)a(o);s=1,o=t[u]}return{c:e.subarray(0,i),n:n}},G=function(t,n){for(var r=0,e=0;e<n.length;++e)r+=t[e]*n[e];return r},L=function(t,n,r){var e=r.length,i=T(n+2);t[i]=255&e,t[i+1]=e>>8,t[i+2]=255^t[i],t[i+3]=255^t[i+1];for(var o=0;o<e;++o)t[i+o+4]=r[o];return 8*(i+4+e)},H=function(t,n,e,a,u,h,f,l,c,p,v){F(n,v++,e),++u[256];for(var d=Z(u,15),g=d.t,x=d.l,k=Z(h,15),M=k.t,S=k.l,A=O(g),T=A.c,D=A.n,C=O(M),I=C.c,U=C.n,q=new r(19),H=0;H<T.length;++H)++q[31&T[H]];for(H=0;H<I.length;++H)++q[31&I[H]];for(var j=Z(q,7),N=j.t,P=j.l,B=19;B>4&&!N[s[B-1]];--B);var Y,J,K,Q,R=p+5<<3,V=G(u,m)+G(h,b)+f,W=G(u,g)+G(h,M)+f+14+3*B+G(q,N)+2*q[16]+3*q[17]+7*q[18];if(c>=0&&R<=V&&R<=W)return L(n,v,t.subarray(c,c+p));if(F(n,v,1+(W<V)),v+=2,W<V){Y=y(g,x,0),J=g,K=y(M,S,0),Q=M;var X=y(N,P,0);for(F(n,v,D-257),F(n,v+5,U-1),F(n,v+10,B-4),v+=14,H=0;H<B;++H)F(n,v+3*H,N[s[H]]);v+=3*B;for(var $=[T,I],_=0;_<2;++_){var tt=$[_];for(H=0;H<tt.length;++H)F(n,v,X[rt=31&tt[H]]),v+=N[rt],rt>15&&(F(n,v,tt[H]>>5&127),v+=tt[H]>>12)}}else Y=w,J=m,K=z,Q=b;for(H=0;H<l;++H){var nt=a[H];if(nt>255){var rt;E(n,v,Y[257+(rt=nt>>18&31)]),v+=J[rt+257],rt>7&&(F(n,v,nt>>23&31),v+=i[rt]);var et=31&nt;E(n,v,K[et]),v+=Q[et],et>3&&(E(n,v,nt>>5&8191),v+=o[et])}else E(n,v,Y[nt]),v+=J[nt]}return E(n,v,Y[256]),v+J[256]},j=new e([65540,131080,131088,131104,262176,1048704,1048832,2114560,2117632]),N=new n(0),P=function(t,s,a,u,h,l){var c=l.z||t.length,v=new n(u+c+5*(1+Math.ceil(c/7e3))+h),d=v.subarray(u,v.length-h),g=l.l,y=7&(l.r||0);if(s){y&&(d[0]=l.r>>3);for(var m=j[s-1],b=m>>13,w=8191&m,x=(1<<a)-1,z=l.p||new r(32768),k=l.h||new r(x+1),M=Math.ceil(a/3),S=2*M,A=function(n){return(t[n]^t[n+1]<<M^t[n+2]<<S)&x},C=new e(25e3),I=new r(288),U=new r(32),F=0,E=0,Z=l.i||0,q=0,O=l.w||0,G=0;Z+2<c;++Z){var N=A(Z),P=32767&Z,B=k[N];if(z[P]=B,k[N]=P,O<=Z){var Y=c-Z;if((F>7e3||q>24576)&&(Y>423||!g)){y=H(t,d,0,C,I,U,E,q,G,Z-G,y),q=F=E=0,G=Z;for(var J=0;J<286;++J)I[J]=0;for(J=0;J<30;++J)U[J]=0}var K=2,Q=0,R=w,V=P-B&32767;if(Y>2&&N==A(Z-V))for(var W=Math.min(b,Y)-1,X=Math.min(32767,Z),$=Math.min(258,Y);V<=X&&--R&&P!=B;){if(t[Z+K]==t[Z+K-V]){for(var _=0;_<$&&t[Z+_]==t[Z+_-V];++_);if(_>K){if(K=_,Q=V,_>W)break;var tt=Math.min(V,_-2),nt=0;for(J=0;J<tt;++J){var rt=Z-V+J&32767,et=rt-z[rt]&32767;et>nt&&(nt=et,B=rt)}}}V+=(P=B)-(B=z[P])&32767}if(Q){C[q++]=268435456|f[K]<<18|p[Q];var it=31&f[K],ot=31&p[Q];E+=i[it]+o[ot],++I[257+it],++U[ot],O=Z+K,++F}else C[q++]=t[Z],++I[t[Z]]}}for(Z=Math.max(Z,O);Z<c;++Z)C[q++]=t[Z],++I[t[Z]];y=H(t,d,g,C,I,U,E,q,G,Z-G,y),g||(l.r=7&y|d[y/8|0]<<3,y-=7,l.h=k,l.p=z,l.i=Z,l.w=O)}else{for(Z=l.w||0;Z<c+g;Z+=65535){var st=Z+65535;st>=c&&(d[y/8|0]=g,st=c),y=L(d,y+1,t.subarray(Z,st))}l.i=c}return D(v,0,u+T(y)+h)},B=function(){for(var t=new Int32Array(256),n=0;n<256;++n){for(var r=n,e=9;--e;)r=(1&r&&-306674912)^r>>>1;t[n]=r}return t}(),Y=function(){var t=-1;return{p:function(n){for(var r=t,e=0;e<n.length;++e)r=B[255&r^n[e]]^r>>>8;t=r},d:function(){return~t}}},J=function(){var t=1,n=0;return{p:function(r){for(var e=t,i=n,o=0|r.length,s=0;s!=o;){for(var a=Math.min(s+2655,o);s<a;++s)i+=e+=r[s];e=(65535&e)+15*(e>>16),i=(65535&i)+15*(i>>16)}t=e,n=i},d:function(){return(255&(t%=65521))<<24|(65280&t)<<8|(255&(n%=65521))<<8|n>>8}}},K=function(t,r,e,i,o){if(!o&&(o={l:1},r.dictionary)){var s=r.dictionary.subarray(-32768),a=new n(s.length+t.length);a.set(s),a.set(t,s.length),t=a,o.w=s.length}return P(t,null==r.level?6:r.level,null==r.mem?o.l?Math.ceil(1.5*Math.max(8,Math.min(13,Math.log(t.length)))):20:12+r.mem,e,i,o)},Q=function(t,n){var r={};for(var e in t)r[e]=t[e];for(var e in n)r[e]=n[e];return r},R=function(t,n,r){for(var e=t(),i=""+t,o=i.slice(i.indexOf("[")+1,i.lastIndexOf("]")).replace(/\s+/g,"").split(","),s=0;s<e.length;++s){var a=e[s],u=o[s];if("function"==typeof a){n+=";"+u+"=";var h=""+a;if(a.prototype)if(-1!=h.indexOf("[native code]")){var f=h.indexOf(" ",8)+1;n+=h.slice(f,h.indexOf("(",f))}else for(var l in n+=h,a.prototype)n+=";"+u+".prototype."+l+"="+a.prototype[l];else n+=h}else r[u]=a}return n},V=[],W=function(t){var n=[];for(var r in t)t[r].buffer&&n.push((t[r]=new t[r].constructor(t[r])).buffer);return n},X=function(n,r,e,i){if(!V[e]){for(var o="",s={},a=n.length-1,u=0;u<a;++u)o=R(n[u],o,s);V[e]={c:R(n[a],o,s),e:s}}var h=Q({},V[e].e);return(0,t.default)(V[e].c+";onmessage=function(e){for(var k in e.data)self[k]=e.data[k];onmessage="+r+"}",e,h,W(h),i)},$=function(){return[n,r,e,i,o,s,h,c,x,k,v,C,y,M,S,A,T,D,I,U,Tt,it,ot]},_=function(){return[n,r,e,i,o,s,f,p,w,m,z,b,v,j,N,y,F,E,Z,q,O,G,L,H,T,D,P,K,kt,it]},tt=function(){return[pt,gt,ct,Y,B]},nt=function(){return[vt,dt]},rt=function(){return[yt,ct,J]},et=function(){return[mt]},it=function(t){return postMessage(t,[t.buffer])},ot=function(t){return t&&{out:t.size&&new n(t.size),dictionary:t.dictionary}},st=function(t,n,r,e,i,o){var s=X(r,e,i,(function(t,n){s.terminate(),o(t,n)}));return s.postMessage([t,n],n.consume?[t.buffer]:[]),function(){s.terminate()}},at=function(t){return t.ondata=function(t,n){return postMessage([t,n],[t.buffer])},function(n){n.data.length?(t.push(n.data[0],n.data[1]),postMessage([n.data[0].length])):t.flush()}},ut=function(t,n,r,e,i,o,s){var a,u=X(t,e,i,(function(t,r){t?(u.terminate(),n.ondata.call(n,t)):Array.isArray(r)?1==r.length?(n.queuedSize-=r[0],n.ondrain&&n.ondrain(r[0])):(r[1]&&u.terminate(),n.ondata.call(n,t,r[0],r[1])):s(r)}));u.postMessage(r),n.queuedSize=0,n.push=function(t,r){n.ondata||I(5),a&&n.ondata(I(4,0,1),null,!!r),n.queuedSize+=t.length,u.postMessage([t,a=r],[t.buffer])},n.terminate=function(){u.terminate()},o&&(n.flush=function(){u.postMessage([])})},ht=function(t,n){return t[n]|t[n+1]<<8},ft=function(t,n){return(t[n]|t[n+1]<<8|t[n+2]<<16|t[n+3]<<24)>>>0},lt=function(t,n){return ft(t,n)+4294967296*ft(t,n+4)},ct=function(t,n,r){for(;r;++n)t[n]=r,r>>>=8},pt=function(t,n){var r=n.filename;if(t[0]=31,t[1]=139,t[2]=8,t[8]=n.level<2?4:9==n.level?2:0,t[9]=3,0!=n.mtime&&ct(t,4,Math.floor(new Date(n.mtime||Date.now())/1e3)),r){t[3]=8;for(var e=0;e<=r.length;++e)t[e+10]=r.charCodeAt(e)}},vt=function(t){31==t[0]&&139==t[1]&&8==t[2]||I(6,"invalid gzip data");var n=t[3],r=10;4&n&&(r+=2+(t[10]|t[11]<<8));for(var e=(n>>3&1)+(n>>4&1);e>0;e-=!t[r++]);return r+(2&n)},dt=function(t){var n=t.length;return(t[n-4]|t[n-3]<<8|t[n-2]<<16|t[n-1]<<24)>>>0},gt=function(t){return 10+(t.filename?t.filename.length+1:0)},yt=function(t,n){var r=n.level,e=0==r?0:r<6?1:9==r?3:2;if(t[0]=120,t[1]=e<<6|(n.dictionary&&32),t[1]|=31-(t[0]<<8|t[1])%31,n.dictionary){var i=J();i.p(n.dictionary),ct(t,2,i.d())}},mt=function(t,n){return(8!=(15&t[0])||t[0]>>4>7||(t[0]<<8|t[1])%31)&&I(6,"invalid zlib data"),(t[1]>>5&1)==+!n&&I(6,"invalid zlib data: "+(32&t[1]?"need":"unexpected")+" dictionary"),2+(t[1]>>3&4)};function bt(t,n){return"function"==typeof t&&(n=t,t={}),this.ondata=n,t}var wt=function(){function t(t,r){if("function"==typeof t&&(r=t,t={}),this.ondata=r,this.o=t||{},this.s={l:0,i:32768,w:32768,z:32768},this.b=new n(98304),this.o.dictionary){var e=this.o.dictionary.subarray(-32768);this.b.set(e,32768-e.length),this.s.i=32768-e.length}}return t.prototype.p=function(t,n){this.ondata(K(t,this.o,0,0,this.s),n)},t.prototype.push=function(t,r){this.ondata||I(5),this.s.l&&I(4);var e=t.length+this.s.z;if(e>this.b.length){if(e>2*this.b.length-32768){var i=new n(-32768&e);i.set(this.b.subarray(0,this.s.z)),this.b=i}var o=this.b.length-this.s.z;this.b.set(t.subarray(0,o),this.s.z),this.s.z=this.b.length,this.p(this.b,!1),this.b.set(this.b.subarray(-32768)),this.b.set(t.subarray(o),32768),this.s.z=t.length-o+32768,this.s.i=32766,this.s.w=32768}else this.b.set(t,this.s.z),this.s.z+=t.length;this.s.l=1&r,(this.s.z>this.s.w+8191||r)&&(this.p(this.b,r||!1),this.s.w=this.s.i,this.s.i-=2)},t.prototype.flush=function(){this.ondata||I(5),this.s.l&&I(4),this.p(this.b,!1),this.s.w=this.s.i,this.s.i-=2},t}();_e.Deflate=wt;var xt=function(){return function(t,n){ut([_,function(){return[at,wt]}],this,bt.call(this,t,n),(function(t){var n=new wt(t.data);onmessage=at(n)}),6,1)}}();function zt(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[_],(function(t){return it(kt(t.data[0],t.data[1]))}),0,r)}function kt(t,n){return K(t,n||{},0,0)}_e.AsyncDeflate=xt,_e.deflate=zt,_e.deflateSync=kt;var Mt=function(){function t(t,r){"function"==typeof t&&(r=t,t={}),this.ondata=r;var e=t&&t.dictionary&&t.dictionary.subarray(-32768);this.s={i:0,b:e?e.length:0},this.o=new n(32768),this.p=new n(0),e&&this.o.set(e)}return t.prototype.e=function(t){if(this.ondata||I(5),this.d&&I(4),this.p.length){if(t.length){var r=new n(this.p.length+t.length);r.set(this.p),r.set(t,this.p.length),this.p=r}}else this.p=t},t.prototype.c=function(t){this.s.i=+(this.d=t||!1);var n=this.s.b,r=U(this.p,this.s,this.o);this.ondata(D(r,n,this.s.b),this.d),this.o=D(r,this.s.b-32768),this.s.b=this.o.length,this.p=D(this.p,this.s.p/8|0),this.s.p&=7},t.prototype.push=function(t,n){this.e(t),this.c(n)},t}();_e.Inflate=Mt;var St=function(){return function(t,n){ut([$,function(){return[at,Mt]}],this,bt.call(this,t,n),(function(t){var n=new Mt(t.data);onmessage=at(n)}),7,0)}}();function At(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[$],(function(t){return it(Tt(t.data[0],ot(t.data[1])))}),1,r)}function Tt(t,n){return U(t,{i:2},n&&n.out,n&&n.dictionary)}_e.AsyncInflate=St,_e.inflate=At,_e.inflateSync=Tt;var Dt=function(){function t(t,n){this.c=Y(),this.l=0,this.v=1,wt.call(this,t,n)}return t.prototype.push=function(t,n){this.c.p(t),this.l+=t.length,wt.prototype.push.call(this,t,n)},t.prototype.p=function(t,n){var r=K(t,this.o,this.v&>(this.o),n&&8,this.s);this.v&&(pt(r,this.o),this.v=0),n&&(ct(r,r.length-8,this.c.d()),ct(r,r.length-4,this.l)),this.ondata(r,n)},t.prototype.flush=function(){wt.prototype.flush.call(this)},t}();_e.Gzip=Dt,_e.Compress=Dt;var Ct=function(){return function(t,n){ut([_,tt,function(){return[at,wt,Dt]}],this,bt.call(this,t,n),(function(t){var n=new Dt(t.data);onmessage=at(n)}),8,1)}}();function It(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[_,tt,function(){return[Ut]}],(function(t){return it(Ut(t.data[0],t.data[1]))}),2,r)}function Ut(t,n){n||(n={});var r=Y(),e=t.length;r.p(t);var i=K(t,n,gt(n),8),o=i.length;return pt(i,n),ct(i,o-8,r.d()),ct(i,o-4,e),i}_e.AsyncGzip=Ct,_e.AsyncCompress=Ct,_e.gzip=It,_e.compress=It,_e.gzipSync=Ut,_e.compressSync=Ut;var Ft=function(){function t(t,n){this.v=1,this.r=0,Mt.call(this,t,n)}return t.prototype.push=function(t,r){if(Mt.prototype.e.call(this,t),this.r+=t.length,this.v){var e=this.p.subarray(this.v-1),i=e.length>3?vt(e):4;if(i>e.length){if(!r)return}else this.v>1&&this.onmember&&this.onmember(this.r-e.length);this.p=e.subarray(i),this.v=0}Mt.prototype.c.call(this,r),!this.s.f||this.s.l||r||(this.v=T(this.s.p)+9,this.s={i:0},this.o=new n(0),this.push(new n(0),r))},t}();_e.Gunzip=Ft;var Et=function(){return function(t,n){var r=this;ut([$,nt,function(){return[at,Mt,Ft]}],this,bt.call(this,t,n),(function(t){var n=new Ft(t.data);n.onmember=function(t){return postMessage(t)},onmessage=at(n)}),9,0,(function(t){return r.onmember&&r.onmember(t)}))}}();function Zt(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[$,nt,function(){return[qt]}],(function(t){return it(qt(t.data[0],t.data[1]))}),3,r)}function qt(t,r){var e=vt(t);return e+8>t.length&&I(6,"invalid gzip data"),U(t.subarray(e,-8),{i:2},r&&r.out||new n(dt(t)),r&&r.dictionary)}_e.AsyncGunzip=Et,_e.gunzip=Zt,_e.gunzipSync=qt;var Ot=function(){function t(t,n){this.c=J(),this.v=1,wt.call(this,t,n)}return t.prototype.push=function(t,n){this.c.p(t),wt.prototype.push.call(this,t,n)},t.prototype.p=function(t,n){var r=K(t,this.o,this.v&&(this.o.dictionary?6:2),n&&4,this.s);this.v&&(yt(r,this.o),this.v=0),n&&ct(r,r.length-4,this.c.d()),this.ondata(r,n)},t.prototype.flush=function(){wt.prototype.flush.call(this)},t}();_e.Zlib=Ot;var Gt=function(){return function(t,n){ut([_,rt,function(){return[at,wt,Ot]}],this,bt.call(this,t,n),(function(t){var n=new Ot(t.data);onmessage=at(n)}),10,1)}}();function Lt(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[_,rt,function(){return[Ht]}],(function(t){return it(Ht(t.data[0],t.data[1]))}),4,r)}function Ht(t,n){n||(n={});var r=J();r.p(t);var e=K(t,n,n.dictionary?6:2,4);return yt(e,n),ct(e,e.length-4,r.d()),e}_e.AsyncZlib=Gt,_e.zlib=Lt,_e.zlibSync=Ht;var jt=function(){function t(t,n){Mt.call(this,t,n),this.v=t&&t.dictionary?2:1}return t.prototype.push=function(t,n){if(Mt.prototype.e.call(this,t),this.v){if(this.p.length<6&&!n)return;this.p=this.p.subarray(mt(this.p,this.v-1)),this.v=0}n&&(this.p.length<4&&I(6,"invalid zlib data"),this.p=this.p.subarray(0,-4)),Mt.prototype.c.call(this,n)},t}();_e.Unzlib=jt;var Nt=function(){return function(t,n){ut([$,et,function(){return[at,Mt,jt]}],this,bt.call(this,t,n),(function(t){var n=new jt(t.data);onmessage=at(n)}),11,0)}}();function Pt(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[$,et,function(){return[Bt]}],(function(t){return it(Bt(t.data[0],ot(t.data[1])))}),5,r)}function Bt(t,n){return U(t.subarray(mt(t,n&&n.dictionary),-4),{i:2},n&&n.out,n&&n.dictionary)}_e.AsyncUnzlib=Nt,_e.unzlib=Pt,_e.unzlibSync=Bt;var Yt=function(){function t(t,n){this.o=bt.call(this,t,n)||{},this.G=Ft,this.I=Mt,this.Z=jt}return t.prototype.i=function(){var t=this;this.s.ondata=function(n,r){t.ondata(n,r)}},t.prototype.push=function(t,r){if(this.ondata||I(5),this.s)this.s.push(t,r);else{if(this.p&&this.p.length){var e=new n(this.p.length+t.length);e.set(this.p),e.set(t,this.p.length)}else this.p=t;this.p.length>2&&(this.s=31==this.p[0]&&139==this.p[1]&&8==this.p[2]?new this.G(this.o):8!=(15&this.p[0])||this.p[0]>>4>7||(this.p[0]<<8|this.p[1])%31?new this.I(this.o):new this.Z(this.o),this.i(),this.s.push(this.p,r),this.p=null)}},t}();_e.Decompress=Yt;var Jt=function(){function t(t,n){Yt.call(this,t,n),this.queuedSize=0,this.G=Et,this.I=St,this.Z=Nt}return t.prototype.i=function(){var t=this;this.s.ondata=function(n,r,e){t.ondata(n,r,e)},this.s.ondrain=function(n){t.queuedSize-=n,t.ondrain&&t.ondrain(n)}},t.prototype.push=function(t,n){this.queuedSize+=t.length,Yt.prototype.push.call(this,t,n)},t}();function Kt(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),31==t[0]&&139==t[1]&&8==t[2]?Zt(t,n,r):8!=(15&t[0])||t[0]>>4>7||(t[0]<<8|t[1])%31?At(t,n,r):Pt(t,n,r)}function Qt(t,n){return 31==t[0]&&139==t[1]&&8==t[2]?qt(t,n):8!=(15&t[0])||t[0]>>4>7||(t[0]<<8|t[1])%31?Tt(t,n):Bt(t,n)}_e.AsyncDecompress=Jt,_e.decompress=Kt,_e.decompressSync=Qt;var Rt=function(t,r,e,i){for(var o in t){var s=t[o],a=r+o,u=i;Array.isArray(s)&&(u=Q(i,s[1]),s=s[0]),s instanceof n?e[a]=[s,u]:(e[a+="/"]=[new n(0),u],Rt(s,a,e,i))}},Vt="undefined"!=typeof TextEncoder&&new TextEncoder,Wt="undefined"!=typeof TextDecoder&&new TextDecoder,Xt=0;try{Wt.decode(N,{stream:!0}),Xt=1}catch(t){}var $t=function(t){for(var n="",r=0;;){var e=t[r++],i=(e>127)+(e>223)+(e>239);if(r+i>t.length)return{s:n,r:D(t,r-1)};i?3==i?(e=((15&e)<<18|(63&t[r++])<<12|(63&t[r++])<<6|63&t[r++])-65536,n+=String.fromCharCode(55296|e>>10,56320|1023&e)):n+=String.fromCharCode(1&i?(31&e)<<6|63&t[r++]:(15&e)<<12|(63&t[r++])<<6|63&t[r++]):n+=String.fromCharCode(e)}},_t=function(){function t(t){this.ondata=t,Xt?this.t=new TextDecoder:this.p=N}return t.prototype.push=function(t,r){if(this.ondata||I(5),r=!!r,this.t)return this.ondata(this.t.decode(t,{stream:!0}),r),void(r&&(this.t.decode().length&&I(8),this.t=null));this.p||I(4);var e=new n(this.p.length+t.length);e.set(this.p),e.set(t,this.p.length);var i=$t(e),o=i.s,s=i.r;r?(s.length&&I(8),this.p=null):this.p=s,this.ondata(o,r)},t}();_e.DecodeUTF8=_t;var tn=function(){function t(t){this.ondata=t}return t.prototype.push=function(t,n){this.ondata||I(5),this.d&&I(4),this.ondata(nn(t),this.d=n||!1)},t}();function nn(t,r){if(r){for(var e=new n(t.length),i=0;i<t.length;++i)e[i]=t.charCodeAt(i);return e}if(Vt)return Vt.encode(t);var o=t.length,s=new n(t.length+(t.length>>1)),a=0,u=function(t){s[a++]=t};for(i=0;i<o;++i){if(a+5>s.length){var h=new n(a+8+(o-i<<1));h.set(s),s=h}var f=t.charCodeAt(i);f<128||r?u(f):f<2048?(u(192|f>>6),u(128|63&f)):f>55295&&f<57344?(u(240|(f=65536+(1047552&f)|1023&t.charCodeAt(++i))>>18),u(128|f>>12&63),u(128|f>>6&63),u(128|63&f)):(u(224|f>>12),u(128|f>>6&63),u(128|63&f))}return D(s,0,a)}function rn(t,n){if(n){for(var r="",e=0;e<t.length;e+=16384)r+=String.fromCharCode.apply(null,t.subarray(e,e+16384));return r}if(Wt)return Wt.decode(t);var i=$t(t),o=i.s;return(r=i.r).length&&I(8),o}_e.EncodeUTF8=tn,_e.strToU8=nn,_e.strFromU8=rn;var en=function(t){return 1==t?3:t<6?2:9==t?1:0},on=function(t,n){return n+30+ht(t,n+26)+ht(t,n+28)},sn=function(t,n,r){var e=ht(t,n+28),i=rn(t.subarray(n+46,n+46+e),!(2048&ht(t,n+8))),o=n+46+e,s=ft(t,n+20),a=r&&4294967295==s?an(t,o):[s,ft(t,n+24),ft(t,n+42)],u=a[0],h=a[1],f=a[2];return[ht(t,n+10),u,h,i,o+ht(t,n+30)+ht(t,n+32),f]},an=function(t,n){for(;1!=ht(t,n);n+=4+ht(t,n+2));return[lt(t,n+12),lt(t,n+4),lt(t,n+20)]},un=function(t){var n=0;if(t)for(var r in t){var e=t[r].length;e>65535&&I(9),n+=e+4}return n},hn=function(t,n,r,e,i,o,s,a){var u=e.length,h=r.extra,f=a&&a.length,l=un(h);ct(t,n,null!=s?33639248:67324752),n+=4,null!=s&&(t[n++]=20,t[n++]=r.os),t[n]=20,n+=2,t[n++]=r.flag<<1|(o<0&&8),t[n++]=i&&8,t[n++]=255&r.compression,t[n++]=r.compression>>8;var c=new Date(null==r.mtime?Date.now():r.mtime),p=c.getFullYear()-1980;if((p<0||p>119)&&I(10),ct(t,n,p<<25|c.getMonth()+1<<21|c.getDate()<<16|c.getHours()<<11|c.getMinutes()<<5|c.getSeconds()>>1),n+=4,-1!=o&&(ct(t,n,r.crc),ct(t,n+4,o<0?-o-2:o),ct(t,n+8,r.size)),ct(t,n+12,u),ct(t,n+14,l),n+=16,null!=s&&(ct(t,n,f),ct(t,n+6,r.attrs),ct(t,n+10,s),n+=14),t.set(e,n),n+=u,l)for(var v in h){var d=h[v],g=d.length;ct(t,n,+v),ct(t,n+2,g),t.set(d,n+4),n+=4+g}return f&&(t.set(a,n),n+=f),n},fn=function(t,n,r,e,i){ct(t,n,101010256),ct(t,n+8,r),ct(t,n+10,r),ct(t,n+12,e),ct(t,n+16,i)},ln=function(){function t(t){this.filename=t,this.c=Y(),this.size=0,this.compression=0}return t.prototype.process=function(t,n){this.ondata(null,t,n)},t.prototype.push=function(t,n){this.ondata||I(5),this.c.p(t),this.size+=t.length,n&&(this.crc=this.c.d()),this.process(t,n||!1)},t}();_e.ZipPassThrough=ln;var cn=function(){function t(t,n){var r=this;n||(n={}),ln.call(this,t),this.d=new wt(n,(function(t,n){r.ondata(null,t,n)})),this.compression=8,this.flag=en(n.level)}return t.prototype.process=function(t,n){try{this.d.push(t,n)}catch(t){this.ondata(t,null,n)}},t.prototype.push=function(t,n){ln.prototype.push.call(this,t,n)},t}();_e.ZipDeflate=cn;var pn=function(){function t(t,n){var r=this;n||(n={}),ln.call(this,t),this.d=new xt(n,(function(t,n,e){r.ondata(t,n,e)})),this.compression=8,this.flag=en(n.level),this.terminate=this.d.terminate}return t.prototype.process=function(t,n){this.d.push(t,n)},t.prototype.push=function(t,n){ln.prototype.push.call(this,t,n)},t}();_e.AsyncZipDeflate=pn;var vn=function(){function t(t){this.ondata=t,this.u=[],this.d=1}return t.prototype.add=function(t){var r=this;if(this.ondata||I(5),2&this.d)this.ondata(I(4+8*(1&this.d),0,1),null,!1);else{var e=nn(t.filename),i=e.length,o=t.comment,s=o&&nn(o),a=i!=t.filename.length||s&&o.length!=s.length,u=i+un(t.extra)+30;i>65535&&this.ondata(I(11,0,1),null,!1);var h=new n(u);hn(h,0,t,e,a,-1);var f=[h],l=function(){for(var t=0,n=f;t<n.length;t++)r.ondata(null,n[t],!1);f=[]},c=this.d;this.d=0;var p=this.u.length,v=Q(t,{f:e,u:a,o:s,t:function(){t.terminate&&t.terminate()},r:function(){if(l(),c){var t=r.u[p+1];t?t.r():r.d=1}c=1}}),d=0;t.ondata=function(e,i,o){if(e)r.ondata(e,i,o),r.terminate();else if(d+=i.length,f.push(i),o){var s=new n(16);ct(s,0,134695760),ct(s,4,t.crc),ct(s,8,d),ct(s,12,t.size),f.push(s),v.c=d,v.b=u+d+16,v.crc=t.crc,v.size=t.size,c&&v.r(),c=1}else c&&l()},this.u.push(v)}},t.prototype.end=function(){var t=this;2&this.d?this.ondata(I(4+8*(1&this.d),0,1),null,!0):(this.d?this.e():this.u.push({r:function(){1&t.d&&(t.u.splice(-1,1),t.e())},t:function(){}}),this.d=3)},t.prototype.e=function(){for(var t=0,r=0,e=0,i=0,o=this.u;i<o.length;i++)e+=46+(h=o[i]).f.length+un(h.extra)+(h.o?h.o.length:0);for(var s=new n(e+22),a=0,u=this.u;a<u.length;a++){var h;hn(s,t,h=u[a],h.f,h.u,-h.c-2,r,h.o),t+=46+h.f.length+un(h.extra)+(h.o?h.o.length:0),r+=h.b}fn(s,t,this.u.length,e,r),this.ondata(null,s,!0),this.d=2},t.prototype.terminate=function(){for(var t=0,n=this.u;t<n.length;t++)n[t].t();this.d=2},t}();function dn(t,r,e){e||(e=r,r={}),"function"!=typeof e&&I(7);var i={};Rt(t,"",i,r);var o=Object.keys(i),s=o.length,a=0,u=0,h=s,f=Array(s),l=[],c=function(){for(var t=0;t<l.length;++t)l[t]()},p=function(t,n){xn((function(){e(t,n)}))};xn((function(){p=e}));var v=function(){var t=new n(u+22),r=a,e=u-a;u=0;for(var i=0;i<h;++i){var o=f[i];try{var s=o.c.length;hn(t,u,o,o.f,o.u,s);var l=30+o.f.length+un(o.extra),c=u+l;t.set(o.c,c),hn(t,a,o,o.f,o.u,s,u,o.m),a+=16+l+(o.m?o.m.length:0),u=c+s}catch(t){return p(t,null)}}fn(t,a,f.length,e,r),p(null,t)};s||v();for(var d=function(t){var n=o[t],r=i[n],e=r[0],h=r[1],d=Y(),g=e.length;d.p(e);var y=nn(n),m=y.length,b=h.comment,w=b&&nn(b),x=w&&w.length,z=un(h.extra),k=0==h.level?0:8,M=function(r,e){if(r)c(),p(r,null);else{var i=e.length;f[t]=Q(h,{size:g,crc:d.d(),c:e,f:y,m:w,u:m!=n.length||w&&b.length!=x,compression:k}),a+=30+m+z+i,u+=76+2*(m+z)+(x||0)+i,--s||v()}};if(m>65535&&M(I(11,0,1),null),k)if(g<16e4)try{M(null,kt(e,h))}catch(t){M(t,null)}else l.push(zt(e,h,M));else M(null,e)},g=0;g<h;++g)d(g);return c}function gn(t,r){r||(r={});var e={},i=[];Rt(t,"",e,r);var o=0,s=0;for(var a in e){var u=e[a],h=u[0],f=u[1],l=0==f.level?0:8,c=(M=nn(a)).length,p=f.comment,v=p&&nn(p),d=v&&v.length,g=un(f.extra);c>65535&&I(11);var y=l?kt(h,f):h,m=y.length,b=Y();b.p(h),i.push(Q(f,{size:h.length,crc:b.d(),c:y,f:M,m:v,u:c!=a.length||v&&p.length!=d,o:o,compression:l})),o+=30+c+g+m,s+=76+2*(c+g)+(d||0)+m}for(var w=new n(s+22),x=o,z=s-o,k=0;k<i.length;++k){var M;hn(w,(M=i[k]).o,M,M.f,M.u,M.c.length);var S=30+M.f.length+un(M.extra);w.set(M.c,M.o+S),hn(w,o,M,M.f,M.u,M.c.length,M.o,M.m),o+=16+S+(M.m?M.m.length:0)}return fn(w,o,i.length,z,x),w}_e.Zip=vn,_e.zip=dn,_e.zipSync=gn;var yn=function(){function t(){}return t.prototype.push=function(t,n){this.ondata(null,t,n)},t.compression=0,t}();_e.UnzipPassThrough=yn;var mn=function(){function t(){var t=this;this.i=new Mt((function(n,r){t.ondata(null,n,r)}))}return t.prototype.push=function(t,n){try{this.i.push(t,n)}catch(t){this.ondata(t,null,n)}},t.compression=8,t}();_e.UnzipInflate=mn;var bn=function(){function t(t,n){var r=this;n<32e4?this.i=new Mt((function(t,n){r.ondata(null,t,n)})):(this.i=new St((function(t,n,e){r.ondata(t,n,e)})),this.terminate=this.i.terminate)}return t.prototype.push=function(t,n){this.i.terminate&&(t=D(t,0)),this.i.push(t,n)},t.compression=8,t}();_e.AsyncUnzipInflate=bn;var wn=function(){function t(t){this.onfile=t,this.k=[],this.o={0:yn},this.p=N}return t.prototype.push=function(t,r){var e=this;if(this.onfile||I(5),this.p||I(4),this.c>0){var i=Math.min(this.c,t.length),o=t.subarray(0,i);if(this.c-=i,this.d?this.d.push(o,!this.c):this.k[0].push(o),(t=t.subarray(i)).length)return this.push(t,r)}else{var s=0,a=0,u=void 0,h=void 0;this.p.length?t.length?((h=new n(this.p.length+t.length)).set(this.p),h.set(t,this.p.length)):h=this.p:h=t;for(var f=h.length,l=this.c,c=l&&this.d,p=function(){var t,n=ft(h,a);if(67324752==n){s=1,u=a,v.d=null,v.c=0;var r=ht(h,a+6),i=ht(h,a+8),o=2048&r,c=8&r,p=ht(h,a+26),d=ht(h,a+28);if(f>a+30+p+d){var g=[];v.k.unshift(g),s=2;var y,m=ft(h,a+18),b=ft(h,a+22),w=rn(h.subarray(a+30,a+=30+p),!o);4294967295==m?(t=c?[-2]:an(h,a),m=t[0],b=t[1]):c&&(m=-1),a+=d,v.c=m;var x={name:w,compression:i,start:function(){if(x.ondata||I(5),m){var t=e.o[i];t||x.ondata(I(14,"unknown compression type "+i,1),null,!1),(y=m<0?new t(w):new t(w,m,b)).ondata=function(t,n,r){x.ondata(t,n,r)};for(var n=0,r=g;n<r.length;n++)y.push(r[n],!1);e.k[0]==g&&e.c?e.d=y:y.push(N,!0)}else x.ondata(null,N,!0)},terminate:function(){y&&y.terminate&&y.terminate()}};m>=0&&(x.size=m,x.originalSize=b),v.onfile(x)}return"break"}if(l){if(134695760==n)return u=a+=12+(-2==l&&8),s=3,v.c=0,"break";if(33639248==n)return u=a-=4,s=3,v.c=0,"break"}},v=this;a<f-4&&"break"!==p();++a);if(this.p=N,l<0){var d=h.subarray(0,s?u-12-(-2==l&&8)-(134695760==ft(h,u-16)&&4):a);c?c.push(d,!!s):this.k[+(2==s)].push(d)}if(2&s)return this.push(h.subarray(a),r);this.p=h.subarray(a)}r&&(this.c&&I(13),this.p=null)},t.prototype.register=function(t){this.o[t.compression]=t},t}();_e.Unzip=wn;var xn="function"==typeof queueMicrotask?queueMicrotask:"function"==typeof setTimeout?setTimeout:function(t){t()};function zn(t,r,e){e||(e=r,r={}),"function"!=typeof e&&I(7);var i=[],o=function(){for(var t=0;t<i.length;++t)i[t]()},s={},a=function(t,n){xn((function(){e(t,n)}))};xn((function(){a=e}));for(var u=t.length-22;101010256!=ft(t,u);--u)if(!u||t.length-u>65558)return a(I(13,0,1),null),o;var h=ht(t,u+8);if(h){var f=h,l=ft(t,u+16),c=4294967295==l||65535==f;if(c){var p=ft(t,u-12);(c=101075792==ft(t,p))&&(f=h=ft(t,p+32),l=ft(t,p+48))}for(var v=r&&r.filter,d=function(r){var e=sn(t,l,c),u=e[0],f=e[1],p=e[2],d=e[3],g=e[4],y=on(t,e[5]);l=g;var m=function(t,n){t?(o(),a(t,null)):(n&&(s[d]=n),--h||a(null,s))};if(!v||v({name:d,size:f,originalSize:p,compression:u}))if(u)if(8==u){var b=t.subarray(y,y+f);if(p<524288||f>.8*p)try{m(null,Tt(b,{out:new n(p)}))}catch(t){m(t,null)}else i.push(At(b,{size:p},m))}else m(I(14,"unknown compression type "+u,1),null);else m(null,D(t,y,y+f));else m(null,null)},g=0;g<f;++g)d()}else a(null,{});return o}function kn(t,r){for(var e={},i=t.length-22;101010256!=ft(t,i);--i)(!i||t.length-i>65558)&&I(13);var o=ht(t,i+8);if(!o)return{};var s=ft(t,i+16),a=4294967295==s||65535==o;if(a){var u=ft(t,i-12);(a=101075792==ft(t,u))&&(o=ft(t,u+32),s=ft(t,u+48))}for(var h=r&&r.filter,f=0;f<o;++f){var l=sn(t,s,a),c=l[0],p=l[1],v=l[2],d=l[3],g=l[4],y=on(t,l[5]);s=g,h&&!h({name:d,size:p,originalSize:v,compression:c})||(c?8==c?e[d]=Tt(t.subarray(y,y+p),{out:new n(v)}):I(14,"unknown compression type "+c):e[d]=D(t,y,y+p))}return e}_e.unzip=zn,_e.unzipSync=kn;return _e}); // eslint-disable-line | |
| // Userscript Adapter Layer — replaces extension-adapter.js in GreasyFork builds | |
| // Provides the same GM_* API surface as extension-adapter.js but uses native | |
| // Greasemonkey/Tampermonkey/Violentmonkey APIs instead of chrome.* messaging. | |
| const LOOMINARY_ENV = 'userscript'; | |
| console.log('[Loominary] userscript-adapter loaded, unsafeWindow available:', typeof unsafeWindow !== 'undefined'); | |
| // ViolentMonkey with @grant declarations sandboxes the script: the default `fetch` | |
| // becomes the extension's isolated fetch, which fails with NetworkError for | |
| // same-origin requests because it lacks page cookies. Shadow it with the page's | |
| // real window.fetch so all API calls are same-origin and credentials are included. | |
| // eslint-disable-next-line no-var | |
| var fetch = (typeof unsafeWindow !== 'undefined' && unsafeWindow.fetch) | |
| ? unsafeWindow.fetch.bind(unsafeWindow) | |
| : (typeof window !== 'undefined' && window.fetch ? window.fetch.bind(window) : globalThis.fetch); | |
| // GM_addStyle is natively available in userscript context via @grant GM_addStyle, | |
| // but define a fallback in case it is not (e.g., @grant none mode). | |
| if (typeof GM_addStyle === 'undefined') { | |
| function GM_addStyle(css) { | |
| const style = document.createElement('style'); | |
| style.textContent = css; | |
| (document.head || document.documentElement).appendChild(style); | |
| return style; | |
| } | |
| } | |
| /** | |
| * Cross-origin fetch via native GM_xmlhttpRequest. | |
| * Replaces the chrome background-proxy version in extension-adapter.js. | |
| */ | |
| function fetchViaBackground(url, responseType) { | |
| return new Promise((resolve, reject) => { | |
| if (typeof GM_xmlhttpRequest === 'undefined') { | |
| return reject(new Error('GM_xmlhttpRequest not available — add @grant GM_xmlhttpRequest to the userscript header')); | |
| } | |
| GM_xmlhttpRequest({ | |
| method: 'GET', | |
| url, | |
| responseType: responseType || 'blob', | |
| onload: (response) => { | |
| if (response.status >= 200 && response.status < 300) { | |
| resolve(response.response); | |
| } else { | |
| reject(new Error(`HTTP ${response.status}: ${response.statusText}`)); | |
| } | |
| }, | |
| onerror: (err) => reject(new Error(err.statusText || 'GM_xmlhttpRequest failed')) | |
| }); | |
| }); | |
| } | |
| /** | |
| * GM_xmlhttpRequest shim with the same call signature used in the codebase. | |
| * In a properly granted userscript context GM_xmlhttpRequest is already available | |
| * natively — this thin wrapper normalises the interface for the few code paths | |
| * that call it directly (e.g. grok.js image fetching). | |
| * | |
| * If the native API is unavailable (e.g. @grant none), falls back to fetch(). | |
| */ | |
| if (typeof GM_xmlhttpRequest === 'undefined') { | |
| function GM_xmlhttpRequest(options) { | |
| const { method = 'GET', url, headers = {}, responseType, onload, onerror } = options; | |
| const fetchOptions = { method, headers, credentials: 'include' }; | |
| fetch(url, fetchOptions) | |
| .then(async response => { | |
| let responseData; | |
| if (responseType === 'blob') { | |
| responseData = await response.blob(); | |
| } else if (responseType === 'json') { | |
| responseData = await response.json(); | |
| } else if (responseType === 'arraybuffer') { | |
| responseData = await response.arrayBuffer(); | |
| } else { | |
| responseData = await response.text(); | |
| } | |
| if (onload) { | |
| onload({ | |
| status: response.status, | |
| statusText: response.statusText, | |
| response: responseData, | |
| responseText: typeof responseData === 'string' ? responseData : '', | |
| responseHeaders: [...response.headers.entries()] | |
| .map(([k, v]) => `${k}: ${v}`) | |
| .join('\r\n') | |
| }); | |
| } | |
| }) | |
| .catch(error => { | |
| if (onerror) onerror({ error: error.message, statusText: error.message }); | |
| }); | |
| return { abort: () => {} }; | |
| } | |
| } | |
| // Trusted Types support for CSP compatibility | |
| let trustedPolicy = null; | |
| if (typeof window.trustedTypes !== 'undefined' && window.trustedTypes.createPolicy) { | |
| try { | |
| trustedPolicy = window.trustedTypes.createPolicy('loominary-exporter-policy', { | |
| createHTML: (input) => input | |
| }); | |
| console.log('[Loominary] Trusted-Types policy created successfully'); | |
| } catch (e) { | |
| console.warn('[Loominary] Failed to create Trusted-Types policy:', e); | |
| } | |
| } | |
| function safeSetInnerHTML(element, html) { | |
| if (!element) return; | |
| try { | |
| if (trustedPolicy) { | |
| element.innerHTML = trustedPolicy.createHTML(html); | |
| return; | |
| } | |
| element.innerHTML = html; | |
| } catch (e) { | |
| // Trusted Types blocked innerHTML (e.g. Gemini CSP) — parse via DOMParser instead | |
| try { | |
| const doc = new DOMParser().parseFromString(html, 'text/html'); | |
| element.replaceChildren(...doc.body.childNodes); | |
| } catch (e2) { | |
| element.textContent = html; | |
| } | |
| } | |
| } | |
| const Config = { | |
| CONTROL_ID: 'loominary-controls', | |
| TOGGLE_ID: 'loominary-toggle-button', | |
| LANG_SWITCH_ID: 'loominary-lang-switch', | |
| TREE_SWITCH_ID: 'loominary-tree-mode-switch', | |
| IMAGE_SWITCH_ID: 'loominary-image-switch', | |
| CANVAS_SWITCH_ID: 'loominary-canvas-switch', | |
| WORKSPACE_TYPE_ID: 'loominary-workspace-type', | |
| MANUAL_ID_BTN: 'loominary-manual-id-btn', | |
| TIMING: { | |
| SCROLL_DELAY: 250, | |
| SCROLL_TOP_WAIT: 1000, | |
| VERSION_STABLE: 1500, | |
| VERSION_SCAN_INTERVAL: 1000, | |
| HREF_CHECK_INTERVAL: 800, | |
| PANEL_INIT_DELAY: 2000, | |
| BATCH_EXPORT_SLEEP: 300, | |
| BATCH_EXPORT_YIELD: 0 | |
| } | |
| }; | |
| const State = { | |
| currentPlatform: (() => { | |
| const host = window.location.hostname; | |
| const path = window.location.pathname; | |
| console.log('[Loominary] Detecting platform, hostname:', host, 'path:', path); | |
| if (host.includes('claude.ai')) { | |
| console.log('[Loominary] Platform detected: claude'); | |
| return 'claude'; | |
| } | |
| if (host.includes('chatgpt') || host.includes('openai')) { | |
| console.log('[Loominary] Platform detected: chatgpt'); | |
| return 'chatgpt'; | |
| } | |
| if (host.includes('grok.com')) { | |
| console.log('[Loominary] Platform detected: grok'); | |
| return 'grok'; | |
| } | |
| if (host.includes('gemini')) { | |
| console.log('[Loominary] Platform detected: gemini'); | |
| return 'gemini'; | |
| } | |
| if (host.includes('aistudio')) { | |
| console.log('[Loominary] Platform detected: aistudio'); | |
| return 'aistudio'; | |
| } | |
| console.log('[Loominary] Platform detected: null (unknown)'); | |
| return null; | |
| })(), | |
| isPanelCollapsed: localStorage.getItem('exporterCollapsed') !== 'false', | |
| includeImages: localStorage.getItem('includeImages') === 'true', | |
| capturedUserId: localStorage.getItem('claudeUserId') || '', | |
| chatgptAccessToken: null, | |
| chatgptUserId: localStorage.getItem('chatGPTUserId') || '', | |
| chatgptWorkspaceId: localStorage.getItem('chatGPTWorkspaceId') || '', | |
| chatgptWorkspaceType: localStorage.getItem('chatGPTWorkspaceType') || 'user', | |
| panelInjected: false, | |
| includeCanvas: localStorage.getItem('includeCanvas') === 'true' | |
| }; | |
| let collectedData = new Map(); | |
| const Flags = { | |
| hasRetryWithoutToolButton: false, | |
| lastCanvasContent: null, | |
| lastCanvasMessageIndex: -1 | |
| }; | |
| const i18n = { | |
| languages: { | |
| zh: { | |
| loading: '加载中...', exporting: '导出中...', compressing: '压缩中...', preparing: '准备中...', | |
| exportSuccess: '导出成功!', noContent: '没有可导出的对话内容。', | |
| exportCurrentJSON: '导出当前', exportAllConversations: '导出全部', | |
| branchMode: '多分支', includeImages: '含图像', | |
| enterFilename: '请输入文件名(不含扩展名):', untitledChat: '未命名对话', | |
| uuidNotFound: '未找到对话UUID!', fetchFailed: '获取对话数据失败', | |
| exportFailed: '导出失败: ', gettingConversation: '获取对话', | |
| withImages: ' (处理图片中...)', successExported: '成功导出', conversations: '个对话!', | |
| manualUserId: '手动设置ID', enterUserId: '请输入您的组织ID (settings/account):', | |
| userIdSaved: '用户ID已保存!', | |
| workspaceType: '团队空间', userWorkspace: '个人区', teamWorkspace: '工作区', | |
| manualWorkspaceId: '手动设置工作区ID', enterWorkspaceId: '请输入工作区ID (工作空间设置/工作空间 ID):', | |
| workspaceIdSaved: '工作区ID已保存!', tokenNotFound: '未找到访问令牌!', | |
| viewOnline: '预览对话', | |
| loadFailed: '加载失败: ', | |
| cannotOpenExporter: '无法打开 Loominary,请检查弹窗拦截', | |
| versionTracking: '实时', | |
| detectingConversations: '正在探测对话数量...', | |
| foundConversations: '检测到', | |
| selectExportCount: '请输入要导出最近的多少个对话 (输入 0 或留空导出全部):', | |
| invalidNumber: '输入无效,请输入有效的数字', | |
| exportCancelled: '已取消导出' | |
| }, | |
| en: { | |
| loading: 'Loading...', exporting: 'Exporting...', compressing: 'Compressing...', preparing: 'Preparing...', | |
| exportSuccess: 'Export successful!', noContent: 'No conversation content to export.', | |
| exportCurrentJSON: 'Export', exportAllConversations: 'Save All', | |
| branchMode: 'Branch', includeImages: 'Images', | |
| enterFilename: 'Enter filename (without extension):', untitledChat: 'Untitled Chat', | |
| uuidNotFound: 'UUID not found!', fetchFailed: 'Failed to fetch conversation data', | |
| exportFailed: 'Export failed: ', gettingConversation: 'Getting conversation', | |
| withImages: ' (processing images...)', successExported: 'Successfully exported', conversations: 'conversations!', | |
| manualUserId: 'Customize UUID', enterUserId: 'Organization ID (settings/account)', | |
| userIdSaved: 'User ID saved!', | |
| workspaceType: 'Workspace', userWorkspace: 'Personal', teamWorkspace: 'Team', | |
| manualWorkspaceId: 'Set Workspace ID', enterWorkspaceId: 'Enter Workspace ID(Workspace settings/Workspace ID):', | |
| workspaceIdSaved: 'Workspace ID saved!', tokenNotFound: 'Access token not found!', | |
| viewOnline: 'Preview', | |
| loadFailed: 'Load failed: ', | |
| cannotOpenExporter: 'Cannot open Loominary, please check popup blocker', | |
| versionTracking: 'Realtime', | |
| detectingConversations: 'Detecting conversations...', | |
| foundConversations: 'Found', | |
| selectExportCount: 'How many recent conversations to export? (Enter 0 or leave empty for all):', | |
| invalidNumber: 'Invalid input, please enter a valid number', | |
| exportCancelled: 'Export cancelled' | |
| } | |
| }, | |
| currentLang: localStorage.getItem('exporterLanguage') || (navigator.language.startsWith('zh') ? 'zh' : 'en'), | |
| t: (key) => i18n.languages[i18n.currentLang]?.[key] || key, | |
| setLanguage: (lang) => { | |
| i18n.currentLang = lang; | |
| localStorage.setItem('exporterLanguage', lang); | |
| if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.local) { | |
| chrome.storage.local.set({ loominary_lang: lang }); | |
| } | |
| }, | |
| getLanguageShort() { | |
| return this.currentLang === 'zh' ? '简体中文' : 'English'; | |
| } | |
| }; | |
| // Sync initial language to chrome.storage for popup access | |
| if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.local) { | |
| chrome.storage.local.set({ loominary_lang: i18n.currentLang }); | |
| } | |
| const previewIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"></path><circle cx="12" cy="12" r="3"></circle></svg>'; | |
| const collapseIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"></polyline></svg>'; | |
| const expandIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"></polyline></svg>'; | |
| const exportIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>'; | |
| const zipIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 11V9a7 7 0 0 0-7-7a7 7 0 0 0-7 7v2"></path><rect x="3" y="11" width="18" height="10" rx="2" ry="2"></rect></svg>'; | |
| const ErrorHandler = { | |
| handle: (error, context, options = {}) => { | |
| const { | |
| showAlert = true, | |
| logToConsole = true, | |
| userMessage = null | |
| } = options; | |
| const errorMsg = error?.message || String(error); | |
| const contextMsg = context ? `[${context}]` : ''; | |
| if (logToConsole) { | |
| console.error(`[Loominary] ${contextMsg}`, error); | |
| } | |
| if (showAlert) { | |
| const displayMsg = userMessage || `${i18n.t('exportFailed')} ${errorMsg}`; | |
| alert(displayMsg); | |
| } | |
| return false; | |
| } | |
| }; | |
| const Utils = { | |
| sleep: (ms) => new Promise(resolve => setTimeout(resolve, ms)), | |
| sanitizeFilename: (name) => { | |
| if (!name) return 'unnamed'; | |
| return name | |
| .replace(/[<>:"\/\\|?*\x00-\x1F]/g, '') // 移除非法字符 | |
| .replace(/[\u0080-\uFFFF]/g, (c) => { // 移除非ASCII字符(保留中文) | |
| const code = c.charCodeAt(0); | |
| return (code >= 0x4e00 && code <= 0x9fa5) ? c : ''; | |
| }) | |
| .replace(/_{2,}/g, '_') // 多个下划线合并为一个 | |
| .replace(/^[._]+|[._]+$/g, '') // 移除首尾的点和下划线 | |
| .substring(0, 100) || 'unnamed'; | |
| }, | |
| blobToBase64: (blob) => new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onloadend = () => resolve(reader.result.split(',')[1]); | |
| reader.onerror = reject; | |
| reader.readAsDataURL(blob); | |
| }), | |
| downloadJSON: (jsonString, filename) => { | |
| const blob = new Blob([jsonString], { type: 'application/json' }); | |
| Utils.downloadFile(blob, filename); | |
| }, | |
| downloadFile: (blob, filename) => { | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = filename; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| }, | |
| setButtonLoading: (btn, text) => { | |
| btn.disabled = true; | |
| safeSetInnerHTML(btn, `<div class="loominary-loading"></div> <span>${text}</span>`); | |
| }, | |
| restoreButton: (btn, originalContent) => { | |
| btn.disabled = false; | |
| safeSetInnerHTML(btn, originalContent); | |
| }, | |
| createButton: (innerHTML, onClick, useInlineStyles = false) => { | |
| const btn = document.createElement('button'); | |
| btn.className = 'loominary-button'; | |
| safeSetInnerHTML(btn, innerHTML); | |
| btn.addEventListener('click', () => onClick(btn)); | |
| if (useInlineStyles) { | |
| Object.assign(btn.style, { | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'flex-start', | |
| gap: '8px', | |
| width: '100%', | |
| maxWidth: '100%', | |
| padding: '8px 12px', | |
| margin: '8px 0', | |
| border: 'none', | |
| borderRadius: '6px', | |
| fontSize: '11px', | |
| fontWeight: '500', | |
| cursor: 'pointer', | |
| letterSpacing: '0.3px', | |
| height: '32px', | |
| boxSizing: 'border-box', | |
| whiteSpace: 'nowrap' | |
| }); | |
| } | |
| return btn; | |
| }, | |
| createToggle: (label, id, checked = false) => { | |
| const container = document.createElement('div'); | |
| container.className = 'loominary-toggle'; | |
| const labelSpan = document.createElement('span'); | |
| labelSpan.className = 'loominary-toggle-label'; | |
| labelSpan.textContent = label; | |
| const switchLabel = document.createElement('label'); | |
| switchLabel.className = 'loominary-switch'; | |
| const input = document.createElement('input'); | |
| input.type = 'checkbox'; | |
| input.id = id; | |
| input.checked = checked; | |
| const slider = document.createElement('span'); | |
| slider.className = 'loominary-slider'; | |
| switchLabel.appendChild(input); | |
| switchLabel.appendChild(slider); | |
| container.appendChild(labelSpan); | |
| container.appendChild(switchLabel); | |
| return container; | |
| }, | |
| createProgressElem: (parent) => { | |
| const elem = document.createElement('div'); | |
| elem.className = 'loominary-progress'; | |
| parent.appendChild(elem); | |
| return elem; | |
| } | |
| }; | |
| // Simple hash function for better deduplication | |
| function simpleHash(str) { | |
| let hash = 0; | |
| for (let i = 0; i < str.length; i++) { | |
| const char = str.charCodeAt(i); | |
| hash = ((hash << 5) - hash) + char; | |
| hash = hash & hash; // Convert to 32bit integer | |
| } | |
| return hash.toString(36); | |
| } | |
| /** | |
| * Extract canvas content from a DOM element | |
| * Supports code blocks, artifacts, interactive elements, and text content | |
| * @param {Element} root - The root element to extract canvas from (typically a model-response container) | |
| * @returns {Array} Array of canvas objects with type, content, and metadata | |
| */ | |
| function extractCanvasFromElement(root) { | |
| const canvasData = []; | |
| const seen = new Set(); | |
| if (!root || !(root instanceof Element)) return canvasData; | |
| // Enhanced code block detection with multiple selectors | |
| const codeBlockSelectors = [ | |
| 'code-block', | |
| 'pre code', | |
| '.code-block', | |
| '[data-code-block]', | |
| '.artifact-code', | |
| 'code-execution-result code' | |
| ]; | |
| codeBlockSelectors.forEach((selector) => { | |
| const blocks = root.querySelectorAll(selector); | |
| blocks.forEach((block) => { | |
| const codeContent = block.textContent || block.innerText; | |
| if (!codeContent) return; | |
| const trimmed = codeContent.trim(); | |
| if (!trimmed || trimmed.length < 5) return; // Skip very short content | |
| const hash = simpleHash(trimmed); | |
| if (seen.has(hash)) return; | |
| seen.add(hash); | |
| // Try to detect language from multiple sources | |
| let language = 'unknown'; | |
| const langAttr = block.querySelector('[data-lang]'); | |
| if (langAttr) { | |
| language = langAttr.getAttribute('data-lang') || 'unknown'; | |
| } else if (block.className) { | |
| const match = block.className.match(/language-(\w+)/); | |
| if (match) language = match[1]; | |
| } | |
| canvasData.push({ | |
| type: 'code', | |
| content: trimmed, | |
| language: language, | |
| selector: selector | |
| }); | |
| }); | |
| }); | |
| // Artifact detection (Gemini's interactive components) | |
| const artifactSelectors = [ | |
| '[data-artifact]', | |
| '.artifact-container', | |
| 'artifact-element', | |
| '.interactive-canvas' | |
| ]; | |
| artifactSelectors.forEach((selector) => { | |
| const artifacts = root.querySelectorAll(selector); | |
| artifacts.forEach((artifact) => { | |
| const content = artifact.textContent || artifact.innerText; | |
| if (!content) return; | |
| const trimmed = content.trim(); | |
| if (!trimmed || trimmed.length < 5) return; | |
| const hash = simpleHash(trimmed); | |
| if (seen.has(hash)) return; | |
| seen.add(hash); | |
| canvasData.push({ | |
| type: 'artifact', | |
| content: trimmed, | |
| selector: selector | |
| }); | |
| }); | |
| }); | |
| // Canvas element detection (actual HTML5 canvas) | |
| const canvasElements = root.querySelectorAll('canvas'); | |
| canvasElements.forEach((canvas) => { | |
| // Try to get canvas context or data | |
| const canvasId = canvas.id || canvas.className || 'unnamed-canvas'; | |
| const hash = simpleHash(canvasId + canvas.width + canvas.height); | |
| if (seen.has(hash)) return; | |
| seen.add(hash); | |
| canvasData.push({ | |
| type: 'canvas_element', | |
| content: `Canvas element: ${canvasId} (${canvas.width}x${canvas.height})`, | |
| metadata: { | |
| id: canvasId, | |
| width: canvas.width, | |
| height: canvas.height | |
| } | |
| }); | |
| }); | |
| return canvasData; | |
| } | |
| function extractGlobalCanvasContent() { | |
| const canvasData = []; | |
| const seen = new Set(); | |
| const codeBlocks = document.querySelectorAll('code-block, pre code, .code-block'); | |
| codeBlocks.forEach((block) => { | |
| const codeContent = block.textContent || block.innerText; | |
| if (!codeContent) return; | |
| const trimmed = codeContent.trim(); | |
| if (!trimmed) return; | |
| const key = trimmed.substring(0, 100); | |
| if (seen.has(key)) return; | |
| seen.add(key); | |
| const langAttr = block.querySelector('[data-lang]'); | |
| const language = langAttr ? langAttr.getAttribute('data-lang') || 'unknown' : 'unknown'; | |
| canvasData.push({ | |
| type: 'code', | |
| content: trimmed, | |
| language: language | |
| }); | |
| }); | |
| const responseElements = document.querySelectorAll('response-element, .model-response-text, .markdown'); | |
| responseElements.forEach((element) => { | |
| if (element.closest('code-block') || element.querySelector('code-block')) return; | |
| let clone; | |
| try { | |
| clone = element.cloneNode(true); | |
| clone.querySelectorAll('button.retry-without-tool-button').forEach(btn => btn.remove()); | |
| } catch (e) { | |
| clone = element; | |
| } | |
| let md = ''; | |
| try { | |
| md = htmlToMarkdown(clone).trim(); | |
| } catch (e) { | |
| const textContent = element.textContent || element.innerText; | |
| md = textContent ? textContent.trim() : ''; | |
| } | |
| if (!md) return; | |
| const key = md.substring(0, 100); | |
| if (seen.has(key)) return; | |
| seen.add(key); | |
| canvasData.push({ | |
| type: 'text', | |
| content: md | |
| }); | |
| }); | |
| return canvasData; | |
| } | |
| const Communicator = { | |
| open: async (jsonData, filename, extraData) => { | |
| const defaultFilename = filename || `${State.currentPlatform}_export_${new Date().toISOString().slice(0,10)}.json`; | |
| // Userscript mode: open GitHub Pages viewer and transfer data via postMessage | |
| if (typeof LOOMINARY_ENV !== 'undefined' && LOOMINARY_ENV === 'userscript') { | |
| const GITHUB_PAGES_URL = 'https://Laumss.github.io/react'; | |
| // Use unsafeWindow.open so the new tab's window.opener = actual page window, | |
| // not the ViolentMonkey sandbox proxy. This allows github.io to postMessage back. | |
| const _opener = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; | |
| const newWin = _opener.open(GITHUB_PAGES_URL, '_blank'); | |
| if (!newWin) { | |
| alert(i18n.t('cannotOpenExporter')); | |
| return false; | |
| } | |
| return new Promise((resolve) => { | |
| // Poll with LOOMINARY_HANDSHAKE until the GitHub Pages app signals it is ready | |
| const interval = setInterval(() => { | |
| try { | |
| newWin.postMessage({ type: 'LOOMINARY_HANDSHAKE' }, 'https://Laumss.github.io'); | |
| } catch (e) { /* page may not be loaded yet */ } | |
| }, 500); | |
| const timeout = setTimeout(() => { | |
| clearInterval(interval); | |
| _opener.removeEventListener('message', handler); | |
| console.warn('[Loominary] Timed out waiting for GitHub Pages viewer to respond'); | |
| resolve(false); | |
| }, 15000); | |
| function handler(event) { | |
| if (event.source !== newWin || event.data?.type !== 'LOOMINARY_READY') return; | |
| clearInterval(interval); | |
| clearTimeout(timeout); | |
| _opener.removeEventListener('message', handler); | |
| // Viewer sends back its saved export config — save it to local storage so | |
| // content-script exports use the same settings as the React viewer. | |
| if (event.data.config && typeof event.data.config === 'object') { | |
| const cfgStr = JSON.stringify(event.data.config); | |
| console.log('[Loominary] LOOMINARY_READY: syncing config from viewer:', cfgStr); | |
| try { localStorage.setItem('loominary_export_config', cfgStr); } catch (e) {} | |
| } | |
| // Detect page theme via color-scheme CSS property | |
| const pageTheme = getComputedStyle(document.documentElement).getPropertyValue('color-scheme').trim(); | |
| const detectedTheme = (pageTheme === 'light') ? 'light' : 'dark'; | |
| newWin.postMessage({ | |
| type: 'LOOMINARY_LOAD_DATA', | |
| data: { content: jsonData, filename: defaultFilename, lang: i18n.currentLang, theme: detectedTheme, ...extraData } | |
| }, 'https://Laumss.github.io'); | |
| resolve(true); | |
| } | |
| _opener.addEventListener('message', handler); | |
| }); | |
| } | |
| // Extension mode: open side panel via background service worker | |
| try { | |
| if (State.capturedUserId) { | |
| chrome.storage.local.set({ loominary_browse_context: { | |
| baseUrl: window.location.origin, | |
| userId: State.capturedUserId | |
| }}); | |
| } | |
| // Detect page theme and sync lang before opening tab | |
| const _extPageTheme = getComputedStyle(document.documentElement).getPropertyValue('color-scheme').trim(); | |
| const _extDetectedTheme = (_extPageTheme === 'light') ? 'light' : 'dark'; | |
| chrome.storage.local.set({ loominary_lang: i18n.currentLang, loominary_page_theme: _extDetectedTheme }); | |
| chrome.runtime.sendMessage({ | |
| type: 'LOOMINARY_OPEN_SIDEPANEL', | |
| data: { | |
| content: jsonData, | |
| filename: defaultFilename, | |
| lang: i18n.currentLang, | |
| theme: _extDetectedTheme, | |
| ...extraData | |
| } | |
| }, () => { | |
| if (chrome.runtime.lastError) { | |
| console.error('[Loominary Extension] Send message error:', chrome.runtime.lastError); | |
| alert(i18n.t('cannotOpenExporter') + ': ' + chrome.runtime.lastError.message); | |
| } else { | |
| console.log('[Loominary Extension] Side panel opened successfully'); | |
| } | |
| }); | |
| return true; | |
| } catch (error) { | |
| alert(`${i18n.t('cannotOpenExporter')}: ${error.message}`); | |
| return false; | |
| } | |
| } | |
| }; | |
| // Listen for settings updates posted back from the viewer tab (github.io SettingsPanel) | |
| // Must use unsafeWindow in userscript mode: ViolentMonkey sandbox `window` is a proxy; | |
| // the actual postMessage from github.io goes to the real page window (unsafeWindow). | |
| const _msgTarget = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; | |
| console.log('[Loominary] settings listener registered on', typeof unsafeWindow !== 'undefined' ? 'unsafeWindow' : 'window'); | |
| _msgTarget.addEventListener('message', (event) => { | |
| if (event.data?.type !== 'LOOMINARY_SETTINGS_UPDATE') return; | |
| if (!event.data.config || typeof event.data.config !== 'object') return; | |
| const config = event.data.config; | |
| console.log('[Loominary] LOOMINARY_SETTINGS_UPDATE received, saving config:', JSON.stringify(config)); | |
| if (typeof LOOMINARY_ENV !== 'undefined' && LOOMINARY_ENV === 'userscript') { | |
| try { localStorage.setItem('loominary_export_config', JSON.stringify(config)); } catch (e) {} | |
| } else if (typeof chrome !== 'undefined' && chrome.storage?.local) { | |
| chrome.storage.local.set({ loominary_export_config: config }); | |
| } | |
| }); | |
| // markdown-core.js — Script-layer Markdown generation & export | |
| // Injected into content.js (extension) and userscript AFTER common-base.js. | |
| // No ES module syntax; all functions live in the enclosing IIFE scope. | |
| // ─── i18n helper ──────────────────────────────────────────────────────────── | |
| function _mdT(key, fallback) { | |
| try { const v = i18n.t('exportManager.' + key); return (v && v !== 'exportManager.' + key) ? v : fallback; } | |
| catch (_) { return fallback; } | |
| } | |
| // ─── Date utils ────────────────────────────────────────────────────────────── | |
| function _fmtDate(s) { | |
| if (!s) return ''; | |
| try { return new Date(s).toLocaleString(); } catch (_) { return s; } | |
| } | |
| function _todayStr() { | |
| return new Date().toISOString().slice(0, 10); | |
| } | |
| // ─── Artifact / tool extractors (ported from helpers.js) ───────────────────── | |
| function _extractArtifact(item) { | |
| try { | |
| const input = item.input || {}; | |
| const command = input.command || ''; | |
| if (command === 'create') return { id: input.id || '', command, type: input.type || '', title: input.title || '', content: input.content || '', language: input.language || '', result: null }; | |
| if (command === 'update' || command === 'rewrite') return { id: input.id || '', command, old_str: input.old_str || '', new_str: input.new_str || '', result: null }; | |
| } catch (_) {} | |
| return null; | |
| } | |
| function _extractToolUse(item) { | |
| const t = { name: item.name || 'unknown', input: item.input || {}, result: null }; | |
| if (item.name === 'web_search_tool' && item.input?.query) t.query = item.input.query; | |
| return t; | |
| } | |
| function _extractToolResult(item) { | |
| return { name: item.name || 'unknown', is_error: !!item.is_error, content: item.content || [] }; | |
| } | |
| function _filterCitations(cits) { | |
| if (!Array.isArray(cits)) return []; | |
| return cits.filter(c => c && typeof c === 'object' && (c.metadata?.type !== 'file') && (c.metadata?.source !== 'my_files')); | |
| } | |
| // ─── Claude content-array processor (ported from helpers.js) ───────────────── | |
| function _processContentArray(arr, msg, isHuman) { | |
| let text = ''; | |
| (arr || []).forEach((item, idx) => { | |
| if (!item || typeof item !== 'object') return; | |
| const t = item.type || ''; | |
| if (t === 'text') { | |
| text += item.text || ''; | |
| if (Array.isArray(item.citations)) msg.citations.push(..._filterCitations(item.citations)); | |
| } else if (t === 'image') { | |
| const src = item.source || {}; | |
| const placeholder = ` [图片${msg.images.length + 1}] `; | |
| msg.images.push({ index: msg.images.length, file_name: `image_${idx}`, file_type: src.media_type || 'image/jpeg', display_mode: 'base64', embedded_image: { data: `data:${src.media_type};base64,${src.data}` }, placeholder }); | |
| text += placeholder; | |
| } else if (t === 'thinking' && !isHuman) { | |
| msg.thinking = (item.thinking || '').trim(); | |
| } else if (t === 'tool_use' && !isHuman) { | |
| if (item.name === 'artifacts') { const a = _extractArtifact(item); if (a) msg.artifacts.push(a); } | |
| else { const tool = _extractToolUse(item); if (tool) msg.tools.push(tool); } | |
| } else if (t === 'tool_result') { | |
| const res = _extractToolResult(item); | |
| if (item.name && item.name.includes('artifacts')) { if (msg.artifacts.length) msg.artifacts[msg.artifacts.length - 1].result = res; } | |
| else { if (msg.tools.length) msg.tools[msg.tools.length - 1].result = res; } | |
| } | |
| }); | |
| msg.display_text += text.trim(); | |
| } | |
| // ─── Build blank message object ────────────────────────────────────────────── | |
| function _blankMsg(idx, uuid, parentUuid, sender, senderLabel, timestamp) { | |
| return { index: idx, uuid: uuid || '', parent_uuid: parentUuid || '', sender, sender_label: senderLabel, timestamp: timestamp || '', display_text: '', thinking: '', tools: [], artifacts: [], citations: [], images: [], attachments: [], branch_id: null, is_branch_point: false, branch_level: 0 }; | |
| } | |
| // ─── Claude parser ──────────────────────────────────────────────────────────── | |
| function _parseClaude(d) { | |
| const meta = { title: d.name || 'Untitled', created_at: _fmtDate(d.created_at), updated_at: _fmtDate(d.updated_at), uuid: d.uuid || '', project_uuid: d.project_uuid || '', platform: 'claude' }; | |
| const history = (d.chat_messages || []).map((m, i) => { | |
| const isHuman = m.sender === 'human'; | |
| const msg = _blankMsg(i, m.uuid, m.parent_message_uuid, m.sender, isHuman ? 'User' : 'Claude', _fmtDate(m.created_at)); | |
| if (Array.isArray(m.content)) _processContentArray(m.content, msg, isHuman); | |
| else if (m.text) { msg.display_text = m.text; } | |
| if (Array.isArray(m.attachments)) msg.attachments = m.attachments.map(a => ({ id: a.id || '', file_name: a.file_name || '', file_size: a.file_size || 0, file_type: a.file_type || '', extracted_content: a.extracted_content || '', created_at: _fmtDate(a.created_at) })); | |
| return msg; | |
| }); | |
| return { meta_info: meta, chat_history: history, format: 'claude' }; | |
| } | |
| // ─── Grok parser ────────────────────────────────────────────────────────────── | |
| function _parseGrok(d) { | |
| const meta = { title: d.title || 'Untitled', created_at: _fmtDate(d.exportTime), uuid: d.conversationId || '', platform: 'grok' }; | |
| const history = (d.responses || []).map((m, i) => { | |
| const isHuman = m.sender === 'human'; | |
| const msg = _blankMsg(i, m.responseId, m.parentResponseId, m.sender, isHuman ? 'User' : 'Grok', _fmtDate(m.createTime)); | |
| let text = m.message || ''; | |
| if (Array.isArray(m.citations)) { | |
| const map = new Map(); | |
| m.citations.forEach(c => map.set(c.id, c)); | |
| text = text.replace(/<grok:render card_id="([^"]+)"[\s\S]*?<\/grok:render>/g, (_, id) => { | |
| const c = map.get(id); return c ? `[${c.title || 'Source'}](${c.url})` : ''; | |
| }).replace(/<grok:render[\s\S]*?<\/grok:render>/g, '').trim(); | |
| msg.citations = m.citations.map(c => ({ url: c.url, title: c.title || 'Source' })); | |
| } | |
| msg.display_text = text; | |
| if (Array.isArray(m.attachments)) msg.attachments = m.attachments; | |
| return msg; | |
| }); | |
| return { meta_info: meta, chat_history: history, format: 'grok' }; | |
| } | |
| // ─── Gemini / scraped parser ────────────────────────────────────────────────── | |
| function _attachGeminiImages(msg, images) { | |
| if (!Array.isArray(images) || !images.length) return; | |
| msg.images = images.map(img => ({ | |
| link: 'data:' + (img.format || 'image/png') + ';base64,' + img.data, | |
| is_embedded_image: true | |
| })); | |
| } | |
| function _parseGemini(d) { | |
| const platform = d.platform || 'gemini'; | |
| const platLabel = platform.charAt(0).toUpperCase() + platform.slice(1); | |
| const meta = { title: d.title || 'Untitled', created_at: _fmtDate(d.exportedAt), uuid: platform + '_' + Date.now(), platform }; | |
| const history = []; | |
| let idx = 0; | |
| (d.conversation || []).forEach((item, turnIdx) => { | |
| // 多分支版本格式(VersionTracker 输出) | |
| if (item.turnIndex !== undefined && (item.human?.versions || item.assistant?.versions)) { | |
| (item.human?.versions || []).forEach(hv => { | |
| const msg = _blankMsg(idx++, `h_t${turnIdx}_v${hv.version}`, '', 'human', 'User', meta.created_at); | |
| msg.display_text = hv.text || ''; | |
| if (hv.version > 0) msg.is_branch_point = true; | |
| _attachGeminiImages(msg, hv.images); | |
| history.push(msg); | |
| }); | |
| (item.assistant?.versions || []).forEach(av => { | |
| const parentUuid = `h_t${turnIdx}_v${av.userVersion ?? 0}`; | |
| const msg = _blankMsg(idx++, `a_t${turnIdx}_v${av.version}`, parentUuid, 'assistant', platLabel, meta.created_at); | |
| msg.display_text = av.text || ''; | |
| if (av.version > 0) msg.is_branch_point = true; | |
| if (av.thinking) msg.thinking = av.thinking; | |
| _attachGeminiImages(msg, av.images); | |
| history.push(msg); | |
| }); | |
| } else { | |
| // 普通格式(scraper 输出) | |
| if (item.human) { | |
| const hc = typeof item.human === 'string' ? { text: item.human } : item.human; | |
| const msg = _blankMsg(idx++, 'h_' + idx, '', 'human', 'User', meta.created_at); | |
| msg.display_text = hc.text || ''; | |
| _attachGeminiImages(msg, hc.images); | |
| history.push(msg); | |
| } | |
| if (item.assistant) { | |
| const ac = typeof item.assistant === 'string' ? { text: item.assistant } : item.assistant; | |
| const msg = _blankMsg(idx++, 'a_' + idx, '', 'assistant', platLabel, meta.created_at); | |
| msg.display_text = ac.text || ''; | |
| if (ac.thinking) msg.thinking = ac.thinking; | |
| _attachGeminiImages(msg, ac.images); | |
| history.push(msg); | |
| } | |
| } | |
| }); | |
| return { meta_info: meta, chat_history: history, format: platform }; | |
| } | |
| // ─── Dispatch parser by content ────────────────────────────────────────────── | |
| function _parseRaw(jsonData) { | |
| if (!jsonData || typeof jsonData !== 'object') return null; | |
| if (jsonData.chat_history && jsonData.format) return jsonData; // already processed | |
| if (jsonData.chat_messages) return _parseClaude(jsonData); | |
| if (jsonData.responses && jsonData.conversationId !== undefined) return _parseGrok(jsonData); | |
| if (jsonData.conversation && jsonData.platform) return _parseGemini(jsonData); | |
| return null; | |
| } | |
| // ─── Format helpers (ported from formatHelpers.js) ─────────────────────────── | |
| function _escXml(s) { return s ? String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') : ''; } | |
| function _details(summary, lines) { | |
| return ['<details>', `<summary>${summary}</summary>`, '', ...(Array.isArray(lines) ? lines : [lines]), '</details>', ''].join('\n'); | |
| } | |
| function _fmtAttachments(atts, opts) { | |
| if (!atts || !atts.length) return ''; | |
| const lines = ['<attachments>']; | |
| atts.forEach((a, i) => { | |
| lines.push(`<attachment index="${i+1}">`, `<file_name>${_escXml(a.file_name || '')}</file_name>`, `<file_size>${a.file_size || 0}</file_size>`); | |
| if (a.created_at) lines.push(`<created_at>${_escXml(a.created_at)}</created_at>`); | |
| if (a.extracted_content) { | |
| lines.push('<attachment_content>'); | |
| lines.push(opts?.includeAttachments !== false ? a.extracted_content : a.extracted_content.substring(0, 200) + (a.extracted_content.length > 200 ? '...' : '')); | |
| lines.push('</attachment_content>'); | |
| } | |
| lines.push('</attachment>', ''); | |
| }); | |
| lines.push('</attachments>', ''); | |
| return lines.join('\n'); | |
| } | |
| function _fmtThinking(thinking, fmt, label) { | |
| label = label || _mdT('format.thinkingLabel', '💭 Thinking:'); | |
| switch (fmt) { | |
| case 'xml': return ['<anthropic_thinking>', thinking, '</anthropic_thinking>', ''].join('\n'); | |
| case 'emoji': return [label, '```', thinking, '```', ''].join('\n'); | |
| default: return ['``` thinking', thinking, '```', ''].join('\n'); | |
| } | |
| } | |
| function _fmtArtifact(a) { | |
| const typeLabel = _mdT('format.typeLabel', 'Type:'); | |
| const langLabel = _mdT('format.language', 'Language:'); | |
| const contLabel = _mdT('format.content', 'Content:'); | |
| const artLabel = _mdT('format.artifact', 'Artifact:'); | |
| const noTitle = _mdT('format.noTitle', '(no title)'); | |
| const lines = [`${typeLabel} \`${a.type || ''}\``, '']; | |
| if (a.command === 'create' && a.content) { | |
| if (a.language) lines.push(`${langLabel} \`${a.language}\``); | |
| lines.push('', contLabel, `\`\`\`${a.language || ''}`, a.content, '```'); | |
| } | |
| return _details(`${artLabel} ${a.title || noTitle}`, lines); | |
| } | |
| function _fmtTool(t) { | |
| const toolLabel = _mdT('format.tool', 'Tool:'); | |
| const queryLabel = _mdT('format.searchQuery', 'Query:'); | |
| const resultLabel = _mdT('format.searchResults', 'Results:'); | |
| const noTitle = _mdT('format.noTitle', '(no title)'); | |
| const lines = []; | |
| if (t.query) lines.push(`${queryLabel} \`${t.query}\``, ''); | |
| if (t.result?.content && t.name === 'web_search_tool') { | |
| lines.push(resultLabel, ''); | |
| t.result.content.slice(0, 5).forEach((item, i) => lines.push(`${i+1}. [${item.title || noTitle}](${item.url || '#'})`)); | |
| } | |
| return _details(`${toolLabel} ${t.name}`, lines); | |
| } | |
| function _fmtCitations(cits) { | |
| const label = _mdT('format.citations', 'Citations'); | |
| const unk = _mdT('format.unknownSource', 'Unknown'); | |
| const lines = ['| Title | Source |', '| --- | --- |']; | |
| cits.forEach(c => { | |
| const url = c.url || '#'; | |
| const src = url.includes('/') ? url.split('/')[2] : unk; | |
| lines.push(`| [${c.title || unk}](${url}) | ${src} |`); | |
| }); | |
| return _details(label, lines); | |
| } | |
| function _branchMarker(msg) { | |
| if (msg.is_branch_point) return ' 🔀'; | |
| if (msg.branch_level > 0) { | |
| const b = msg.branch_id || ''; | |
| const dot = b.match(/^main((?:\.\d+)+)$/); | |
| if (dot) return ' ↳' + dot[1].slice(1).replace(/\./g, '-'); | |
| const alt = [...b.matchAll(/_alt(\d+)/g)].map(m => m[1]).join('-'); | |
| if (alt) return ' ↳' + alt; | |
| return ' ↳' + msg.branch_level; | |
| } | |
| return ''; | |
| } | |
| function _senderLabel(msg, cfg) { | |
| const isHuman = msg.sender === 'human'; | |
| const fmt = (cfg || {}).senderFormat || 'default'; | |
| if (fmt === 'default') return isHuman ? 'User' : 'AI'; | |
| if (fmt === 'human-assistant') return isHuman ? 'Human' : 'Assistant'; | |
| if (fmt === 'custom' && cfg.humanLabel && cfg.assistantLabel) return isHuman ? cfg.humanLabel : cfg.assistantLabel; | |
| return msg.sender_label || (isHuman ? 'Human' : 'Assistant'); | |
| } | |
| function _toExcelCol(n) { let r = ''; while (n > 0) { n--; r = String.fromCharCode(65 + (n % 26)) + r; n = Math.floor(n / 26); } return r; } | |
| function _toRoman(n) { | |
| if (n <= 0 || n >= 4000) return String(n); | |
| const vs = [1000,900,500,400,100,90,50,40,10,9,5,4,1], ss = ['M','CM','D','CD','C','XC','L','XL','X','IX','V','IV','I']; | |
| let r = ''; | |
| for (let i = 0; i < vs.length; i++) while (n >= vs[i]) { r += ss[i]; n -= vs[i]; } | |
| return r; | |
| } | |
| // ─── Markdown generation ────────────────────────────────────────────────────── | |
| function _generateMarkdown(processedData, cfg) { | |
| cfg = cfg || {}; | |
| const { meta_info = {}, chat_history = [] } = processedData; | |
| const title = meta_info.title || _mdT('metadata.defaultTitle', 'Conversation'); | |
| const lines = []; | |
| // Header | |
| lines.push(`# ${title}`); | |
| lines.push(`*${_mdT('metadata.created', 'Created')}: ${meta_info.created_at || ''}*`); | |
| lines.push(`*${_mdT('metadata.exportTime', 'Exported')}: ${new Date().toLocaleString()}*`); | |
| lines.push('', '---', ''); | |
| const thinkFmt = cfg.thinkingFormat || 'codeblock'; | |
| const thinkLabel = _mdT('format.thinkingLabel', '💭 Thinking:'); | |
| const hLevel = cfg.includeHeaderPrefix !== false ? '#'.repeat(cfg.headerLevel || 2) + ' ' : ''; | |
| const msgLines = []; | |
| chat_history.forEach((msg, i) => { | |
| const num = i + 1; | |
| const bm = cfg.includeBranchMarkers !== false ? _branchMarker(msg) : ''; | |
| let msgHeader = hLevel; | |
| if (cfg.includeNumbering !== false && cfg.numberingFormat !== 'none') { | |
| const fmt = cfg.numberingFormat || 'numeric'; | |
| if (fmt === 'letter') msgHeader += _toExcelCol(num) + '. '; | |
| else if (fmt === 'roman') msgHeader += _toRoman(num) + '. '; | |
| else msgHeader += num + '. '; | |
| } | |
| msgHeader += _senderLabel(msg, cfg) + bm; | |
| const part = [msgHeader]; | |
| if (cfg.includeTimestamps && msg.timestamp) part.push(`*${msg.timestamp}*`); | |
| part.push(''); | |
| if (msg.thinking && cfg.includeThinking && msg.sender !== 'human' && (thinkFmt === 'codeblock' || thinkFmt === 'xml')) | |
| part.push(_fmtThinking(msg.thinking, thinkFmt, thinkLabel)); | |
| if (msg.display_text) part.push(msg.display_text, ''); | |
| if (msg.attachments?.length && cfg.includeAttachments !== false && msg.sender === 'human') | |
| part.push(_fmtAttachments(msg.attachments, cfg)); | |
| if (msg.thinking && cfg.includeThinking && msg.sender !== 'human' && thinkFmt === 'emoji') | |
| part.push(_fmtThinking(msg.thinking, thinkFmt, thinkLabel)); | |
| if (msg.artifacts?.length && cfg.includeArtifacts !== false && msg.sender !== 'human') | |
| msg.artifacts.forEach(a => part.push(_fmtArtifact(a))); | |
| if (msg.tools?.length && cfg.includeTools !== false) | |
| msg.tools.forEach(t => part.push(_fmtTool(t))); | |
| if (msg.citations?.length && cfg.includeCitations !== false) | |
| part.push(_fmtCitations(msg.citations)); | |
| msgLines.push(part.join('\n')); | |
| }); | |
| lines.push(msgLines.join('\n---\n\n')); | |
| return lines.join('\n'); | |
| } | |
| // ─── Image processing ───────────────────────────────────────────────────────── | |
| function _processImages(messages) { | |
| const imageFiles = []; | |
| const processed = messages.map(msg => { | |
| if (!msg.images || !msg.images.length) return msg; | |
| let text = msg.display_text || ''; | |
| msg.images.forEach((img, idx) => { | |
| const placeholder = img.placeholder || ` [图片${idx + 1}] `; | |
| const data = img.embedded_image?.data || (img.is_embedded_image ? img.link : null); | |
| if (data) { | |
| const m = data.match(/^data:([^;]+);base64,(.+)$/); | |
| if (m) { | |
| const ext = m[1].split('/')[1] || 'jpg'; | |
| const n = imageFiles.length + 1; | |
| const zipPath = `images/img_${String(n).padStart(3,'0')}.${ext}`; | |
| imageFiles.push({ zipPath, base64Data: m[2], mimeType: m[1] }); | |
| text = text.replace(placeholder.trim(), ``); | |
| } | |
| } | |
| }); | |
| return { ...msg, display_text: text }; | |
| }); | |
| return { messages: processed, imageFiles }; | |
| } | |
| // ─── Context block ──────────────────────────────────────────────────────────── | |
| function _contextBlock(exportContext, knowledgeRefs) { | |
| if (!exportContext) return ''; | |
| const { projectInfo, userMemory } = exportContext; | |
| if (!projectInfo && !userMemory) return ''; | |
| const toStr = v => !v ? '' : typeof v === 'string' ? v : JSON.stringify(v, null, 2); | |
| const parts = []; | |
| if (userMemory) { | |
| const mem = toStr(userMemory.memories); if (mem) parts.push(`<userMemories>${mem.replace(/\n/g,'\\n')}</userMemories>`); | |
| const pref = toStr(userMemory.preferences); if (pref) parts.push(`<userPreferences>${pref.replace(/\n/g,'\\n')}</userPreferences>`); | |
| } | |
| if (projectInfo) { | |
| const mem = toStr(projectInfo.memory); if (mem) parts.push(`<projectMemories>${mem.replace(/\n/g,'\\n')}</projectMemories>`); | |
| const ins = toStr(projectInfo.instructions); if (ins) parts.push(`<projectInstructions>${ins.replace(/\n/g,'\\n')}</projectInstructions>`); | |
| if (knowledgeRefs?.length) parts.push(`<projectKnowledge>${knowledgeRefs.map(r=>`- [${r.name}](${r.zipPath})`).join('\\n')}</projectKnowledge>`); | |
| } | |
| return parts.length ? parts.join('') + '\n\n---\n\n' : ''; | |
| } | |
| // ─── Download helpers ───────────────────────────────────────────────────────── | |
| function _triggerDownload(blob, filename) { | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; a.download = filename; a.style.display = 'none'; | |
| (document.body || document.documentElement).appendChild(a); | |
| a.click(); | |
| setTimeout(() => { a.remove(); URL.revokeObjectURL(url); }, 500); | |
| } | |
| async function _downloadMarkdownExport(result) { | |
| const { needsZip, mdText, imageFiles, knowledgeFiles, filename } = result; | |
| if (!needsZip) { | |
| _triggerDownload(new Blob([mdText], { type: 'text/markdown;charset=utf-8' }), filename); | |
| return; | |
| } | |
| // ZIP: use global fflate (extension injects fflate.min.js; userscript uses @require) | |
| const fl = (typeof fflate !== 'undefined') ? fflate : null; | |
| if (!fl || typeof fl.zip !== 'function') { | |
| // Fallback: download plain md without images | |
| console.warn('[Loominary] fflate not available, downloading Markdown without images'); | |
| _triggerDownload(new Blob([mdText], { type: 'text/markdown;charset=utf-8' }), filename.replace('.zip', '.md')); | |
| return; | |
| } | |
| const entries = {}; | |
| entries['conversation.md'] = fl.strToU8(mdText); | |
| for (const { zipPath, base64Data } of imageFiles) { | |
| const bin = atob(base64Data); | |
| const bytes = new Uint8Array(bin.length); | |
| for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); | |
| entries[zipPath] = bytes; | |
| } | |
| for (const { zipPath, content } of knowledgeFiles) { | |
| entries[zipPath] = fl.strToU8(content); | |
| } | |
| await new Promise((resolve, reject) => { | |
| fl.zip(entries, { level: 1 }, (err, data) => { | |
| if (err) { reject(err); return; } | |
| _triggerDownload(new Blob([data], { type: 'application/zip' }), filename); | |
| resolve(); | |
| }); | |
| }); | |
| } | |
| // ─── Config builder ─────────────────────────────────────────────────────────── | |
| function _buildGenConfig(c) { | |
| c = c || {}; | |
| return { | |
| includeTimestamps: !!c.includeTimestamps, | |
| includeThinking: !!c.includeThinking, | |
| includeArtifacts: c.includeArtifacts !== false, | |
| includeTools: !!c.includeTools, | |
| includeCitations: !!c.includeCitations, | |
| includeAttachments: c.includeAttachments !== false, | |
| includeBranchMarkers: c.includeBranchMarkers !== false, | |
| includeNumbering: c.includeNumbering !== false, | |
| numberingFormat: c.numberingFormat || 'numeric', | |
| senderFormat: c.senderFormat || 'default', | |
| humanLabel: c.humanLabel || 'Human', | |
| assistantLabel: c.assistantLabel || 'Assistant', | |
| includeHeaderPrefix: c.includeHeaderPrefix !== false, | |
| headerLevel: c.headerLevel || 2, | |
| thinkingFormat: c.thinkingFormat || 'codeblock', | |
| }; | |
| } | |
| // ─── Read export config from storage ───────────────────────────────────────── | |
| async function _readExportConfig() { | |
| if (typeof chrome !== 'undefined' && chrome.storage?.local?.get) { | |
| return new Promise(resolve => | |
| chrome.storage.local.get(['loominary_export_config'], r => { | |
| const cfg = r.loominary_export_config || {}; | |
| console.log('[Loominary] _readExportConfig (extension):', JSON.stringify(cfg)); | |
| resolve(cfg); | |
| }) | |
| ); | |
| } | |
| // Userscript: localStorage on the AI site | |
| try { | |
| const raw = localStorage.getItem('loominary_export_config') || '{}'; | |
| const cfg = JSON.parse(raw); | |
| console.log('[Loominary] _readExportConfig (userscript), raw:', raw); | |
| return cfg; | |
| } | |
| catch (_) { return {}; } | |
| } | |
| // ─── Main export entry point ────────────────────────────────────────────────── | |
| async function loominaryExportMarkdown(rawData, baseFilename, exportConfig, exportContext) { | |
| const parsedData = typeof rawData === 'string' ? JSON.parse(rawData) : rawData; | |
| const processedData = _parseRaw(parsedData); | |
| if (!processedData) { | |
| alert('[Loominary] Could not parse conversation data.'); | |
| return; | |
| } | |
| const cfg = exportConfig || await _readExportConfig(); | |
| const genCfg = _buildGenConfig(cfg); | |
| // Process images in messages | |
| const { messages: processedMsgs, imageFiles } = _processImages(processedData.chat_history || []); | |
| // Process knowledge files | |
| const knowledgeFiles = []; | |
| const knowledgeRefs = []; | |
| if (exportContext?.projectInfo?.knowledgeFiles) { | |
| exportContext.projectInfo.knowledgeFiles.forEach(({ name, content }) => { | |
| const safe = name.replace(/[<>:"/\\|?*]/g, '_'); | |
| const zipPath = 'knowledge/' + safe; | |
| knowledgeFiles.push({ zipPath, content }); | |
| knowledgeRefs.push({ name, zipPath }); | |
| }); | |
| } | |
| const contextBlock = _contextBlock(exportContext || null, knowledgeRefs); | |
| const bodyMd = _generateMarkdown({ ...processedData, chat_history: processedMsgs }, genCfg); | |
| const mdText = contextBlock ? contextBlock + bodyMd : bodyMd; | |
| const needsZip = imageFiles.length > 0 || knowledgeFiles.length > 0; | |
| const filename = (baseFilename || 'conversation') + (needsZip ? '.zip' : '.md'); | |
| await _downloadMarkdownExport({ needsZip, mdText, imageFiles, knowledgeFiles, filename }); | |
| } | |
| const ClaudeHandler = { | |
| _cache: { | |
| baseUrl: null, | |
| accountData: null, | |
| allConversations: null, | |
| allConversationsTime: 0, | |
| }, | |
| init: () => { | |
| // 扩展模式下 injected.js 已通过 script.src 注入(符合 CSP),不需要 inline script | |
| const isExtension = typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.id; | |
| if (!isExtension) { | |
| // Userscript 模式:通过 unsafeWindow 直接拦截 fetch/XHR 以捕获 userId | |
| // CSP 阻止内联 script 注入,但 unsafeWindow 可以直接修改页面的 window 对象 | |
| function captureUserId(url) { | |
| const match = url && url.match(/\/api\/organizations\/([a-f0-9-]+)\//); | |
| if (match && match[1] && !State.capturedUserId) { | |
| State.capturedUserId = match[1]; | |
| localStorage.setItem('claudeUserId', match[1]); | |
| } | |
| } | |
| const uw = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; | |
| const origFetch = uw.fetch; | |
| uw.fetch = function(resource) { | |
| const url = typeof resource === 'string' ? resource : (resource && resource.url || ''); | |
| captureUserId(url); | |
| return origFetch.apply(uw, arguments); | |
| }; | |
| const origXHROpen = uw.XMLHttpRequest.prototype.open; | |
| uw.XMLHttpRequest.prototype.open = function() { | |
| if (arguments[1]) captureUserId(arguments[1]); | |
| return origXHROpen.apply(this, arguments); | |
| }; | |
| } else { | |
| // 扩展模式:injected.js 通过 postMessage 发送 LOOMINARY_USER_ID_CAPTURED | |
| // build.py 的 header 已在 content.js 顶部监听并写入 localStorage | |
| // 这里直接从 localStorage 读取已捕获的 userId(延迟读取,首次使用时通过 ensureUserId 获取) | |
| } | |
| }, | |
| addUI: (controlsArea) => { | |
| const savedTreeMode = localStorage.getItem('treeMode'); | |
| const treeMode = savedTreeMode !== null ? savedTreeMode === 'true' : true; | |
| const branchToggle = Utils.createToggle(i18n.t('branchMode'), Config.TREE_SWITCH_ID, treeMode); | |
| branchToggle.querySelector('input')?.addEventListener('change', (e) => { | |
| localStorage.setItem('treeMode', e.target.checked); | |
| }); | |
| controlsArea.appendChild(branchToggle); | |
| controlsArea.appendChild(Utils.createToggle(i18n.t('includeImages'), Config.IMAGE_SWITCH_ID, State.includeImages)); | |
| document.getElementById(Config.IMAGE_SWITCH_ID)?.addEventListener('change', (e) => { | |
| State.includeImages = e.target.checked; | |
| localStorage.setItem('includeImages', State.includeImages); | |
| }); | |
| const memoryToggle = Utils.createToggle( | |
| i18n.currentLang === 'zh' ? '含记忆' : 'Memory', | |
| 'loominary-memory-switch', | |
| false | |
| ); | |
| const memoryToggleEl = memoryToggle.querySelector('input'); | |
| controlsArea.appendChild(memoryToggle); | |
| ClaudeHandler.getAccountSettings().then(settings => { | |
| if (settings && memoryToggleEl) { | |
| memoryToggleEl.checked = settings.enabled_saffron !== false; | |
| } | |
| }); | |
| memoryToggleEl?.addEventListener('change', async (e) => { | |
| const toggle = e.target; | |
| const newValue = toggle.checked; | |
| toggle.disabled = true; | |
| try { | |
| const response = await fetch(`${ClaudeHandler.getBaseUrl()}/api/account/settings`, { | |
| method: 'PATCH', | |
| headers: { 'Content-Type': 'application/json' }, | |
| credentials: 'include', | |
| body: JSON.stringify({ enabled_saffron: newValue }) | |
| }); | |
| if (!response.ok) throw new Error('Failed to update'); | |
| location.reload(); | |
| } catch (error) { | |
| toggle.checked = !newValue; | |
| toggle.disabled = false; | |
| State.showToast(i18n.currentLang === 'zh' ? '更新失败' : 'Update failed', 'error'); | |
| } | |
| }); | |
| }, | |
| addButtons: (controlsArea) => { | |
| controlsArea.appendChild(Utils.createButton( | |
| `${previewIcon} ${i18n.t('viewOnline')}`, | |
| async (btn) => { | |
| const uuid = ClaudeHandler.getCurrentUUID(); | |
| if (!uuid) { alert(i18n.t('uuidNotFound')); return; } | |
| if (!await ClaudeHandler.ensureUserId()) return; | |
| const original = btn.innerHTML; | |
| Utils.setButtonLoading(btn, i18n.t('loading')); | |
| try { | |
| const includeImages = document.getElementById(Config.IMAGE_SWITCH_ID)?.checked || false; | |
| const [data, meta] = await Promise.all([ | |
| ClaudeHandler.getConversation(uuid, includeImages), | |
| ClaudeHandler.getConversationMeta(uuid) | |
| ]); | |
| if (!data) throw new Error(i18n.t('fetchFailed')); | |
| if (meta) { | |
| if (meta.project_uuid) data.project_uuid = meta.project_uuid; | |
| if (meta.project) data.project = meta.project; | |
| } | |
| // 读取导出配置并收集 exportContext(project 信息 / 用户记忆) | |
| const exportCfg = await _readExportConfig(); | |
| const ctx = {}; | |
| const projectUuid = data.project_uuid; | |
| if (exportCfg.includeProjectInfo && projectUuid) { | |
| const [detail, memory, files] = await Promise.all([ | |
| ClaudeHandler.getProjectDetail(projectUuid), | |
| ClaudeHandler.getProjectMemory(projectUuid), | |
| ClaudeHandler.getProjectFiles(projectUuid) | |
| ]); | |
| const knowledgeFiles = []; | |
| if (files && files.length > 0) { | |
| const fileResults = await Promise.allSettled( | |
| files.map(f => ClaudeHandler.getProjectFileContent(projectUuid, f.uuid) | |
| .then(content => ({ name: f.file_name || f.uuid, content }))) | |
| ); | |
| for (const r of fileResults) { | |
| if (r.status === 'fulfilled' && r.value.content) { | |
| const c = r.value.content; | |
| knowledgeFiles.push({ name: r.value.name, content: typeof c === 'string' ? c : JSON.stringify(c) }); | |
| } | |
| } | |
| } | |
| ctx.projectInfo = { | |
| name: detail?.name || data.project?.name || '', | |
| description: detail?.description || '', | |
| instructions: detail?.prompt_template || '', | |
| memory: memory?.memory || '', | |
| knowledgeFiles | |
| }; | |
| } | |
| if (exportCfg.includeUserMemory) { | |
| const [profile, globalMem] = await Promise.all([ | |
| ClaudeHandler.getUserProfile(), | |
| ClaudeHandler.getGlobalMemory() | |
| ]); | |
| ctx.userMemory = { | |
| preferences: profile?.conversation_preferences || '', | |
| memories: globalMem?.memory || '' | |
| }; | |
| } | |
| const exportContext = Object.keys(ctx).length ? ctx : undefined; | |
| const jsonString = JSON.stringify(data, null, 2); | |
| const filename = `claude_${data.name || 'conversation'}_${uuid.substring(0, 8)}.json`; | |
| await Communicator.open(jsonString, filename, exportContext ? { exportContext } : undefined); | |
| } catch (error) { | |
| ErrorHandler.handle(error, 'Preview conversation', { | |
| userMessage: `${i18n.t('loadFailed')} ${error.message}` | |
| }); | |
| } finally { | |
| Utils.restoreButton(btn, original); | |
| } | |
| } | |
| )); | |
| controlsArea.appendChild(Utils.createButton( | |
| `${exportIcon} ${i18n.t('exportCurrentJSON')}`, | |
| async (btn) => { | |
| const uuid = ClaudeHandler.getCurrentUUID(); | |
| if (!uuid) { alert(i18n.t('uuidNotFound')); return; } | |
| if (!await ClaudeHandler.ensureUserId()) return; | |
| const original = btn.innerHTML; | |
| Utils.setButtonLoading(btn, i18n.t('exporting')); | |
| try { | |
| const includeImages = document.getElementById(Config.IMAGE_SWITCH_ID)?.checked || false; | |
| const [data, meta] = await Promise.all([ | |
| ClaudeHandler.getConversation(uuid, includeImages), | |
| ClaudeHandler.getConversationMeta(uuid) | |
| ]); | |
| if (!data) throw new Error(i18n.t('fetchFailed')); | |
| if (meta) { | |
| if (meta.project_uuid) data.project_uuid = meta.project_uuid; | |
| if (meta.project) data.project = meta.project; | |
| } | |
| // 读取导出配置(extension 从 chrome.storage,userscript 从 localStorage) | |
| const exportCfg = await _readExportConfig(); | |
| const exportContext = {}; | |
| const projectUuid = data.project_uuid; | |
| if (exportCfg.includeProjectInfo && projectUuid) { | |
| const [detail, memory, files] = await Promise.all([ | |
| ClaudeHandler.getProjectDetail(projectUuid), | |
| ClaudeHandler.getProjectMemory(projectUuid), | |
| ClaudeHandler.getProjectFiles(projectUuid) | |
| ]); | |
| const knowledgeFiles = []; | |
| if (files && files.length > 0) { | |
| const fileResults = await Promise.allSettled( | |
| files.map(f => ClaudeHandler.getProjectFileContent(projectUuid, f.uuid) | |
| .then(content => ({ name: f.file_name || f.uuid, content }))) | |
| ); | |
| for (const r of fileResults) { | |
| if (r.status === 'fulfilled' && r.value.content) { | |
| const c = r.value.content; | |
| knowledgeFiles.push({ name: r.value.name, content: typeof c === 'string' ? c : JSON.stringify(c) }); | |
| } | |
| } | |
| } | |
| exportContext.projectInfo = { | |
| name: detail?.name || data.project?.name || '', | |
| description: detail?.description || '', | |
| instructions: detail?.prompt_template || '', | |
| memory: memory?.memory || '', | |
| knowledgeFiles | |
| }; | |
| } | |
| if (exportCfg.includeUserMemory) { | |
| const [profile, globalMem] = await Promise.all([ | |
| ClaudeHandler.getUserProfile(), | |
| ClaudeHandler.getGlobalMemory() | |
| ]); | |
| exportContext.userMemory = { | |
| preferences: profile?.conversation_preferences || '', | |
| memories: globalMem?.memory || '' | |
| }; | |
| } | |
| const title = data.name || uuid.substring(0, 8); | |
| const filename = `claude_${Utils.sanitizeFilename(title)}_${uuid.substring(0, 8)}`; | |
| await loominaryExportMarkdown(data, filename, exportCfg, Object.keys(exportContext).length ? exportContext : null); | |
| } catch (error) { | |
| ErrorHandler.handle(error, 'Export conversation markdown'); | |
| } finally { | |
| Utils.restoreButton(btn, original); | |
| } | |
| } | |
| )); | |
| controlsArea.appendChild(Utils.createButton( | |
| `${zipIcon} ${i18n.t('exportAllConversations')}`, | |
| async (btn) => { | |
| return ClaudeHandler.exportAll(btn, controlsArea); | |
| } | |
| )); | |
| }, | |
| getCurrentUUID: () => window.location.pathname.match(/\/chat\/([a-zA-Z0-9-]+)/)?.[1], | |
| ensureUserId: async () => { | |
| if (State.capturedUserId) return State.capturedUserId; | |
| const saved = localStorage.getItem('claudeUserId'); | |
| if (saved) { | |
| State.capturedUserId = saved; | |
| return saved; | |
| } | |
| alert('未能检测到用户ID / User ID not detected'); | |
| return null; | |
| }, | |
| getBaseUrl: () => { | |
| if (ClaudeHandler._cache.baseUrl) return ClaudeHandler._cache.baseUrl; | |
| let url; | |
| if (window.location.hostname.includes('claude.ai')) { | |
| url = 'https://claude.ai'; | |
| } else { | |
| url = window.location.origin; | |
| } | |
| ClaudeHandler._cache.baseUrl = url; | |
| return url; | |
| }, | |
| getAllConversations: async (skipCache = false) => { | |
| const now = Date.now(); | |
| if (!skipCache && ClaudeHandler._cache.allConversations && now - ClaudeHandler._cache.allConversationsTime < 30000) { | |
| return ClaudeHandler._cache.allConversations; | |
| } | |
| const userId = await ClaudeHandler.ensureUserId(); | |
| if (!userId) return null; | |
| try { | |
| const response = await fetch(`${ClaudeHandler.getBaseUrl()}/api/organizations/${userId}/chat_conversations`); | |
| if (!response.ok) throw new Error('Fetch failed'); | |
| const data = await response.json(); | |
| ClaudeHandler._cache.allConversations = data; | |
| ClaudeHandler._cache.allConversationsTime = now; | |
| return data; | |
| } catch (error) { | |
| console.error('Get all conversations error:', error); | |
| return null; | |
| } | |
| }, | |
| getConversationMeta: async (uuid) => { | |
| try { | |
| const allConvs = await ClaudeHandler.getAllConversations(); | |
| if (!allConvs || !Array.isArray(allConvs)) return null; | |
| return allConvs.find(conv => conv.uuid === uuid) || null; | |
| } catch (error) { | |
| return null; | |
| } | |
| }, | |
| getConversation: async (uuid, includeImages = false, _userId = null) => { | |
| const userId = _userId || await ClaudeHandler.ensureUserId(); | |
| if (!userId) return null; | |
| try { | |
| const treeMode = document.getElementById(Config.TREE_SWITCH_ID)?.checked || false; | |
| const endpoint = treeMode ? | |
| `/api/organizations/${userId}/chat_conversations/${uuid}?tree=True&rendering_mode=messages&render_all_tools=true` : | |
| `/api/organizations/${userId}/chat_conversations/${uuid}`; | |
| const response = await fetch(`${ClaudeHandler.getBaseUrl()}${endpoint}`); | |
| if (!response.ok) throw new Error(`Fetch failed: ${response.status}`); | |
| const data = await response.json(); | |
| data.organization_id = userId; | |
| if (includeImages && data.chat_messages) { | |
| const imagePromises = []; | |
| const baseUrl = ClaudeHandler.getBaseUrl(); | |
| for (const msg of data.chat_messages) { | |
| for (const key of ['files', 'files_v2', 'attachments']) { | |
| if (Array.isArray(msg[key])) { | |
| for (const file of msg[key]) { | |
| const isImage = file.file_kind === 'image' || file.file_type?.startsWith('image/'); | |
| const imageUrl = file.preview_url || file.thumbnail_url || file.file_url; | |
| if (isImage && imageUrl && !file.embedded_image) { | |
| const fullUrl = imageUrl.startsWith('http') ? imageUrl : baseUrl + imageUrl; | |
| imagePromises.push( | |
| fetch(fullUrl).then(async (imgResp) => { | |
| if (imgResp.ok) { | |
| const blob = await imgResp.blob(); | |
| const base64 = await Utils.blobToBase64(blob); | |
| file.embedded_image = { type: 'image', format: blob.type, size: blob.size, data: base64, original_url: imageUrl }; | |
| } | |
| }).catch(() => {}) | |
| ); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| await Promise.all(imagePromises); | |
| } | |
| return data; | |
| } catch (error) { | |
| console.error('Get conversation error:', error); | |
| return null; | |
| } | |
| }, | |
| getAllProjects: async () => { | |
| const userId = await ClaudeHandler.ensureUserId(); | |
| if (!userId) return null; | |
| try { | |
| const response = await fetch(`${ClaudeHandler.getBaseUrl()}/api/organizations/${userId}/projects`); | |
| if (!response.ok) throw new Error('Fetch projects failed'); | |
| return await response.json(); | |
| } catch (error) { | |
| return null; | |
| } | |
| }, | |
| getProjectDetail: async (projectUuid) => { | |
| const userId = await ClaudeHandler.ensureUserId(); | |
| if (!userId) return null; | |
| try { | |
| const response = await fetch(`${ClaudeHandler.getBaseUrl()}/api/organizations/${userId}/projects/${projectUuid}`); | |
| if (!response.ok) throw new Error('Fetch project detail failed'); | |
| return await response.json(); | |
| } catch (error) { | |
| return null; | |
| } | |
| }, | |
| getGlobalMemory: async () => { | |
| const userId = await ClaudeHandler.ensureUserId(); | |
| if (!userId) return null; | |
| try { | |
| const response = await fetch(`${ClaudeHandler.getBaseUrl()}/api/organizations/${userId}/memory`); | |
| if (!response.ok) throw new Error('Fetch global memory failed'); | |
| return await response.json(); | |
| } catch (error) { | |
| return null; | |
| } | |
| }, | |
| getProjectMemory: async (projectUuid) => { | |
| const userId = await ClaudeHandler.ensureUserId(); | |
| if (!userId) return null; | |
| try { | |
| const response = await fetch(`${ClaudeHandler.getBaseUrl()}/api/organizations/${userId}/memory?project_uuid=${projectUuid}`); | |
| if (!response.ok) throw new Error('Fetch project memory failed'); | |
| return await response.json(); | |
| } catch (error) { | |
| return null; | |
| } | |
| }, | |
| getUserProfile: async () => { | |
| try { | |
| const response = await fetch(`${ClaudeHandler.getBaseUrl()}/api/account_profile`); | |
| if (!response.ok) throw new Error('Fetch user profile failed'); | |
| return await response.json(); | |
| } catch (error) { | |
| return null; | |
| } | |
| }, | |
| _fetchAccountData: async () => { | |
| if (ClaudeHandler._cache.accountData) return ClaudeHandler._cache.accountData; | |
| try { | |
| const response = await fetch(`${ClaudeHandler.getBaseUrl()}/api/account`); | |
| if (!response.ok) return null; | |
| const data = await response.json(); | |
| ClaudeHandler._cache.accountData = data; | |
| return data; | |
| } catch (error) { | |
| return null; | |
| } | |
| }, | |
| getAccountSettings: async () => { | |
| const data = await ClaudeHandler._fetchAccountData(); | |
| return data?.settings || null; | |
| }, | |
| getAccountInfo: async () => { | |
| return await ClaudeHandler._fetchAccountData(); | |
| }, | |
| getProjectFiles: async (projectUuid) => { | |
| const userId = await ClaudeHandler.ensureUserId(); | |
| if (!userId) return null; | |
| try { | |
| const response = await fetch(`${ClaudeHandler.getBaseUrl()}/api/organizations/${userId}/projects/${projectUuid}/docs`); | |
| if (!response.ok) return []; | |
| return await response.json(); | |
| } catch (error) { | |
| return []; | |
| } | |
| }, | |
| getProjectFileContent: async (projectUuid, fileUuid) => { | |
| const userId = await ClaudeHandler.ensureUserId(); | |
| if (!userId) return null; | |
| try { | |
| const response = await fetch(`${ClaudeHandler.getBaseUrl()}/api/organizations/${userId}/projects/${projectUuid}/docs/${fileUuid}`); | |
| if (!response.ok) return null; | |
| const data = await response.json(); | |
| return data.content || data; | |
| } catch (error) { | |
| return null; | |
| } | |
| }, | |
| exportAll: async (btn, controlsArea) => { | |
| const userId = await ClaudeHandler.ensureUserId(); | |
| if (!userId) return; | |
| // 检查导出模式配置 | |
| const isExtensionMode = typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.id; | |
| let exportAllMode = 'zip'; | |
| try { | |
| let exportCfg = {}; | |
| if (isExtensionMode) { | |
| exportCfg = await new Promise(resolve => | |
| chrome.storage.local.get(['loominary_export_config'], r => resolve(r.loominary_export_config || {})) | |
| ); | |
| } else { | |
| const raw = localStorage.getItem('loominary_export_config') || '{}'; | |
| exportCfg = JSON.parse(raw); | |
| } | |
| exportAllMode = exportCfg.exportAllMode || 'zip'; | |
| } catch (e) {} | |
| const original = btn.innerHTML; | |
| Utils.setButtonLoading(btn, i18n.t('detectingConversations')); | |
| let allConvs; | |
| try { | |
| allConvs = await ClaudeHandler.getAllConversations(); | |
| if (!allConvs || !Array.isArray(allConvs)) throw new Error(i18n.t('fetchFailed')); | |
| } catch (error) { | |
| ErrorHandler.handle(error, 'Detect conversations'); | |
| Utils.restoreButton(btn, original); | |
| return; | |
| } | |
| const totalCount = allConvs.length; | |
| Utils.restoreButton(btn, original); | |
| // app 模式:发送对话列表元数据到 React App | |
| if (exportAllMode === 'app') { | |
| const conversations = allConvs.map(conv => ({ | |
| uuid: conv.uuid, | |
| name: conv.name || conv.uuid, | |
| created_at: conv.created_at || null, | |
| updated_at: conv.updated_at || null, | |
| project_uuid: conv.project_uuid || null, | |
| project: conv.project || null, | |
| })); | |
| const baseUrl = ClaudeHandler.getBaseUrl(); | |
| await Communicator.open(null, 'browse_all', { | |
| action: 'browse_all', | |
| conversations, | |
| userId, | |
| baseUrl | |
| }); | |
| return; | |
| } | |
| // zip 模式:检查压缩库 | |
| if (typeof fflate === 'undefined' || typeof fflate.zip !== 'function' || typeof fflate.strToU8 !== 'function') { | |
| const errorMsg = i18n.currentLang === 'zh' | |
| ? '批量导出功能需要压缩库支持。\n\n由于当前平台的安全策略限制,该功能暂时不可用。\n建议使用"导出当前"功能单个导出对话。' | |
| : 'Batch export requires compression library.\n\nThis feature is currently unavailable due to platform security policies.\nPlease use "Export" button to export conversations individually.'; | |
| alert(errorMsg); | |
| return; | |
| } | |
| const promptMsg = `${i18n.t('foundConversations')} ${totalCount} ${i18n.t('conversations')}\n\n${i18n.t('selectExportCount')}`; | |
| const userInput = prompt(promptMsg, totalCount.toString()); | |
| if (userInput === null) { | |
| alert(i18n.t('exportCancelled')); | |
| return; | |
| } | |
| let exportCount = totalCount; | |
| const trimmedInput = userInput.trim(); | |
| if (trimmedInput !== '' && trimmedInput !== '0') { | |
| const parsed = parseInt(trimmedInput, 10); | |
| if (isNaN(parsed) || parsed < 0) { | |
| alert(i18n.t('invalidNumber')); | |
| return; | |
| } | |
| exportCount = Math.min(parsed, totalCount); | |
| } | |
| const progress = Utils.createProgressElem(controlsArea); | |
| progress.textContent = i18n.t('preparing'); | |
| Utils.setButtonLoading(btn, i18n.t('exporting')); | |
| const accountInfo = await ClaudeHandler.getAccountInfo(); | |
| const accountName = Utils.sanitizeFilename(accountInfo?.display_name || accountInfo?.full_name || 'claude'); | |
| // 读取 popup 导出配置 | |
| let includeProjectInfo = true, includeUserMemory = true; | |
| const isExtension = typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.id; | |
| try { | |
| let exportCfg = {}; | |
| if (isExtension) { | |
| exportCfg = await new Promise(resolve => | |
| chrome.storage.local.get(['loominary_export_config'], r => resolve(r.loominary_export_config || {})) | |
| ); | |
| } else { | |
| const raw = localStorage.getItem('loominary_export_config') || '{}'; | |
| exportCfg = JSON.parse(raw); | |
| } | |
| includeProjectInfo = exportCfg.includeProjectInfo !== false; | |
| includeUserMemory = exportCfg.includeUserMemory !== false; | |
| } catch (e) {} | |
| try { | |
| const includeImages = document.getElementById(Config.IMAGE_SWITCH_ID)?.checked || false; | |
| let exported = 0; | |
| const convsToExport = allConvs.slice(0, exportCount); | |
| const zipEntries = {}; | |
| const BATCH_SIZE = 25; | |
| for (let i = 0; i < convsToExport.length; i += BATCH_SIZE) { | |
| const batch = convsToExport.slice(i, i + BATCH_SIZE); | |
| progress.textContent = `${i18n.t('gettingConversation')} ${i + 1}-${Math.min(i + BATCH_SIZE, convsToExport.length)}/${convsToExport.length}${includeImages ? i18n.t('withImages') : ''}`; | |
| const results = await Promise.allSettled( | |
| batch.map(conv => ClaudeHandler.getConversation(conv.uuid, includeImages, userId).then(data => ({ conv, data }))) | |
| ); | |
| for (const result of results) { | |
| if (result.status === 'fulfilled' && result.value.data) { | |
| const { conv, data } = result.value; | |
| if (conv.project_uuid) data.project_uuid = conv.project_uuid; | |
| if (conv.project) data.project = conv.project; | |
| const title = Utils.sanitizeFilename(data.name || conv.uuid); | |
| const filename = `claude_${conv.uuid.substring(0, 8)}_${title}.json`; | |
| zipEntries[filename] = fflate.strToU8(JSON.stringify(data, null, 2)); | |
| exported++; | |
| } | |
| } | |
| if (i + BATCH_SIZE < convsToExport.length) { | |
| await Utils.sleep(Config.TIMING.BATCH_EXPORT_SLEEP); | |
| } | |
| } | |
| if (includeProjectInfo || includeUserMemory) { | |
| progress.textContent = i18n.currentLang === 'zh' ? '正在获取项目数据...' : 'Fetching project data...'; | |
| const projectsMeta = { exported_at: new Date().toISOString(), organization_id: userId, user_instructions: null, global_memory: null, projects: [] }; | |
| if (includeUserMemory) { | |
| try { | |
| const profile = await ClaudeHandler.getUserProfile(); | |
| if (profile?.conversation_preferences) projectsMeta.user_instructions = profile.conversation_preferences; | |
| } catch (e) {} | |
| try { | |
| const globalMem = await ClaudeHandler.getGlobalMemory(); | |
| if (globalMem) projectsMeta.global_memory = globalMem; | |
| } catch (e) {} | |
| } | |
| if (includeProjectInfo) { | |
| try { | |
| const projects = await ClaudeHandler.getAllProjects(); | |
| if (projects && Array.isArray(projects)) { | |
| const projectResults = await Promise.allSettled( | |
| projects.map(async (proj) => { | |
| const [detail, memory, files] = await Promise.all([ | |
| ClaudeHandler.getProjectDetail(proj.uuid), | |
| ClaudeHandler.getProjectMemory(proj.uuid), | |
| ClaudeHandler.getProjectFiles(proj.uuid) | |
| ]); | |
| const knowledgeFiles = []; | |
| if (files && files.length > 0) { | |
| const fileResults = await Promise.allSettled( | |
| files.map((file, fileIdx) => | |
| ClaudeHandler.getProjectFileContent(proj.uuid, file.uuid).then(content => ({ file, fileIdx, content })) | |
| ) | |
| ); | |
| for (const result of fileResults) { | |
| if (result.status === 'fulfilled' && result.value.content) { | |
| const { file, fileIdx, content } = result.value; | |
| const rawName = file.file_name || file.uuid; | |
| const ext = rawName.match(/\.([^.]+)$/)?.[1] || 'txt'; | |
| const baseName = Utils.sanitizeFilename(rawName.replace(/\.[^.]+$/, '')) || 'file'; | |
| const projName = Utils.sanitizeFilename(proj.name || proj.uuid.substring(0, 8)); | |
| const needsPrefix = /[\u0080-\uFFFF]/.test(rawName) && !/[\u4e00-\u9fa5]/.test(rawName); | |
| const seqNum = needsPrefix ? String(fileIdx + 1).padStart(3, '0') + '_' : ''; | |
| const filename = `projects/${projName}_${seqNum}${baseName}.${ext}`; | |
| zipEntries[filename] = fflate.strToU8(typeof content === 'string' ? content : JSON.stringify(content, null, 2)); | |
| knowledgeFiles.push(filename); | |
| } | |
| } | |
| } | |
| return { | |
| uuid: proj.uuid, | |
| name: proj.name || '(unnamed)', | |
| description: detail?.description || '', | |
| instructions: detail?.prompt_template || '', | |
| memory: memory?.memory || '', | |
| memory_updated_at: memory?.updated_at || null, | |
| archived: !!proj.archived_at, | |
| knowledge_files: knowledgeFiles | |
| }; | |
| }) | |
| ); | |
| for (const result of projectResults) { | |
| if (result.status === 'fulfilled') projectsMeta.projects.push(result.value); | |
| } | |
| } | |
| } catch (e) {} | |
| } // end includeProjectInfo | |
| zipEntries[`projects/${userId}_projects.json`] = fflate.strToU8(JSON.stringify(projectsMeta, null, 2)); | |
| } // end includeProjectInfo || includeUserMemory | |
| progress.textContent = `${i18n.t('compressing')}…`; | |
| const zipUint8 = await new Promise((resolve, reject) => { | |
| fflate.zip(zipEntries, { level: 1 }, (err, data) => { | |
| if (err) reject(err); | |
| else resolve(data); | |
| }); | |
| }); | |
| const zipBlob = new Blob([zipUint8], { type: 'application/zip' }); | |
| const zipFilename = `claude_${accountName}_${exportCount === totalCount ? 'all' : 'recent_' + exportCount}_${new Date().toISOString().slice(0, 10)}.zip`; | |
| Utils.downloadFile(zipBlob, zipFilename); | |
| alert(`${i18n.t('successExported')} ${exported} ${i18n.t('conversations')}`); | |
| } catch (error) { | |
| ErrorHandler.handle(error, 'Export all conversations'); | |
| } finally { | |
| Utils.restoreButton(btn, original); | |
| if (progress.parentNode) progress.parentNode.removeChild(progress); | |
| } | |
| } | |
| }; | |
| // Helper function to fetch images via GM_xmlhttpRequest (bypass CORS) | |
| function fetchViaGM(url, headers = {}) { | |
| return new Promise((resolve, reject) => { | |
| if (typeof GM_xmlhttpRequest === 'undefined') { | |
| fetch(url, { headers }).then(r => { | |
| if (r.ok) return r.blob(); | |
| return Promise.reject(new Error(`Status: ${r.status}`)); | |
| }).then(resolve).catch(reject); | |
| return; | |
| } | |
| GM_xmlhttpRequest({ | |
| method: "GET", | |
| url, | |
| headers, | |
| responseType: "blob", | |
| onload: r => { | |
| if (r.status >= 200 && r.status < 300) { | |
| resolve(r.response); | |
| } else { | |
| reject(new Error(`Status: ${r.status}`)); | |
| } | |
| }, | |
| onerror: e => reject(new Error(e.statusText || 'Network error')) | |
| }); | |
| }); | |
| } | |
| // Process image element and return base64 data | |
| async function processImageElement(imgElement, accessToken = null) { | |
| if (!imgElement) return null; | |
| const url = imgElement.src; | |
| if (!url || url.startsWith('data:')) return null; | |
| try { | |
| let base64Data, mimeType, size; | |
| if (url.startsWith('blob:')) { | |
| try { | |
| const blob = await fetch(url).then(r => r.ok ? r.blob() : Promise.reject()); | |
| base64Data = await Utils.blobToBase64(blob); | |
| mimeType = blob.type; | |
| size = blob.size; | |
| } catch { | |
| // Canvas fallback | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = imgElement.naturalWidth || imgElement.width; | |
| canvas.height = imgElement.naturalHeight || imgElement.height; | |
| canvas.getContext('2d').drawImage(imgElement, 0, 0); | |
| const isPhoto = canvas.width * canvas.height > 50000; | |
| const dataURL = isPhoto ? canvas.toDataURL('image/jpeg', 0.85) : canvas.toDataURL('image/png'); | |
| mimeType = isPhoto ? 'image/jpeg' : 'image/png'; | |
| base64Data = dataURL.split(',')[1]; | |
| size = Math.round((base64Data.length * 3) / 4); | |
| } | |
| } else { | |
| const headers = {}; | |
| if (url.includes('backend-api') && accessToken) { | |
| headers['Authorization'] = `Bearer ${accessToken}`; | |
| } | |
| const blob = await fetchViaGM(url, headers); | |
| base64Data = await Utils.blobToBase64(blob); | |
| mimeType = blob.type; | |
| size = blob.size; | |
| // Fix MIME type if it's octet-stream or empty | |
| if (!mimeType || mimeType === 'application/octet-stream' || !mimeType.startsWith('image/')) { | |
| if (url.includes('.jpg') || url.includes('.jpeg')) { | |
| mimeType = 'image/jpeg'; | |
| } else if (url.includes('.png')) { | |
| mimeType = 'image/png'; | |
| } else if (url.includes('.gif')) { | |
| mimeType = 'image/gif'; | |
| } else if (url.includes('.webp')) { | |
| mimeType = 'image/webp'; | |
| } else { | |
| // Detect from base64 magic bytes | |
| const firstBytes = base64Data.substring(0, 20); | |
| if (firstBytes.startsWith('iVBORw0KGgo')) mimeType = 'image/png'; | |
| else if (firstBytes.startsWith('/9j/')) mimeType = 'image/jpeg'; | |
| else if (firstBytes.startsWith('R0lGOD')) mimeType = 'image/gif'; | |
| else if (firstBytes.startsWith('UklGR')) mimeType = 'image/webp'; | |
| else mimeType = 'image/png'; | |
| } | |
| } | |
| } | |
| return { type: 'image', format: mimeType, size, data: base64Data, original_src: url }; | |
| } catch (e) { | |
| console.error('[ChatGPT] Failed to process image:', url.substring(0, 80)); | |
| return null; | |
| } | |
| } | |
| const ChatGPTHandler = { | |
| init: () => { | |
| const rawFetch = window.fetch; | |
| window.fetch = async function(resource, options) { | |
| const headers = options?.headers; | |
| if (headers) { | |
| let authHeader = null; | |
| if (typeof headers === 'string') { | |
| authHeader = headers; | |
| } else if (headers instanceof Headers) { | |
| authHeader = headers.get('Authorization'); | |
| } else { | |
| authHeader = headers.Authorization || headers.authorization; | |
| } | |
| if (authHeader?.startsWith('Bearer ')) { | |
| const token = authHeader.slice(7); | |
| if (token && token.toLowerCase() !== 'dummy') { | |
| State.chatgptAccessToken = token; | |
| } | |
| } | |
| } | |
| return rawFetch.apply(this, arguments); | |
| }; | |
| }, | |
| ensureAccessToken: async () => { | |
| if (State.chatgptAccessToken) return State.chatgptAccessToken; | |
| try { | |
| const response = await fetch('/api/auth/session?unstable_client=true'); | |
| const session = await response.json(); | |
| if (session.accessToken) { | |
| State.chatgptAccessToken = session.accessToken; | |
| return session.accessToken; | |
| } | |
| } catch (error) { | |
| console.error('Failed to get access token:', error); | |
| } | |
| return null; | |
| }, | |
| getOaiDeviceId: () => { | |
| const cookieString = document.cookie; | |
| const match = cookieString.match(/oai-did=([^;]+)/); | |
| return match ? match[1] : null; | |
| }, | |
| getCurrentConversationId: () => { | |
| const match = window.location.pathname.match(/\/c\/([a-zA-Z0-9-]+)/); | |
| return match ? match[1] : null; | |
| }, | |
| getAllConversations: async () => { | |
| const token = await ChatGPTHandler.ensureAccessToken(); | |
| if (!token) throw new Error(i18n.t('tokenNotFound')); | |
| const deviceId = ChatGPTHandler.getOaiDeviceId(); | |
| if (!deviceId) throw new Error('Cannot get device ID'); | |
| const headers = { | |
| 'Authorization': `Bearer ${token}`, | |
| 'oai-device-id': deviceId | |
| }; | |
| if (State.chatgptWorkspaceType === 'team' && State.chatgptWorkspaceId) { | |
| headers['ChatGPT-Account-Id'] = State.chatgptWorkspaceId; | |
| } | |
| const allConversations = []; | |
| let offset = 0; | |
| let hasMore = true; | |
| while (hasMore) { | |
| const response = await fetch(`/backend-api/conversations?offset=${offset}&limit=28&order=updated`, { headers }); | |
| if (!response.ok) throw new Error('Failed to fetch conversation list'); | |
| const data = await response.json(); | |
| if (data.items && data.items.length > 0) { | |
| allConversations.push(...data.items); | |
| hasMore = data.items.length === 28; | |
| offset += data.items.length; | |
| } else { | |
| hasMore = false; | |
| } | |
| } | |
| return allConversations; | |
| }, | |
| // Extract images from DOM for current conversation | |
| extractImagesFromDOM: async (conversationId, includeImages, accessToken = null) => { | |
| if (!includeImages) return {}; | |
| const currentId = ChatGPTHandler.getCurrentConversationId(); | |
| if (currentId !== conversationId) { | |
| console.log('[ChatGPT] Not current conversation, skipping DOM image extraction'); | |
| return {}; | |
| } | |
| const imageMap = {}; | |
| let lastUserMessageId = null; // 追踪最后的用户消息 ID,用于关联孤立的助手图片 | |
| const messageGroups = document.querySelectorAll('[data-testid^="conversation-turn-"]'); | |
| for (const group of messageGroups) { | |
| // 查找整个 group 中所有可能的 message-id | |
| const findMessageId = (container) => { | |
| if (!container) return null; | |
| return container.getAttribute('data-message-id') || | |
| container.closest('[data-message-id]')?.getAttribute('data-message-id') || | |
| group.querySelector('[data-message-id]')?.getAttribute('data-message-id'); | |
| }; | |
| // User messages - look for uploaded images | |
| const userContainer = group.querySelector('[data-message-author-role="user"]'); | |
| if (userContainer) { | |
| // 记录用户消息 ID,即使没有图片也要记录(用于关联后续的助手生成图片) | |
| const userMessageId = findMessageId(userContainer); | |
| if (userMessageId) { | |
| lastUserMessageId = userMessageId; | |
| } | |
| // Find images in user message | |
| const userImages = userContainer.querySelectorAll('img[src*="backend-api"], img[src*="files.oaiusercontent.com"], img[src*="oaiusercontent"]'); | |
| if (userImages.length > 0) { | |
| const images = []; | |
| for (const img of userImages) { | |
| const imageData = await processImageElement(img, accessToken); | |
| if (imageData) images.push(imageData); | |
| } | |
| if (images.length > 0 && lastUserMessageId) { | |
| if (!imageMap[lastUserMessageId]) imageMap[lastUserMessageId] = {}; | |
| imageMap[lastUserMessageId].user = images; | |
| } | |
| } | |
| } | |
| // Assistant messages - look for generated images (including DALL-E generated images) | |
| const assistantContainer = group.querySelector('[data-message-author-role="assistant"]'); | |
| // Collect all candidate assistant images from multiple sources | |
| const candidateImages = []; | |
| const seenSrcs = new Set(); | |
| // Helper to add images without duplicates | |
| const addImages = (imgs) => { | |
| for (const img of imgs) { | |
| if (img.src && !seenSrcs.has(img.src)) { | |
| seenSrcs.add(img.src); | |
| candidateImages.push(img); | |
| } | |
| } | |
| }; | |
| // 1. Images in assistant container | |
| if (assistantContainer) { | |
| addImages(assistantContainer.querySelectorAll('img')); | |
| } | |
| // 2. AI-generated images - find by id pattern (image-xxxx) | |
| addImages(group.querySelectorAll('[id^="image-"] img')); | |
| // 3. Images with estuary/content URLs (generated content) | |
| addImages(group.querySelectorAll('img[src*="estuary/content"], img[src*="estuary"]')); | |
| // 4. Images with "已生成图片" or "Generated" alt text | |
| addImages(group.querySelectorAll('img[alt*="生成"], img[alt*="Generated"], img[alt*="generated"]')); | |
| // 5. Find imagegen containers by iterating through elements (handles class names with /) | |
| group.querySelectorAll('div').forEach(div => { | |
| const classList = div.className || ''; | |
| if (classList.includes('imagegen') || classList.includes('image-gen')) { | |
| addImages(div.querySelectorAll('img')); | |
| } | |
| }); | |
| // 6. Find by aria-label | |
| addImages(group.querySelectorAll('img[aria-label*="图片"], img[aria-label*="image"]')); | |
| // Exclude user images | |
| const userImgSrcs = new Set(); | |
| group.querySelectorAll('[data-message-author-role="user"] img').forEach(img => userImgSrcs.add(img.src)); | |
| const uniqueImages = candidateImages.filter(img => !userImgSrcs.has(img.src)); | |
| if (uniqueImages.length > 0) { | |
| const images = []; | |
| for (const img of uniqueImages) { | |
| // Skip loading/placeholder images (blurred intermediate images during generation) | |
| // Check blur on img itself | |
| const imgStyle = window.getComputedStyle(img); | |
| const imgFilter = imgStyle.filter || imgStyle.webkitFilter || ''; | |
| if (imgFilter.includes('blur')) continue; | |
| // Check blur on parent element (ChatGPT applies blur to parent div) | |
| const parent = img.parentElement; | |
| if (parent) { | |
| const parentStyle = window.getComputedStyle(parent); | |
| const parentFilter = parentStyle.filter || parentStyle.webkitFilter || ''; | |
| if (parentFilter.includes('blur')) continue; | |
| } | |
| // Skip images with loading/placeholder/pulse classes | |
| const classList = img.className || ''; | |
| if (classList.includes('loading') || classList.includes('placeholder') || | |
| classList.includes('skeleton') || classList.includes('pulse')) continue; | |
| // Skip images with loading aria attributes | |
| if (img.getAttribute('aria-busy') === 'true' || img.getAttribute('data-loading') === 'true') continue; | |
| // Wait for image to load if needed | |
| if (!img.complete) { | |
| await new Promise(r => { | |
| img.onload = img.onerror = r; | |
| setTimeout(r, 3000); | |
| }); | |
| } | |
| // Skip small images (icons/UI elements) | |
| const width = img.naturalWidth || img.width || 0; | |
| const height = img.naturalHeight || img.height || 0; | |
| if (width < 50 || height < 50) continue; | |
| const imageData = await processImageElement(img, accessToken); | |
| if (imageData) images.push(imageData); | |
| } | |
| if (images.length > 0) { | |
| // 尝试多种方式获取 messageId | |
| let messageId = findMessageId(assistantContainer); | |
| // 如果 assistantContainer 没有 messageId,尝试查找 group 中的任何 assistant 相关的 messageId | |
| if (!messageId) { | |
| // 方法1: 查找所有 data-message-id 属性 | |
| const allMessageIds = group.querySelectorAll('[data-message-id]'); | |
| for (const el of allMessageIds) { | |
| const role = el.getAttribute('data-message-author-role'); | |
| if (role === 'assistant') { | |
| messageId = el.getAttribute('data-message-id'); | |
| break; | |
| } | |
| } | |
| } | |
| // 方法2: 在同一 group 中查找用户消息 | |
| if (!messageId) { | |
| const userContainer = group.querySelector('[data-message-author-role="user"]'); | |
| const userMessageId = findMessageId(userContainer); | |
| if (userMessageId) { | |
| if (!imageMap[userMessageId]) imageMap[userMessageId] = {}; | |
| imageMap[userMessageId].assistant_generated = images; | |
| continue; | |
| } | |
| } | |
| // 方法3: 使用之前遍历过的用户消息 ID(跨 group 查找) | |
| if (!messageId && lastUserMessageId) { | |
| if (!imageMap[lastUserMessageId]) imageMap[lastUserMessageId] = {}; | |
| imageMap[lastUserMessageId].assistant_generated = images; | |
| continue; | |
| } | |
| if (messageId) { | |
| if (!imageMap[messageId]) imageMap[messageId] = {}; | |
| imageMap[messageId].assistant = images; | |
| } | |
| } | |
| } | |
| } | |
| return imageMap; | |
| }, | |
| getConversation: async (conversationId, includeImages = false) => { | |
| const token = await ChatGPTHandler.ensureAccessToken(); | |
| if (!token) { | |
| console.error('[ChatGPT] Token not found'); | |
| throw new Error(i18n.t('tokenNotFound')); | |
| } | |
| const deviceId = ChatGPTHandler.getOaiDeviceId(); | |
| if (!deviceId) { | |
| console.error('[ChatGPT] Device ID not found in cookies'); | |
| throw new Error('Cannot get device ID'); | |
| } | |
| const headers = { | |
| 'Authorization': `Bearer ${token}`, | |
| 'oai-device-id': deviceId | |
| }; | |
| if (State.chatgptWorkspaceType === 'team' && State.chatgptWorkspaceId) { | |
| headers['ChatGPT-Account-Id'] = State.chatgptWorkspaceId; | |
| } | |
| const response = await fetch(`/backend-api/conversation/${conversationId}`, { headers }); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| console.error('[ChatGPT] Fetch failed:', { | |
| status: response.status, | |
| statusText: response.statusText, | |
| error: errorText, | |
| conversationId, | |
| workspaceType: State.chatgptWorkspaceType | |
| }); | |
| let errorMessage = `Failed to fetch conversation (${response.status}): ${errorText || response.statusText}`; | |
| if (response.status === 404) { | |
| const currentMode = State.chatgptWorkspaceType === 'team' ? i18n.t('teamWorkspace') : i18n.t('userWorkspace'); | |
| const suggestMode = State.chatgptWorkspaceType === 'team' ? i18n.t('userWorkspace') : i18n.t('teamWorkspace'); | |
| errorMessage += `\n\n当前模式: ${currentMode}\n建议尝试切换到: ${suggestMode}`; | |
| if (State.chatgptWorkspaceType === 'team') { | |
| errorMessage += '并手动填写工作区ID'; | |
| } else { | |
| errorMessage += '并手动填写个人ID'; | |
| } | |
| } | |
| throw new Error(errorMessage); | |
| } | |
| const data = await response.json(); | |
| // Extract and merge images from DOM if requested | |
| if (includeImages) { | |
| const imageMap = await ChatGPTHandler.extractImagesFromDOM(conversationId, includeImages, token); | |
| // Merge images into conversation data | |
| if (data.mapping && Object.keys(imageMap).length > 0) { | |
| const messageIdToNodeId = {}; | |
| for (const nodeId in data.mapping) { | |
| const node = data.mapping[nodeId]; | |
| if (node?.message?.id) { | |
| messageIdToNodeId[node.message.id] = nodeId; | |
| } | |
| } | |
| for (const [messageId, images] of Object.entries(imageMap)) { | |
| const nodeId = messageIdToNodeId[messageId]; | |
| if (nodeId && data.mapping[nodeId]) { | |
| if (!data.mapping[nodeId].loominary_images) { | |
| data.mapping[nodeId].loominary_images = {}; | |
| } | |
| if (images.user) { | |
| data.mapping[nodeId].loominary_images.user = images.user; | |
| } | |
| if (images.assistant) { | |
| data.mapping[nodeId].loominary_images.assistant = images.assistant; | |
| } | |
| if (images.assistant_generated) { | |
| data.mapping[nodeId].loominary_images.assistant_generated = images.assistant_generated; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| return data; | |
| }, | |
| previewConversation: async () => { | |
| const conversationId = ChatGPTHandler.getCurrentConversationId(); | |
| if (!conversationId) { | |
| alert(i18n.t('uuidNotFound')); | |
| return; | |
| } | |
| try { | |
| const includeImages = State.includeImages || false; | |
| const data = await ChatGPTHandler.getConversation(conversationId, includeImages); | |
| const jsonString = JSON.stringify(data, null, 2); | |
| const filename = `chatgpt_${data.title || 'conversation'}_${conversationId.substring(0, 8)}.json`; | |
| await Communicator.open(jsonString, filename); | |
| } catch (error) { | |
| ErrorHandler.handle(error, 'Preview conversation', { | |
| userMessage: `${i18n.t('loadFailed')} ${error.message}` | |
| }); | |
| } | |
| }, | |
| exportCurrent: async (btn) => { | |
| const conversationId = ChatGPTHandler.getCurrentConversationId(); | |
| if (!conversationId) { | |
| alert(i18n.t('uuidNotFound')); | |
| return; | |
| } | |
| const original = btn.innerHTML; | |
| Utils.setButtonLoading(btn, i18n.t('exporting')); | |
| try { | |
| const includeImages = State.includeImages || false; | |
| const data = await ChatGPTHandler.getConversation(conversationId, includeImages); | |
| const filename = prompt(i18n.t('enterFilename'), data.title || i18n.t('untitledChat')); | |
| if (!filename) { | |
| Utils.restoreButton(btn, original); | |
| return; | |
| } | |
| const baseName = `chatgpt_${Utils.sanitizeFilename(filename)}_${new Date().toISOString().slice(0, 10)}`; | |
| Utils.downloadJSON(JSON.stringify(data, null, 2), `${baseName}.json`); | |
| } catch (error) { | |
| ErrorHandler.handle(error, 'Export conversation'); | |
| } finally { | |
| Utils.restoreButton(btn, original); | |
| } | |
| }, | |
| exportAll: async (btn, controlsArea) => { | |
| if (typeof fflate === 'undefined' || typeof fflate.zipSync !== 'function' || typeof fflate.strToU8 !== 'function') { | |
| const errorMsg = i18n.currentLang === 'zh' | |
| ? '批量导出功能需要压缩库支持。\n\n由于当前平台的安全策略限制,该功能暂时不可用。\n建议使用"导出当前"功能单个导出对话。' | |
| : 'Batch export requires compression library.\n\nThis feature is currently unavailable due to platform security policies.\nPlease use "Export" button to export conversations individually.'; | |
| alert(errorMsg); | |
| return; | |
| } | |
| // 先探测对话数量 | |
| const original = btn.innerHTML; | |
| Utils.setButtonLoading(btn, i18n.t('detectingConversations')); | |
| let allConvs; | |
| try { | |
| allConvs = await ChatGPTHandler.getAllConversations(); | |
| if (!allConvs || !Array.isArray(allConvs)) throw new Error(i18n.t('fetchFailed')); | |
| } catch (error) { | |
| ErrorHandler.handle(error, 'Detect conversations'); | |
| Utils.restoreButton(btn, original); | |
| return; | |
| } | |
| const totalCount = allConvs.length; | |
| Utils.restoreButton(btn, original); | |
| // 弹出确认框让用户选择导出数量 | |
| const promptMsg = i18n.currentLang === 'zh' | |
| ? `${i18n.t('foundConversations')} ${totalCount} ${i18n.t('conversations')}\n\n${i18n.t('selectExportCount')}` | |
| : `${i18n.t('foundConversations')} ${totalCount} ${i18n.t('conversations')}\n\n${i18n.t('selectExportCount')}`; | |
| const userInput = prompt(promptMsg, totalCount.toString()); | |
| // 用户取消 | |
| if (userInput === null) { | |
| alert(i18n.t('exportCancelled')); | |
| return; | |
| } | |
| // 解析用户输入 | |
| let exportCount = totalCount; | |
| const trimmedInput = userInput.trim(); | |
| if (trimmedInput !== '' && trimmedInput !== '0') { | |
| const parsed = parseInt(trimmedInput, 10); | |
| if (isNaN(parsed) || parsed < 0) { | |
| alert(i18n.t('invalidNumber')); | |
| return; | |
| } | |
| exportCount = Math.min(parsed, totalCount); | |
| } | |
| // 开始导出 | |
| const progress = Utils.createProgressElem(controlsArea); | |
| progress.textContent = i18n.t('preparing'); | |
| Utils.setButtonLoading(btn, i18n.t('exporting')); | |
| try { | |
| let exported = 0; | |
| const zipEntries = {}; | |
| const includeImages = State.includeImages || false; | |
| const currentConvId = ChatGPTHandler.getCurrentConversationId(); | |
| // 只导出最近的 exportCount 个对话 | |
| const convsToExport = allConvs.slice(0, exportCount); | |
| console.log(`Starting export of ${convsToExport.length} conversations (out of ${totalCount} total)`); | |
| for (let i = 0; i < convsToExport.length; i++) { | |
| const conv = convsToExport[i]; | |
| progress.textContent = `${i18n.t('gettingConversation')} ${i + 1}/${convsToExport.length}`; | |
| if (i > 0 && i % 5 === 0) { | |
| await new Promise(resolve => setTimeout(resolve, Config.TIMING.BATCH_EXPORT_YIELD)); | |
| } else if (i > 0) { | |
| await Utils.sleep(Config.TIMING.BATCH_EXPORT_SLEEP); | |
| } | |
| try { | |
| // Note: DOM image extraction only works for the currently open conversation | |
| const shouldExtractImages = includeImages && conv.id === currentConvId; | |
| const data = await ChatGPTHandler.getConversation(conv.id, shouldExtractImages); | |
| if (data) { | |
| const title = Utils.sanitizeFilename(data.title || conv.id); | |
| const filename = `chatgpt_${conv.id.substring(0, 8)}_${title}.json`; | |
| zipEntries[filename] = fflate.strToU8(JSON.stringify(data, null, 2)); | |
| exported++; | |
| } | |
| } catch (error) { | |
| console.error(`Failed to process ${conv.id}:`, error); | |
| } | |
| } | |
| progress.textContent = `${i18n.t('compressing')}…`; | |
| const zipUint8 = fflate.zipSync(zipEntries, { level: 1 }); | |
| const zipBlob = new Blob([zipUint8], { type: 'application/zip' }); | |
| const zipFilename = `chatgpt_export_${exportCount === totalCount ? 'all' : 'recent_' + exportCount}_${new Date().toISOString().slice(0, 10)}.zip`; | |
| Utils.downloadFile(zipBlob, zipFilename); | |
| alert(`${i18n.t('successExported')} ${exported} ${i18n.t('conversations')}`); | |
| } catch (error) { | |
| ErrorHandler.handle(error, 'Export all conversations'); | |
| } finally { | |
| Utils.restoreButton(btn, original); | |
| if (progress.parentNode) progress.parentNode.removeChild(progress); | |
| } | |
| }, | |
| addUI: (controls) => { | |
| // Image inclusion toggle | |
| const imageToggle = Utils.createToggle( | |
| i18n.t('includeImages'), | |
| Config.IMAGE_SWITCH_ID, | |
| State.includeImages | |
| ); | |
| const imageToggleInput = imageToggle.querySelector('input'); | |
| imageToggleInput.addEventListener('change', (e) => { | |
| State.includeImages = e.target.checked; | |
| localStorage.setItem('includeImages', State.includeImages); | |
| console.log('[ChatGPT] Include images:', State.includeImages); | |
| }); | |
| controls.appendChild(imageToggle); | |
| // Workspace type toggle | |
| const initialLabel = State.chatgptWorkspaceType === 'team' ? i18n.t('teamWorkspace') : i18n.t('userWorkspace'); | |
| const workspaceToggle = Utils.createToggle( | |
| initialLabel, | |
| Config.WORKSPACE_TYPE_ID, | |
| State.chatgptWorkspaceType === 'team' | |
| ); | |
| const toggleInput = workspaceToggle.querySelector('input'); | |
| const toggleLabel = workspaceToggle.querySelector('.loominary-toggle-label'); | |
| toggleInput.addEventListener('change', (e) => { | |
| State.chatgptWorkspaceType = e.target.checked ? 'team' : 'user'; | |
| localStorage.setItem('chatGPTWorkspaceType', State.chatgptWorkspaceType); | |
| if (toggleLabel) toggleLabel.textContent = e.target.checked ? i18n.t('teamWorkspace') : i18n.t('userWorkspace'); | |
| console.log('[ChatGPT] Workspace type changed to:', State.chatgptWorkspaceType); | |
| UI.recreatePanel(); | |
| }); | |
| controls.appendChild(workspaceToggle); | |
| }, | |
| addButtons: (controls) => { | |
| controls.appendChild(Utils.createButton( | |
| `${previewIcon} ${i18n.t('viewOnline')}`, | |
| () => ChatGPTHandler.previewConversation() | |
| )); | |
| controls.appendChild(Utils.createButton( | |
| `${exportIcon} ${i18n.t('exportCurrentJSON')}`, | |
| (btn) => ChatGPTHandler.exportCurrent(btn) | |
| )); | |
| controls.appendChild(Utils.createButton( | |
| `${zipIcon} ${i18n.t('exportAllConversations')}`, | |
| (btn) => ChatGPTHandler.exportAll(btn, controls) | |
| )); | |
| const idLabel = document.createElement('div'); | |
| idLabel.className = 'loominary-input-trigger'; | |
| if (State.chatgptWorkspaceType === 'user') { | |
| idLabel.textContent = `${i18n.t('manualUserId')}`; | |
| idLabel.addEventListener('click', () => { | |
| const newId = prompt(i18n.t('enterUserId')); | |
| if (newId?.trim()) { | |
| State.chatgptUserId = newId.trim(); | |
| localStorage.setItem('chatGPTUserId', State.chatgptUserId); | |
| alert(i18n.t('userIdSaved')); | |
| } | |
| }); | |
| } else { | |
| idLabel.textContent = `${i18n.t('manualWorkspaceId')}`; | |
| idLabel.addEventListener('click', () => { | |
| const newId = prompt(i18n.t('enterWorkspaceId')); | |
| if (newId?.trim()) { | |
| State.chatgptWorkspaceId = newId.trim(); | |
| localStorage.setItem('chatGPTWorkspaceId', State.chatgptWorkspaceId); | |
| alert(i18n.t('workspaceIdSaved')); | |
| } | |
| }); | |
| } | |
| controls.appendChild(idLabel); | |
| } | |
| }; | |
| // Helper function to fetch images via GM_xmlhttpRequest (routes through background proxy in extension) | |
| function grok_fetchViaGM(url, headers = {}) { | |
| return new Promise((resolve, reject) => { | |
| if (typeof GM_xmlhttpRequest === 'undefined') { | |
| return reject(new Error('GM_xmlhttpRequest not available')); | |
| } | |
| GM_xmlhttpRequest({ | |
| method: "GET", | |
| url, | |
| headers, | |
| responseType: "blob", | |
| onload: r => { | |
| if (r.status >= 200 && r.status < 300) { | |
| resolve(r.response); | |
| } else { | |
| reject(new Error(`Status: ${r.status}`)); | |
| } | |
| }, | |
| onerror: e => reject(new Error(e.statusText || 'Network error')), | |
| ontimeout: () => reject(new Error('Request timeout')) | |
| }); | |
| }); | |
| } | |
| // Fetch image URL via GM proxy and return base64 data (bypasses canvas, gets original file) | |
| async function grok_fetchImageAsData(url) { | |
| const blob = await grok_fetchViaGM(url); | |
| const base64Data = await Utils.blobToBase64(blob); | |
| let mimeType = blob.type; | |
| if (!mimeType || mimeType === 'application/octet-stream' || !mimeType.startsWith('image/')) { | |
| const firstBytes = base64Data.substring(0, 20); | |
| if (firstBytes.startsWith('iVBORw0KGgo')) mimeType = 'image/png'; | |
| else if (firstBytes.startsWith('/9j/')) mimeType = 'image/jpeg'; | |
| else if (firstBytes.startsWith('R0lGOD')) mimeType = 'image/gif'; | |
| else if (firstBytes.startsWith('UklGR')) mimeType = 'image/webp'; | |
| else mimeType = 'image/jpeg'; | |
| } | |
| return { type: 'image', format: mimeType, size: blob.size, data: base64Data, original_src: url }; | |
| } | |
| // Process image element and return base64 data | |
| async function grok_processImageElement(imgElement) { | |
| if (!imgElement) return null; | |
| const url = imgElement.src; | |
| if (!url || url.startsWith('data:')) return null; | |
| try { | |
| let base64Data, mimeType, size; | |
| if (url.startsWith('blob:')) { | |
| try { | |
| const blob = await fetch(url).then(r => r.ok ? r.blob() : Promise.reject()); | |
| base64Data = await Utils.blobToBase64(blob); | |
| mimeType = blob.type; | |
| size = blob.size; | |
| } catch (blobError) { | |
| // Canvas fallback for blob URLs | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = imgElement.naturalWidth || imgElement.width; | |
| canvas.height = imgElement.naturalHeight || imgElement.height; | |
| canvas.getContext('2d').drawImage(imgElement, 0, 0); | |
| const isPhoto = canvas.width * canvas.height > 50000; | |
| const dataURL = isPhoto ? canvas.toDataURL('image/jpeg', 0.85) : canvas.toDataURL('image/png'); | |
| mimeType = isPhoto ? 'image/jpeg' : 'image/png'; | |
| base64Data = dataURL.split(',')[1]; | |
| size = Math.round((base64Data.length * 3) / 4); | |
| } | |
| } else { | |
| // Try Canvas method first (more reliable for already-loaded images) | |
| try { | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = imgElement.naturalWidth || imgElement.width; | |
| canvas.height = imgElement.naturalHeight || imgElement.height; | |
| if (canvas.width === 0 || canvas.height === 0) { | |
| throw new Error('Image not loaded or has zero dimensions'); | |
| } | |
| const ctx = canvas.getContext('2d'); | |
| ctx.drawImage(imgElement, 0, 0); | |
| const isPhoto = canvas.width * canvas.height > 50000; | |
| const dataURL = isPhoto ? canvas.toDataURL('image/jpeg', 0.85) : canvas.toDataURL('image/png'); | |
| mimeType = isPhoto ? 'image/jpeg' : 'image/png'; | |
| base64Data = dataURL.split(',')[1]; | |
| size = Math.round((base64Data.length * 3) / 4); | |
| } catch (canvasError) { | |
| // Fallback to GM_xmlhttpRequest if Canvas fails (CORS issues) | |
| console.warn('[Grok] Canvas method failed, using GM_xmlhttpRequest fallback:', canvasError.message); | |
| const blob = await grok_fetchViaGM(url); | |
| base64Data = await Utils.blobToBase64(blob); | |
| mimeType = blob.type; | |
| size = blob.size; | |
| } | |
| // Fix MIME type if it's octet-stream or empty | |
| if (!mimeType || mimeType === 'application/octet-stream' || !mimeType.startsWith('image/')) { | |
| if (url.includes('.jpg') || url.includes('.jpeg')) { | |
| mimeType = 'image/jpeg'; | |
| } else if (url.includes('.png')) { | |
| mimeType = 'image/png'; | |
| } else if (url.includes('.gif')) { | |
| mimeType = 'image/gif'; | |
| } else if (url.includes('.webp')) { | |
| mimeType = 'image/webp'; | |
| } else { | |
| // Detect from base64 magic bytes | |
| const firstBytes = base64Data.substring(0, 20); | |
| if (firstBytes.startsWith('iVBORw0KGgo')) mimeType = 'image/png'; | |
| else if (firstBytes.startsWith('/9j/')) mimeType = 'image/jpeg'; | |
| else if (firstBytes.startsWith('R0lGOD')) mimeType = 'image/gif'; | |
| else if (firstBytes.startsWith('UklGR')) mimeType = 'image/webp'; | |
| else mimeType = 'image/png'; | |
| } | |
| } | |
| } | |
| return { type: 'image', format: mimeType, size, data: base64Data, original_src: url }; | |
| } catch (e) { | |
| console.error('[Grok] Failed to process image:', e); | |
| return null; | |
| } | |
| } | |
| const GrokHandler = { | |
| init: () => { | |
| // Grok doesn't require special initialization like token capture | |
| console.log('[Loominary] GrokHandler initialized'); | |
| }, | |
| getCurrentConversationId: () => { | |
| // Grok URL: https://grok.com/{conversationId} - ID is the last segment of path | |
| const pathSegments = window.location.pathname.split('/').filter(s => s); | |
| const lastSegment = pathSegments[pathSegments.length - 1]; | |
| // Grok conversation IDs are typically UUID-like (36 chars) or similar long strings | |
| if (lastSegment && lastSegment.length >= 20) { | |
| return lastSegment; | |
| } | |
| return null; | |
| }, | |
| getAllConversations: async () => { | |
| try { | |
| const response = await fetch('/rest/app-chat/conversations', { | |
| credentials: 'include', | |
| headers: { 'Accept': 'application/json' } | |
| }); | |
| if (!response.ok) throw new Error(`Failed to fetch conversations: ${response.status}`); | |
| const data = await response.json(); | |
| return data.conversations || []; | |
| } catch (error) { | |
| console.error('[Loominary] Get all conversations error:', error); | |
| return null; | |
| } | |
| }, | |
| getConversation: async (conversationId) => { | |
| try { | |
| // Step 1: Get all response nodes with tree structure | |
| const nodeUrl = `/rest/app-chat/conversations/${conversationId}/response-node?includeThreads=true`; | |
| const nodeResponse = await fetch(nodeUrl, { | |
| headers: { 'Accept': 'application/json' }, | |
| credentials: 'include' | |
| }); | |
| if (!nodeResponse.ok) throw new Error(`Failed to get response nodes: ${nodeResponse.status}`); | |
| const nodeData = await nodeResponse.json(); | |
| const responseNodes = nodeData.responseNodes || []; | |
| const responseIds = responseNodes.map(node => node.responseId); | |
| if (!responseIds.length) { | |
| return { conversationId, responses: [], title: null, conversationTree: null }; | |
| } | |
| // Step 2: Load full conversation content | |
| const loadUrl = `/rest/app-chat/conversations/${conversationId}/load-responses`; | |
| const loadResponse = await fetch(loadUrl, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| credentials: 'include', | |
| body: JSON.stringify({ responseIds }) | |
| }); | |
| if (!loadResponse.ok) throw new Error(`Failed to load responses: ${loadResponse.status}`); | |
| const conversationData = await loadResponse.json(); | |
| // Step 3: Build tree structure map | |
| const nodeMap = new Map(); | |
| responseNodes.forEach(node => { | |
| nodeMap.set(node.responseId, { | |
| responseId: node.responseId, | |
| parentResponseId: node.parentResponseId || null, | |
| childResponseIds: node.childResponseIds || [], | |
| threadId: node.threadId || null | |
| }); | |
| }); | |
| // Step 4: Process and structure the data | |
| const processedResponses = (conversationData.responses || []) | |
| .filter(r => !r.partial) | |
| .sort((a, b) => new Date(a.createTime) - new Date(b.createTime)) | |
| .map(r => { | |
| const processed = { | |
| responseId: r.responseId, | |
| sender: r.sender, | |
| createTime: r.createTime, | |
| message: r.message || '' | |
| }; | |
| // Add tree structure information | |
| const nodeInfo = nodeMap.get(r.responseId); | |
| if (nodeInfo) { | |
| processed.parentResponseId = nodeInfo.parentResponseId; | |
| processed.childResponseIds = nodeInfo.childResponseIds; | |
| if (nodeInfo.threadId) { | |
| processed.threadId = nodeInfo.threadId; | |
| } | |
| } | |
| // Process citations if present | |
| if (r.sender === 'assistant' && r.cardAttachmentsJson && r.webSearchResults) { | |
| const citations = []; | |
| try { | |
| r.cardAttachmentsJson.forEach(cardStr => { | |
| const card = JSON.parse(cardStr); | |
| if (card.cardType === 'citation_card' && card.url) { | |
| const searchResult = r.webSearchResults.find(sr => sr.url === card.url); | |
| citations.push({ | |
| id: card.id, | |
| url: card.url, | |
| title: searchResult?.title || 'Source' | |
| }); | |
| } | |
| }); | |
| } catch (e) { | |
| console.warn('[Loominary] Failed to parse cardAttachmentsJson:', e); | |
| } | |
| if (citations.length > 0) { | |
| processed.citations = citations; | |
| } | |
| if (r.webSearchResults) { | |
| processed.webSearchResults = r.webSearchResults; | |
| } | |
| } | |
| // Include other potentially useful fields | |
| if (r.attachments) processed.attachments = r.attachments; | |
| if (r.cardAttachmentsJson) processed.cardAttachmentsJson = r.cardAttachmentsJson; | |
| if (r.imageAttachments) processed.imageAttachments = r.imageAttachments; | |
| if (r.fileAttachments) processed.fileAttachments = r.fileAttachments; | |
| return processed; | |
| }); | |
| // Try to get conversation title from list if available | |
| let title = null; | |
| try { | |
| const allConvs = await GrokHandler.getAllConversations(); | |
| const conv = allConvs?.find(c => c.conversationId === conversationId); | |
| title = conv?.title || null; | |
| } catch (e) { | |
| console.warn('[Loominary] Could not fetch title:', e); | |
| } | |
| // Step 5: Capture images from DOM if State.includeImages is true | |
| if (State.includeImages) { | |
| const processedUrls = new Set(); | |
| // Helper: resolve DOM response container to a processedResponse entry | |
| function resolveContainer(el) { | |
| const container = el.closest('[id^="response-"]'); | |
| if (!container) return null; | |
| const responseId = container.id.replace('response-', ''); | |
| return processedResponses.find(r => r.responseId === responseId) || null; | |
| } | |
| // Method 1: AI-generated images — start from the img element, walk up to find response | |
| const allGeneratedImgs = document.querySelectorAll('[data-testid="image-viewer"] img[src*="assets.grok.com"]'); | |
| for (const img of allGeneratedImgs) { | |
| // Skip blurred background images (check both inline style and computed) | |
| const parentStyle = img.parentElement?.style; | |
| if (parentStyle && parentStyle.filter && parentStyle.filter.includes('blur')) continue; | |
| if (processedUrls.has(img.src)) continue; | |
| processedUrls.add(img.src); | |
| try { | |
| // Use GM fetch directly to get original file (bypasses canvas thumbnail capture) | |
| const imageData = await grok_fetchImageAsData(img.src); | |
| if (!imageData) continue; | |
| // Prefer DOM-position match; fallback to last assistant response | |
| let target = resolveContainer(img); | |
| if (!target) { | |
| const assistants = processedResponses.filter(r => r.sender === 'assistant'); | |
| target = assistants[assistants.length - 1] || null; | |
| } | |
| if (target) { | |
| if (!target.capturedImages) target.capturedImages = []; | |
| target.capturedImages.push({ ...imageData, source: 'ai_generated' }); | |
| console.log(`[Grok] Captured AI image for response ${target.responseId}`); | |
| } | |
| } catch (e) { | |
| console.error('[Grok] Failed to process AI image:', e); | |
| } | |
| } | |
| // Method 2: User-uploaded images — figure elements with preview-image URLs | |
| const allUserImages = document.querySelectorAll('figure img[src*="assets.grok.com"][src*="preview-image"]'); | |
| for (const img of allUserImages) { | |
| if (processedUrls.has(img.src)) continue; | |
| processedUrls.add(img.src); | |
| try { | |
| // Strip /preview-image suffix to get full-size URL, fallback to thumbnail | |
| const thumbnailUrl = img.src; | |
| const fullSizeUrl = thumbnailUrl.includes('/preview-image') | |
| ? thumbnailUrl.split('/preview-image')[0] | |
| : thumbnailUrl; | |
| if (fullSizeUrl !== thumbnailUrl) processedUrls.add(fullSizeUrl); | |
| let imageData = null; | |
| if (fullSizeUrl !== thumbnailUrl) { | |
| try { | |
| imageData = await grok_fetchImageAsData(fullSizeUrl); | |
| } catch (e) { | |
| console.warn('[Grok] Full-size fetch failed, using thumbnail:', e.message); | |
| } | |
| } | |
| if (!imageData) { | |
| imageData = await grok_fetchImageAsData(thumbnailUrl); | |
| } | |
| if (!imageData) continue; | |
| // Prefer DOM-position match; fallback to last human with any attachments | |
| let target = resolveContainer(img); | |
| if (!target) { | |
| const humanResponses = processedResponses.filter(r => | |
| r.sender === 'human' && | |
| ((r.fileAttachments && r.fileAttachments.length > 0) || | |
| (r.imageAttachments && r.imageAttachments.length > 0)) | |
| ); | |
| target = humanResponses[humanResponses.length - 1] || null; | |
| } | |
| if (target) { | |
| if (!target.capturedImages) target.capturedImages = []; | |
| target.capturedImages.push({ ...imageData, source: 'user_upload' }); | |
| console.log(`[Grok] Captured user-uploaded image for response ${target.responseId}`); | |
| } else { | |
| console.warn('[Grok] No matching response found for user-uploaded image'); | |
| } | |
| } catch (e) { | |
| console.error('[Grok] Failed to process user image:', e); | |
| } | |
| } | |
| } | |
| return { | |
| conversationId, | |
| title, | |
| responses: processedResponses, | |
| conversationTree: { | |
| nodes: Array.from(nodeMap.values()), | |
| rootNodeId: responseNodes.find(n => !n.parentResponseId)?.responseId || null | |
| }, | |
| exportTime: new Date().toISOString(), | |
| platform: 'grok' | |
| }; | |
| } catch (error) { | |
| console.error('[Loominary] Get conversation error:', error); | |
| throw error; | |
| } | |
| }, | |
| addUI: (controls) => { | |
| // Initialize includeImages to true by default for Grok if not set | |
| if (localStorage.getItem('includeImages') === null) { | |
| State.includeImages = true; | |
| localStorage.setItem('includeImages', 'true'); | |
| console.log('[Grok] Initialized includeImages to true by default'); | |
| } | |
| // Add "Include Images" toggle | |
| const imageToggle = Utils.createToggle( | |
| i18n.t('includeImages'), | |
| 'loominary-include-images-toggle', | |
| State.includeImages | |
| ); | |
| const imageToggleInput = imageToggle.querySelector('input'); | |
| imageToggleInput.addEventListener('change', (e) => { | |
| State.includeImages = e.target.checked; | |
| localStorage.setItem('includeImages', State.includeImages); | |
| console.log('[Grok] Include images:', State.includeImages); | |
| }); | |
| controls.appendChild(imageToggle); | |
| }, | |
| addButtons: (controls) => { | |
| controls.appendChild(Utils.createButton( | |
| `${previewIcon} ${i18n.t('viewOnline')}`, | |
| async (btn) => { | |
| const conversationId = GrokHandler.getCurrentConversationId(); | |
| if (!conversationId) { | |
| alert(i18n.t('uuidNotFound')); | |
| return; | |
| } | |
| const original = btn.innerHTML; | |
| Utils.setButtonLoading(btn, i18n.t('loading')); | |
| try { | |
| const data = await GrokHandler.getConversation(conversationId); | |
| if (!data) throw new Error(i18n.t('fetchFailed')); | |
| const jsonString = JSON.stringify(data, null, 2); | |
| const filename = `grok_${data.title || 'conversation'}_${conversationId.substring(0, 8)}.json`; | |
| await Communicator.open(jsonString, filename); | |
| } catch (error) { | |
| ErrorHandler.handle(error, 'Preview conversation', { | |
| userMessage: `${i18n.t('loadFailed')} ${error.message}` | |
| }); | |
| } finally { | |
| Utils.restoreButton(btn, original); | |
| } | |
| } | |
| )); | |
| controls.appendChild(Utils.createButton( | |
| `${exportIcon} ${i18n.t('exportCurrentJSON')}`, | |
| async (btn) => { | |
| const conversationId = GrokHandler.getCurrentConversationId(); | |
| if (!conversationId) { | |
| alert(i18n.t('uuidNotFound')); | |
| return; | |
| } | |
| const original = btn.innerHTML; | |
| Utils.setButtonLoading(btn, i18n.t('exporting')); | |
| try { | |
| const data = await GrokHandler.getConversation(conversationId); | |
| if (!data) throw new Error(i18n.t('fetchFailed')); | |
| const title = data.title || conversationId.substring(0, 8); | |
| const filename = `grok_${Utils.sanitizeFilename(title)}_${conversationId.substring(0, 8)}`; | |
| await loominaryExportMarkdown(data, filename); | |
| } catch (error) { | |
| ErrorHandler.handle(error, 'Export conversation'); | |
| } finally { | |
| Utils.restoreButton(btn, original); | |
| } | |
| } | |
| )); | |
| controls.appendChild(Utils.createButton( | |
| `${zipIcon} ${i18n.t('exportAllConversations')}`, | |
| (btn) => GrokHandler.exportAll(btn, controls) | |
| )); | |
| }, | |
| exportAll: async (btn, controlsArea) => { | |
| if (typeof fflate === 'undefined' || typeof fflate.zipSync !== 'function' || typeof fflate.strToU8 !== 'function') { | |
| const errorMsg = i18n.currentLang === 'zh' | |
| ? '批量导出功能需要压缩库支持。\n\n由于当前平台的安全策略限制,该功能暂时不可用。\n建议使用"导出当前"功能单个导出对话。' | |
| : 'Batch export requires compression library.\n\nThis feature is currently unavailable due to platform security policies.\nPlease use "Export" button to export conversations individually.'; | |
| alert(errorMsg); | |
| return; | |
| } | |
| // 先探测对话数量 | |
| const original = btn.innerHTML; | |
| Utils.setButtonLoading(btn, i18n.t('detectingConversations')); | |
| let allConvs; | |
| try { | |
| allConvs = await GrokHandler.getAllConversations(); | |
| if (!allConvs || !Array.isArray(allConvs)) throw new Error(i18n.t('fetchFailed')); | |
| } catch (error) { | |
| ErrorHandler.handle(error, 'Detect conversations'); | |
| Utils.restoreButton(btn, original); | |
| return; | |
| } | |
| const totalCount = allConvs.length; | |
| Utils.restoreButton(btn, original); | |
| // 弹出确认框让用户选择导出数量 | |
| const promptMsg = i18n.currentLang === 'zh' | |
| ? `${i18n.t('foundConversations')} ${totalCount} ${i18n.t('conversations')}\n\n${i18n.t('selectExportCount')}` | |
| : `${i18n.t('foundConversations')} ${totalCount} ${i18n.t('conversations')}\n\n${i18n.t('selectExportCount')}`; | |
| const userInput = prompt(promptMsg, totalCount.toString()); | |
| // 用户取消 | |
| if (userInput === null) { | |
| alert(i18n.t('exportCancelled')); | |
| return; | |
| } | |
| // 解析用户输入 | |
| let exportCount = totalCount; | |
| const trimmedInput = userInput.trim(); | |
| if (trimmedInput !== '' && trimmedInput !== '0') { | |
| const parsed = parseInt(trimmedInput, 10); | |
| if (isNaN(parsed) || parsed < 0) { | |
| alert(i18n.t('invalidNumber')); | |
| return; | |
| } | |
| exportCount = Math.min(parsed, totalCount); | |
| } | |
| // 开始导出 | |
| const progress = Utils.createProgressElem(controlsArea); | |
| progress.textContent = i18n.t('preparing'); | |
| Utils.setButtonLoading(btn, i18n.t('exporting')); | |
| try { | |
| let exported = 0; | |
| const zipEntries = {}; | |
| // 只导出最近的 exportCount 个对话 | |
| const convsToExport = allConvs.slice(0, exportCount); | |
| console.log(`[Grok] Starting export of ${convsToExport.length} conversations (out of ${totalCount} total)`); | |
| for (let i = 0; i < convsToExport.length; i++) { | |
| const conv = convsToExport[i]; | |
| progress.textContent = `${i18n.t('gettingConversation')} ${i + 1}/${convsToExport.length}`; | |
| if (i > 0 && i % 5 === 0) { | |
| await new Promise(resolve => setTimeout(resolve, Config.TIMING.BATCH_EXPORT_YIELD)); | |
| } else if (i > 0) { | |
| await Utils.sleep(Config.TIMING.BATCH_EXPORT_SLEEP); | |
| } | |
| try { | |
| const data = await GrokHandler.getConversation(conv.conversationId); | |
| if (data) { | |
| const title = Utils.sanitizeFilename(data.title || conv.conversationId); | |
| const filename = `grok_${conv.conversationId.substring(0, 8)}_${title}.json`; | |
| zipEntries[filename] = fflate.strToU8(JSON.stringify(data, null, 2)); | |
| exported++; | |
| } | |
| } catch (error) { | |
| console.error(`[Lyra] Failed to process ${conv.conversationId}:`, error); | |
| } | |
| } | |
| progress.textContent = `${i18n.t('compressing')}…`; | |
| const zipUint8 = fflate.zipSync(zipEntries, { level: 1 }); | |
| const zipBlob = new Blob([zipUint8], { type: 'application/zip' }); | |
| const zipFilename = `grok_export_${exportCount === totalCount ? 'all' : 'recent_' + exportCount}_${new Date().toISOString().slice(0, 10)}.zip`; | |
| Utils.downloadFile(zipBlob, zipFilename); | |
| alert(`${i18n.t('successExported')} ${exported} ${i18n.t('conversations')}`); | |
| } catch (error) { | |
| ErrorHandler.handle(error, 'Export all conversations'); | |
| } finally { | |
| Utils.restoreButton(btn, original); | |
| if (progress.parentNode) progress.parentNode.removeChild(progress); | |
| } | |
| } | |
| }; | |
| // Version tracking system for Gemini (Optimized) | |
| const VersionTracker = { | |
| tracker: null, | |
| scanInterval: null, | |
| hrefCheckInterval: null, | |
| currentHref: location.href, | |
| isTracking: false, | |
| isScanning: false, | |
| imageCache: new Map(), | |
| imagePool: new Map(), | |
| getImageHashKey: (img) => img ? `${img.size}-${img.format}-${img.data.substring(0, 100)}` : null, | |
| getOrFetchImage: async (imgElement, retries = 3) => { | |
| if (!imgElement.complete || !imgElement.naturalWidth) { | |
| await new Promise(r => { | |
| if (imgElement.complete) return r(); | |
| imgElement.onload = imgElement.onerror = r; | |
| setTimeout(r, 2000); | |
| }); | |
| } | |
| const url = imgElement.src; | |
| if (!url || url.startsWith('data:') || url.includes('drive-thirdparty.googleusercontent.com') | |
| || imgElement.classList.contains('new-file-icon') || imgElement.dataset.testId === 'new-file-icon') return null; | |
| if (VersionTracker.imageCache.has(url)) return VersionTracker.imageCache.get(url); | |
| for (let i = 1; i <= retries; i++) { | |
| try { | |
| const imageData = await gemini_processImageElement(imgElement); | |
| if (imageData) { | |
| const hashKey = VersionTracker.getImageHashKey(imageData); | |
| if (hashKey && VersionTracker.imagePool.has(hashKey)) { | |
| const existing = VersionTracker.imagePool.get(hashKey); | |
| VersionTracker.imageCache.set(url, existing); | |
| return existing; | |
| } | |
| if (hashKey) VersionTracker.imagePool.set(hashKey, imageData); | |
| VersionTracker.imageCache.set(url, imageData); | |
| return imageData; | |
| } | |
| } catch (e) { | |
| if (i === retries) return null; | |
| await new Promise(r => setTimeout(r, 500 * i)); | |
| } | |
| } | |
| return null; | |
| }, | |
| createEmptyTracker: () => ({ turns: {}, order: [] }), | |
| resetTracker: (reason) => { | |
| VersionTracker.tracker = VersionTracker.createEmptyTracker(); | |
| VersionTracker.imageCache.clear(); | |
| VersionTracker.imagePool.clear(); | |
| }, | |
| startTracking: () => { | |
| if (VersionTracker.isTracking) return; | |
| VersionTracker.isTracking = true; | |
| VersionTracker.resetTracker(); | |
| console.log('[Gemini] VersionTracker started, scan interval:', Config.TIMING.VERSION_SCAN_INTERVAL, 'ms'); | |
| VersionTracker.scanInterval = setInterval(() => VersionTracker.scanOnce(), Config.TIMING.VERSION_SCAN_INTERVAL); | |
| VersionTracker.hrefCheckInterval = setInterval(() => { | |
| if (location.href !== VersionTracker.currentHref) { | |
| VersionTracker.currentHref = location.href; | |
| VersionTracker.resetTracker(); | |
| } | |
| }, Config.TIMING.HREF_CHECK_INTERVAL); | |
| }, | |
| stopTracking: () => { | |
| if (!VersionTracker.isTracking) return; | |
| VersionTracker.isTracking = false; | |
| clearInterval(VersionTracker.scanInterval); | |
| clearInterval(VersionTracker.hrefCheckInterval); | |
| VersionTracker.scanInterval = VersionTracker.hrefCheckInterval = null; | |
| }, | |
| ensureTurn: (turnId) => { | |
| const tracker = VersionTracker.tracker; | |
| if (!tracker.turns[turnId]) { | |
| tracker.turns[turnId] = { | |
| id: turnId, | |
| userVersions: [], assistantVersions: [], | |
| userLastText: '', assistantCommittedText: '', assistantPendingText: '', assistantPendingSince: 0, assistantPendingImages: [], | |
| userImages: new Map(), assistantImages: new Map() | |
| }; | |
| tracker.order.push(turnId); | |
| } | |
| return tracker.turns[turnId]; | |
| }, | |
| getTurnId: (node, idx) => node.getAttribute?.('data-message-id') || node.getAttribute?.('data-id') || `turn-${idx}`, | |
| areImageListsEqual: (a, b) => { | |
| if (!a && !b) return true; | |
| if (!a || !b || a.length !== b.length) return false; | |
| return a.every((img, i) => img.size === b[i].size && img.data === b[i].data); | |
| }, | |
| handleUser: (turnId, text, images = []) => { | |
| const t = VersionTracker.ensureTurn(turnId); | |
| const value = (text || '').trim(); | |
| if (!value && !images.length) return; | |
| const last = t.userVersions.at(-1); | |
| const lastImages = last ? (t.userImages.get(last.version) || []) : []; | |
| const isTextSame = last?.text === value; | |
| const isImagesSame = VersionTracker.areImageListsEqual(lastImages, images); | |
| if (isTextSame && isImagesSame) return; | |
| if (last?.text && !value && isImagesSame) return; // Skip intermediate edit state | |
| // 文本相同但图片变化(异步加载完成),更新现有版本的图片而非创建新版本 | |
| if (isTextSame && !isImagesSame && images.length) { | |
| t.userImages.set(last.version, images); | |
| return; | |
| } | |
| const version = t.userVersions.length; | |
| t.userVersions.push({ version, type: version ? 'edit' : 'normal', text: value }); | |
| if (images.length) t.userImages.set(version, images); | |
| t.userLastText = value; | |
| }, | |
| handleAssistant: (turnId, domText, images = []) => { | |
| const t = VersionTracker.ensureTurn(turnId); | |
| const text = (domText || '').trim(); | |
| if (!text && !images.length) return; | |
| const now = Date.now(); | |
| if (text !== t.assistantPendingText) { | |
| t.assistantPendingText = text; | |
| t.assistantPendingSince = now; | |
| if (images.length) t.assistantPendingImages = images; | |
| return; | |
| } | |
| // 即使文本未变,也持续更新待处理图片(异步加载可能滞后) | |
| if (images.length) t.assistantPendingImages = images; | |
| if (now - t.assistantPendingSince < Config.TIMING.VERSION_STABLE) return; | |
| const userVersion = t.userVersions.at(-1)?.version ?? null; | |
| const last = t.assistantVersions.at(-1); | |
| const lastImages = last ? (t.assistantImages.get(last.version) || []) : []; | |
| if (last?.userVersion === userVersion && last?.text === text) { | |
| // 文本和 userVersion 相同时,如果只是图片变化(异步加载完成),更新现有版本的图片 | |
| if (!VersionTracker.areImageListsEqual(lastImages, images) && images.length) { | |
| t.assistantImages.set(last.version, images); | |
| } | |
| t.assistantPendingSince = now; | |
| return; | |
| } | |
| const version = t.assistantVersions.length; | |
| t.assistantVersions.push({ version, type: version ? 'retry' : 'normal', userVersion, text }); | |
| if (images.length) t.assistantImages.set(version, images); | |
| t.assistantCommittedText = text; | |
| }, | |
| scanOnce: async () => { | |
| if (VersionTracker.isScanning) return; | |
| VersionTracker.isScanning = true; | |
| try { | |
| const turns = document.querySelectorAll('div.conversation-turn, div.single-turn, div.conversation-container'); | |
| if (!turns.length) { | |
| // 每 30 秒输出一次调试信息,避免刷屏 | |
| if (!VersionTracker._lastDebugLog || Date.now() - VersionTracker._lastDebugLog > 30000) { | |
| VersionTracker._lastDebugLog = Date.now(); | |
| console.log('[Gemini] scanOnce: no turns found. DOM selectors tried: div.conversation-turn, div.single-turn, div.conversation-container'); | |
| } | |
| return; | |
| } | |
| const includeImages = document.getElementById(Config.IMAGE_SWITCH_ID)?.checked || false; | |
| for (const turn of turns) { | |
| const idx = Array.from(turns).indexOf(turn); | |
| const id = VersionTracker.getTurnId(turn, idx); | |
| let userImages = [], assistantImages = []; | |
| if (includeImages) { | |
| // 排除文件类型图标(drive-thirdparty.googleusercontent.com) | |
| const userImgEls = [...turn.querySelectorAll('user-query img, user-query-file-preview img, .file-preview-container img')] | |
| .filter(img => !img.src.includes('drive-thirdparty.googleusercontent.com')); | |
| // 只获取 message-content 内的图片,排除 model-thoughts | |
| const modelContent = turn.querySelector('model-response message-content'); | |
| const modelImgEls = modelContent ? [...modelContent.querySelectorAll('img')] | |
| .filter(img => !img.src.includes('drive-thirdparty.googleusercontent.com')) : []; | |
| if (userImgEls.length) userImages = (await Promise.all(userImgEls.map(i => VersionTracker.getOrFetchImage(i)))).filter(Boolean); | |
| if (modelImgEls.length) assistantImages = (await Promise.all(modelImgEls.map(i => VersionTracker.getOrFetchImage(i)))).filter(Boolean); | |
| } | |
| const userText = VersionTracker.getUserText(turn); | |
| const assistantText = VersionTracker.getAssistantText(turn); | |
| // 调试日志(每 30 秒最多输出一次) | |
| if (!VersionTracker._lastScanDebug || Date.now() - VersionTracker._lastScanDebug > 30000) { | |
| if (idx === 0) VersionTracker._lastScanDebug = Date.now(); | |
| console.log(`[Gemini] Turn ${idx} id=${id}: userText=${userText.length}chars, assistantText=${assistantText.length}chars`, | |
| turn.querySelector('user-query') ? 'has-user-query' : 'no-user-query', | |
| turn.querySelector('message-content') ? 'has-message-content' : 'no-message-content', | |
| turn.querySelector('.markdown-main-panel') ? 'has-markdown-panel' : 'no-markdown-panel'); | |
| } | |
| VersionTracker.handleUser(id, userText, userImages); | |
| VersionTracker.handleAssistant(id, assistantText, assistantImages); | |
| } | |
| } finally { | |
| VersionTracker.isScanning = false; | |
| } | |
| }, | |
| getUserText: (turn) => { | |
| const el = turn.querySelector('user-query .query-text, .query-text-line, [data-user-text]'); | |
| if (!el) return ''; | |
| const clone = el.cloneNode(true); | |
| clone.querySelectorAll('.cdk-visually-hidden').forEach(e => e.remove()); | |
| return clone.innerText.trim(); | |
| }, | |
| getAssistantText: (turn) => { | |
| // 严格只从 message-content 获取内容,完全排除 model-thoughts | |
| const messageContent = turn.querySelector('message-content'); | |
| if (!messageContent) return ''; | |
| // 优先选择 markdown-main-panel | |
| let panel = messageContent.querySelector('.markdown-main-panel'); | |
| if (!panel) { | |
| // 回退:使用整个 message-content,但要排除思考过程 | |
| panel = messageContent; | |
| } | |
| const clone = panel.cloneNode(true); | |
| // 移除所有不需要的元素(含 Gemini 的屏幕阅读器隐藏文本) | |
| clone.querySelectorAll('button.retry-without-tool-button, model-thoughts, .model-thoughts, .thoughts-header, .cdk-visually-hidden').forEach(b => b.remove()); | |
| const text = htmlToMarkdown(clone); | |
| // 过滤掉只有思考标题的短文本(通常小于50字符且不包含换行) | |
| if (text.length < 50 && !text.includes('\n') && !text.includes('*') && !text.includes('#')) { | |
| // 可能是思考标题如"分析分析"、"Analyzing"etc,跳过 | |
| return ''; | |
| } | |
| return text; | |
| }, | |
| // 导出前强制提交所有待处理的 assistant 文本(忽略 VERSION_STABLE 延迟) | |
| forceCommitAll: () => { | |
| const { turns, order } = VersionTracker.tracker; | |
| for (const id of order) { | |
| const t = turns[id]; | |
| if (!t || !t.assistantPendingText) continue; | |
| const text = t.assistantPendingText; | |
| const images = t.assistantPendingImages || []; | |
| const userVersion = t.userVersions.at(-1)?.version ?? null; | |
| const last = t.assistantVersions.at(-1); | |
| if (last?.userVersion === userVersion && last?.text === text) { | |
| // 文本已提交,但图片可能尚未更新 | |
| if (images.length && !VersionTracker.areImageListsEqual(t.assistantImages.get(last.version) || [], images)) { | |
| t.assistantImages.set(last.version, images); | |
| } | |
| continue; | |
| } | |
| const version = t.assistantVersions.length; | |
| t.assistantVersions.push({ version, type: version ? 'retry' : 'normal', userVersion, text }); | |
| if (images.length) t.assistantImages.set(version, images); | |
| t.assistantCommittedText = text; | |
| } | |
| }, | |
| buildVersionedData: (title, includeImages = true) => { | |
| const { turns, order } = VersionTracker.tracker; | |
| const result = []; | |
| console.log('[Gemini] buildVersionedData: tracked turns =', order.length, ', turnIds =', order); | |
| for (const id of order) { | |
| const t = turns[id]; | |
| if (!t) continue; | |
| const mapVersions = (versions, imgMap) => versions | |
| .filter(v => v.text?.trim() || v.thinking?.trim() || (includeImages && imgMap.get(v.version)?.length)) | |
| .map(v => { | |
| const d = { version: v.version, type: v.type, text: v.text }; | |
| if (v.userVersion !== undefined) d.userVersion = v.userVersion; | |
| if (v.thinking) d.thinking = v.thinking; | |
| const imgs = includeImages ? imgMap.get(v.version) : null; | |
| if (imgs?.length) d.images = imgs; | |
| return d; | |
| }); | |
| result.push({ | |
| turnIndex: result.length, | |
| human: t.userVersions.length ? { versions: mapVersions(t.userVersions, t.userImages) } : null, | |
| assistant: t.assistantVersions.length ? { versions: mapVersions(t.assistantVersions, t.assistantImages) } : null | |
| }); | |
| } | |
| return { title: title || 'Gemini Chat', platform: 'gemini', exportedAt: new Date().toISOString(), conversation: result }; | |
| } | |
| }; | |
| VersionTracker.tracker = VersionTracker.createEmptyTracker(); | |
| window.loominaryGeminiExport = (title) => { | |
| const includeImages = document.getElementById(Config.IMAGE_SWITCH_ID)?.checked || false; | |
| return VersionTracker.buildVersionedData(title || 'Gemini Chat', includeImages); | |
| }; | |
| window.loominaryGeminiReset = () => VersionTracker.resetTracker(); | |
| function gemini_fetchViaGM(url) { | |
| return new Promise((resolve, reject) => { | |
| if (typeof GM_xmlhttpRequest === 'undefined') { | |
| return reject(new Error('GM_xmlhttpRequest not available')); | |
| } | |
| GM_xmlhttpRequest({ | |
| method: "GET", url, responseType: "blob", | |
| onload: r => r.status >= 200 && r.status < 300 ? resolve(r.response) : reject(new Error(`Status: ${r.status}`)), | |
| onerror: e => reject(new Error(e.statusText || 'Network error')) | |
| }); | |
| }); | |
| } | |
| async function gemini_processImageElement(imgElement) { | |
| if (!imgElement) return null; | |
| const url = imgElement.src; | |
| if (!url || url.includes('drive-thirdparty.googleusercontent.com') | |
| || imgElement.classList.contains('new-file-icon') || imgElement.dataset.testId === 'new-file-icon') return null; | |
| // data: URI 直接提取 base64,无需 fetch | |
| if (url.startsWith('data:')) { | |
| try { | |
| const commaIdx = url.indexOf(','); | |
| if (commaIdx === -1) return null; | |
| const header = url.slice(0, commaIdx); // e.g. "data:image/jpeg;base64" | |
| const semiIdx = header.indexOf(';'); | |
| if (semiIdx === -1) return null; | |
| const mimeType = header.slice(5, semiIdx); // after "data:" | |
| if (!mimeType.startsWith('image/')) return null; | |
| const base64Data = url.slice(commaIdx + 1); | |
| const size = Math.round((base64Data.length * 3) / 4); | |
| return { type: 'image', format: mimeType, size, data: base64Data, original_src: url.slice(0, 80) + '...' }; | |
| } catch (e) { | |
| console.error('[Gemini] Failed to process data: URI image:', e); | |
| return null; | |
| } | |
| } | |
| try { | |
| let base64Data, mimeType, size; | |
| if (url.startsWith('blob:')) { | |
| try { | |
| const blob = await fetch(url).then(r => r.ok ? r.blob() : Promise.reject()); | |
| base64Data = await Utils.blobToBase64(blob); | |
| mimeType = blob.type; | |
| size = blob.size; | |
| } catch { | |
| // Canvas fallback | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = imgElement.naturalWidth || imgElement.width; | |
| canvas.height = imgElement.naturalHeight || imgElement.height; | |
| canvas.getContext('2d').drawImage(imgElement, 0, 0); | |
| const isPhoto = canvas.width * canvas.height > 50000; | |
| const dataURL = isPhoto ? canvas.toDataURL('image/jpeg', 0.85) : canvas.toDataURL('image/png'); | |
| mimeType = isPhoto ? 'image/jpeg' : 'image/png'; | |
| base64Data = dataURL.split(',')[1]; | |
| size = Math.round((base64Data.length * 3) / 4); | |
| } | |
| } else { | |
| const blob = await gemini_fetchViaGM(url); | |
| base64Data = await Utils.blobToBase64(blob); | |
| mimeType = blob.type; | |
| size = blob.size; | |
| } | |
| return { type: 'image', format: mimeType, size, data: base64Data, original_src: url }; | |
| } catch (e) { | |
| console.error('[Gemini] Failed to process image:', url, e); | |
| return null; | |
| } | |
| } | |
| const MD_TAGS = { | |
| h1: c => `\n# ${c}\n`, h2: c => `\n## ${c}\n`, h3: c => `\n### ${c}\n`, | |
| h4: c => `\n#### ${c}\n`, h5: c => `\n##### ${c}\n`, h6: c => `\n###### ${c}\n`, | |
| strong: c => `**${c}**`, b: c => `**${c}**`, em: c => `*${c}*`, i: c => `*${c}*`, | |
| hr: () => '\n---\n', br: () => '\n', p: c => `\n${c}\n`, div: c => c, | |
| blockquote: c => `\n> ${c.split('\n').join('\n> ')}\n`, | |
| table: c => `\n${c}\n`, thead: c => c, tbody: c => c, tr: c => `${c}|\n`, | |
| th: c => `| **${c}** `, td: c => `| ${c} `, li: c => c | |
| }; | |
| function htmlToMarkdown(element) { | |
| if (!element) return ''; | |
| // HTML实体解码器(修复了 Gemini 的 Trusted Types 安全拦截问题) | |
| const decodeHtmlEntities = (str) => { | |
| if (!str) return ''; | |
| try { | |
| // 使用 DOMParser 将字符串解析为文档,直接提取 textContent,从而完美避开 innerHTML 赋值 | |
| const parser = new DOMParser(); | |
| const doc = parser.parseFromString(str, 'text/html'); | |
| return doc.documentElement.textContent || str; | |
| } catch (e) { | |
| console.error('[Loominary] HTML entity decoding failed:', e); | |
| return str; | |
| } | |
| }; | |
| function processNode(node) { | |
| if (node.nodeType === Node.TEXT_NODE) return node.textContent; | |
| if (node.nodeType !== Node.ELEMENT_NODE) return ''; | |
| const tag = node.tagName.toLowerCase(); | |
| // ========== 数学公式处理 ========== | |
| // 处理 data-math 属性(Gemini 常用) | |
| const dataMathRaw = node.getAttribute('data-math'); | |
| if (dataMathRaw) { | |
| // 解码HTML实体,确保LaTeX命令正确(如 < -> <, & -> &) | |
| const dataMath = decodeHtmlEntities(dataMathRaw); | |
| const content = dataMath.trim(); | |
| // 检测是否为引用格式 [1] 或 [1, 2] | |
| if (/^\d+(,\s*\d+)*$/.test(content)) { | |
| // 检查后面是否跟着单位(区分引用和数值) | |
| let next = node.nextSibling; | |
| while (next && next.nodeType === 3 && !next.textContent.trim()) next = next.nextSibling; | |
| if (next) { | |
| const text = (next.nodeType === 3 ? next.textContent : next.textContent || '').trim().toLowerCase(); | |
| const units = ['min', 's', 'sec', 'h', 'hr', 'd', 'day', 'g', 'kg', 'mg', 'l', 'ml', 'm', 'cm', 'mm', 'km', '%', '分', '秒', '时', '天', '克', '升', '米']; | |
| if (units.some(u => text.startsWith(u))) { | |
| return `$${content}$`; // 数值 + 单位 | |
| } | |
| } | |
| return `[${content}]`; // 引用 | |
| } | |
| // 块级公式 | |
| if (node.classList.contains('math-block')) { | |
| return `\n$$${dataMath}$$\n`; | |
| } | |
| return `$${dataMath}$`; | |
| } | |
| // 处理其他数学属性(data-tex, data-latex, KaTeX) | |
| const potentialLatexRaw = node.getAttribute('data-tex') || node.getAttribute('data-latex') || node.getAttribute('alt') || node.getAttribute('aria-label'); | |
| if (potentialLatexRaw && (tag === 'math' || tag === 'img' || node.classList.contains('math') || /[=^\\_{]/.test(potentialLatexRaw))) { | |
| const potentialLatex = decodeHtmlEntities(potentialLatexRaw); | |
| let clean = potentialLatex.replace(/^Image of /, '').replace(/^Math formula: /, ''); | |
| if (!clean.startsWith('$')) clean = `$${clean}$`; | |
| return clean; | |
| } | |
| // math 标签 | |
| if (tag === 'math') { | |
| const annotation = node.querySelector('annotation[encoding="application/x-tex"]'); | |
| if (annotation) { | |
| const latex = decodeHtmlEntities(annotation.textContent.trim()); | |
| return `$${latex}$`; | |
| } | |
| return node.textContent; | |
| } | |
| // KaTeX 元素 | |
| if (node.classList.contains('katex-mathml')) { | |
| const annotation = node.querySelector('annotation'); | |
| if (annotation) { | |
| const latex = decodeHtmlEntities(annotation.textContent); | |
| return `$${latex}$`; | |
| } | |
| } | |
| if (node.classList.contains('katex-html')) return ''; | |
| // ========== 表格修复处理 ========== | |
| if (tag === 'table') { | |
| let md = '\n'; | |
| let rows = Array.from(node.rows || node.querySelectorAll('tr')); | |
| // 提取数据矩阵 | |
| let matrix = rows.map(row => { | |
| const cells = row.cells?.length > 0 ? Array.from(row.cells) : Array.from(row.querySelectorAll('td, th')); | |
| return cells.map(cell => processNode(cell).replace(/(\r\n|\n|\r)/gm, ' ').trim()); | |
| }); | |
| // 过滤完全空的行 | |
| matrix = matrix.filter(row => row.some(cell => cell !== '')); | |
| if (matrix.length === 0) return ''; | |
| // 确定最大列数 | |
| const maxCols = matrix.reduce((max, row) => Math.max(max, row.length), 0); | |
| // 移除单列伪标题(如果表格明显是多列的) | |
| if (matrix.length > 1 && matrix[0].length === 1 && maxCols > 1) { | |
| matrix.shift(); | |
| } | |
| // 生成 Markdown | |
| matrix.forEach((row, rIndex) => { | |
| // 填充到相同列数 | |
| while (row.length < maxCols) row.push(''); | |
| md += '| ' + row.join(' | ') + ' |\n'; | |
| // 在第一行后添加分隔符 | |
| if (rIndex === 0) { | |
| md += '| ' + Array(maxCols).fill(':---').join(' | ') + ' |\n'; | |
| } | |
| }); | |
| return md + '\n'; | |
| } | |
| const children = [...node.childNodes].map(processNode).join(''); | |
| if (MD_TAGS[tag]) return MD_TAGS[tag](children); | |
| if (tag === 'code') { | |
| const inPre = node.parentElement?.tagName.toLowerCase() === 'pre'; | |
| if (children.includes('\n') || inPre) return inPre ? children : `\n\`\`\`\n${children}\n\`\`\`\n`; | |
| return `\`${children}\``; | |
| } | |
| if (tag === 'pre') { | |
| const code = node.querySelector('code'); | |
| if (code) { | |
| const lang = code.className.match(/language-(\w+)/)?.[1] || ''; | |
| return `\n\`\`\`${lang}\n${code.textContent}\n\`\`\`\n`; | |
| } | |
| return `\n\`\`\`\n${children}\n\`\`\`\n`; | |
| } | |
| if (tag === 'a') { | |
| const href = node.getAttribute('href'); | |
| return href ? `[${children}](${href})` : children; | |
| } | |
| if (tag === 'ul') return `\n${[...node.children].map(li => `- ${processNode(li).replace(/^\n+/, '').replace(/\n+$/, '')}`).join('\n')}\n`; | |
| if (tag === 'ol') { | |
| const start = parseInt(node.getAttribute('start')) || 1; | |
| return `\n${[...node.children].map((li, i) => `${start + i}. ${processNode(li).replace(/^\n+/, '').replace(/\n+$/, '')}`).join('\n')}\n`; | |
| } | |
| return children; | |
| } | |
| let result = processNode(element).replace(/^\s+/, '').replace(/\n{3,}/g, '\n\n').trim(); | |
| // 后处理:移除图片标注文本(如 "$, AI generated$" "$,AI 生成$") | |
| result = result.replace(/\$[,,]\s*AI.{1,100}?\$/g, ''); | |
| // 后处理:修复独立成行的引用 [1, 2] | |
| result = result.replace(/([^\n])\n+(\[[\d,\s.]+\])\n+([^\n])/g, (match, prevChar, citation, nextChar) => { | |
| const isNextPunctuation = /[。,;:!?.,;:!?]/.test(nextChar); | |
| return `${prevChar} ${citation}${isNextPunctuation ? '' : ' '}${nextChar}`; | |
| }); | |
| return result; | |
| } | |
| // ==================== AI Studio XHR 拦截 ==================== | |
| const AiStudioXHR = { | |
| capturedData: null, | |
| capturedTimestamp: 0, | |
| init: () => { | |
| if (State.currentPlatform !== 'aistudio') return; | |
| const originalOpen = XMLHttpRequest.prototype.open; | |
| const originalSend = XMLHttpRequest.prototype.send; | |
| XMLHttpRequest.prototype.open = function(method, url) { | |
| this._aistudio_url = url; | |
| return originalOpen.apply(this, arguments); | |
| }; | |
| XMLHttpRequest.prototype.send = function(body) { | |
| this.addEventListener('load', function() { | |
| if (this._aistudio_url && ( | |
| this._aistudio_url.includes('ResolveDriveResource') || | |
| this._aistudio_url.includes('CreatePrompt') || | |
| this._aistudio_url.includes('UpdatePrompt') | |
| )) { | |
| try { | |
| const rawText = this.responseText.replace(/^\)\]\}'/, '').trim(); | |
| let json = JSON.parse(rawText); | |
| if (Array.isArray(json) && json.length > 0) { | |
| // Normalize: ResolveDriveResource returns [[...]], CreatePrompt/UpdatePrompt returns [...] | |
| if (typeof json[0] === 'string' && json[0].startsWith('prompts/')) { | |
| json = [json]; | |
| } | |
| AiStudioXHR.capturedData = json; | |
| AiStudioXHR.capturedTimestamp = Date.now(); | |
| console.log('[Loominary AI Studio] XHR intercepted:', rawText.length, 'chars'); | |
| } | |
| } catch (err) { | |
| console.error('[Loominary AI Studio] XHR parse error:', err.message); | |
| } | |
| } | |
| }); | |
| return originalSend.apply(this, arguments); | |
| }; | |
| console.log('[Loominary AI Studio] XHR interceptor installed'); | |
| }, | |
| isTurn: (arr) => { | |
| if (!Array.isArray(arr)) return false; | |
| return arr.includes('user') || arr.includes('model'); | |
| }, | |
| findHistory: (node, depth = 0) => { | |
| if (depth > 4 || !Array.isArray(node)) return null; | |
| if (node.slice(0, 5).some(child => AiStudioXHR.isTurn(child))) return node; | |
| for (const child of node) { | |
| if (Array.isArray(child)) { | |
| const result = AiStudioXHR.findHistory(child, depth + 1); | |
| if (result) return result; | |
| } | |
| } | |
| return null; | |
| }, | |
| extractText: (turn) => { | |
| const candidates = []; | |
| const scan = (item, d = 0) => { | |
| if (d > 3) return; | |
| if (typeof item === 'string' && item.length > 1 && !['user', 'model', 'function'].includes(item)) { | |
| candidates.push(item); | |
| } else if (Array.isArray(item)) { | |
| item.forEach(sub => scan(sub, d + 1)); | |
| } | |
| }; | |
| scan(turn.slice(0, 3)); | |
| return candidates.sort((a, b) => b.length - a.length)[0] || ''; | |
| }, | |
| isThinking: (turn) => Array.isArray(turn) && turn.length > 19 && turn[19] === 1, | |
| isResponse: (turn) => Array.isArray(turn) && turn.length > 16 && turn[16] === 1, | |
| isCodeExec: (turn) => Array.isArray(turn) && turn.length > 10 && Array.isArray(turn[10]) && turn[10][0] === 1 && typeof turn[10][1] === 'string', | |
| isCodeResult: (turn) => Array.isArray(turn) && turn.length > 11 && Array.isArray(turn[11]) && turn[11][0] === 1 && typeof turn[11][1] === 'string', | |
| // 将 XHR 数据转换为 Loominary 的 conversation 格式 | |
| parseToConversation: () => { | |
| if (!AiStudioXHR.capturedData) return null; | |
| try { | |
| const root = AiStudioXHR.capturedData[0]; | |
| const history = AiStudioXHR.findHistory(root); | |
| if (!history) return null; | |
| const pairs = []; | |
| let pendingThinking = []; | |
| let pendingCode = []; | |
| let currentUser = null; | |
| for (const turn of history) { | |
| if (!Array.isArray(turn)) continue; | |
| const isUser = turn.includes('user'); | |
| const isModel = turn.includes('model'); | |
| if (isUser) { | |
| const text = AiStudioXHR.extractText(turn); | |
| if (text) currentUser = text; | |
| pendingThinking = []; | |
| pendingCode = []; | |
| } else if (isModel) { | |
| const thinking = AiStudioXHR.isThinking(turn); | |
| const response = AiStudioXHR.isResponse(turn); | |
| const codeExec = AiStudioXHR.isCodeExec(turn); | |
| const codeResult = AiStudioXHR.isCodeResult(turn); | |
| if (codeExec) pendingCode.push({ type: 'code', content: turn[10][1] }); | |
| if (codeResult) pendingCode.push({ type: 'result', content: turn[11][1] }); | |
| if ((codeExec || codeResult) && !response && !thinking) continue; | |
| if (thinking && !response) { | |
| const text = AiStudioXHR.extractText(turn); | |
| if (text) pendingThinking.push(text); | |
| } else { | |
| let text = AiStudioXHR.extractText(turn); | |
| let assistantText = ''; | |
| // 添加代码执行(保留在正文中) | |
| if (pendingCode.length > 0) { | |
| for (const block of pendingCode) { | |
| if (block.type === 'code') { | |
| assistantText += `<details>\n<summary><strong>Executable Code</strong></summary>\n\n\`\`\`python\n${block.content}\n\`\`\`\n\n</details>\n\n`; | |
| } else if (block.type === 'result') { | |
| assistantText += `<details>\n<summary><strong>Code Execution Result</strong></summary>\n\n\`\`\`\n${block.content}\n\`\`\`\n\n</details>\n\n`; | |
| } | |
| } | |
| pendingCode = []; | |
| } | |
| if (text) assistantText += text; | |
| // 思考内容单独存储到 thinking 字段 | |
| const thinkingText = pendingThinking.length > 0 ? pendingThinking.join('\n\n').trim() : undefined; | |
| pendingThinking = []; | |
| if (assistantText || thinkingText) { | |
| const assistantObj = { text: assistantText.trim() }; | |
| if (thinkingText) assistantObj.thinking = thinkingText; | |
| pairs.push({ | |
| human: { text: currentUser || '[No preceding user prompt found]' }, | |
| assistant: assistantObj | |
| }); | |
| currentUser = null; | |
| } | |
| } | |
| } | |
| } | |
| // 如果最后有未配对的用户消息 | |
| if (currentUser) { | |
| pairs.push({ | |
| human: { text: currentUser }, | |
| assistant: { text: '[Model response is pending]' } | |
| }); | |
| } | |
| return pairs.length > 0 ? pairs : null; | |
| } catch (e) { | |
| console.error('[Loominary AI Studio] XHR parse error:', e); | |
| return null; | |
| } | |
| }, | |
| getTitle: () => { | |
| if (!AiStudioXHR.capturedData) return null; | |
| try { | |
| const root = AiStudioXHR.capturedData[0]; | |
| if (Array.isArray(root[4]) && typeof root[4][0] === 'string') return root[4][0]; | |
| } catch (e) {} | |
| return null; | |
| } | |
| }; | |
| function getAIStudioScroller() { | |
| for (const sel of ['ms-chat-session ms-autoscroll-container', 'mat-sidenav-content', '.chat-view-container']) { | |
| const el = document.querySelector(sel); | |
| if (el && (el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth)) return el; | |
| } | |
| return document.documentElement; | |
| } | |
| async function extractDataIncremental_AiStudio(includeImages = true) { | |
| for (const turn of document.querySelectorAll('ms-chat-turn')) { | |
| if (collectedData.has(turn)) continue; | |
| const userEl = turn.querySelector('.chat-turn-container.user'); | |
| const modelEl = turn.querySelector('.chat-turn-container.model'); | |
| const turnData = { type: 'unknown', text: '', images: [] }; | |
| if (userEl) { | |
| turnData.type = 'user'; | |
| const textEl = userEl.querySelector('.user-prompt-container .turn-content'); | |
| if (textEl) { | |
| const clone = textEl.cloneNode(true); | |
| // 移除 author-label(含时间戳如 "User 14:56") | |
| clone.querySelectorAll('.author-label, .turn-separator').forEach(e => e.remove()); | |
| let text = clone.innerText.trim(); | |
| if (text) turnData.text = text; | |
| } | |
| if (includeImages) { | |
| const imgs = userEl.querySelectorAll('.user-prompt-container img'); | |
| console.log('[Loominary AI Studio DOM] user turn: img elements found:', imgs.length, [...imgs].map(i => i.src?.slice(0, 50))); | |
| turnData.images = (await Promise.all([...imgs].map(gemini_processImageElement))).filter(Boolean); | |
| console.log('[Loominary AI Studio DOM] user turn: images processed:', turnData.images.length); | |
| } | |
| } else if (modelEl) { | |
| const chunks = modelEl.querySelectorAll('ms-prompt-chunk'); | |
| const texts = [], thinkingTexts = [], imgPromises = []; | |
| chunks.forEach(chunk => { | |
| const thoughtChunk = chunk.querySelector('ms-thought-chunk'); | |
| if (thoughtChunk) { | |
| const cmark = thoughtChunk.querySelector('ms-cmark-node'); | |
| if (cmark) { | |
| const md = htmlToMarkdown(cmark); | |
| if (md) thinkingTexts.push(md); | |
| } | |
| return; | |
| } | |
| // ms-image-chunk 内的图片(模型生成的图片) | |
| if (includeImages) { | |
| const imageChunk = chunk.querySelector('ms-image-chunk img'); | |
| if (imageChunk) { | |
| imgPromises.push(gemini_processImageElement(imageChunk)); | |
| return; | |
| } | |
| } | |
| const cmark = chunk.querySelector('ms-cmark-node'); | |
| if (cmark) { | |
| const md = htmlToMarkdown(cmark); | |
| if (md) texts.push(md); | |
| if (includeImages) [...cmark.querySelectorAll('img')].forEach(i => imgPromises.push(gemini_processImageElement(i))); | |
| } | |
| }); | |
| const text = texts.join('\n\n').trim(); | |
| const thinkingText = thinkingTexts.join('\n\n').trim(); | |
| if (text || thinkingText) { turnData.type = 'model'; turnData.text = text; } | |
| if (thinkingText) turnData.thinking = thinkingText; | |
| if (includeImages) turnData.images = (await Promise.all(imgPromises)).filter(Boolean); | |
| console.log('[Loominary AI Studio DOM] model turn: text=' + text.length + 'chars, thinking=' + thinkingText.length + 'chars, images=' + turnData.images.length, 'chunks=' + chunks.length); | |
| } | |
| if (turnData.type !== 'unknown' && (turnData.text || turnData.images.length)) { | |
| collectedData.set(turn, turnData); | |
| } | |
| } | |
| } | |
| const ScraperHandler = { | |
| handlers: { | |
| gemini: { | |
| getTitle: () => { | |
| const domTitle = document.querySelector('[data-test-id="conversation-title"]')?.textContent?.trim(); | |
| if (domTitle) return domTitle; | |
| const input = prompt('请输入对话标题 / Enter title:', '对话'); | |
| return input === null ? null : (input || i18n.t('untitledChat')); | |
| }, | |
| extractData: async (includeImages = true) => { | |
| const data = []; | |
| const turns = document.querySelectorAll("div.conversation-turn, div.single-turn, div.conversation-container"); | |
| for (const container of turns) { | |
| const userEl = container.querySelector("user-query .query-text, .query-text-line"); | |
| // 严格只从 message-content 获取内容 | |
| const messageContent = container.querySelector("message-content"); | |
| const modelEl = messageContent?.querySelector(".markdown-main-panel"); | |
| let humanText = ""; | |
| if (userEl) { | |
| const userClone = userEl.cloneNode(true); | |
| userClone.querySelectorAll('.cdk-visually-hidden').forEach(e => e.remove()); | |
| humanText = userClone.innerText.trim(); | |
| } | |
| let assistantText = ""; | |
| if (modelEl) { | |
| const clone = modelEl.cloneNode(true); | |
| clone.querySelectorAll('button.retry-without-tool-button, model-thoughts, .model-thoughts, .thoughts-header, .cdk-visually-hidden').forEach(b => b.remove()); | |
| assistantText = htmlToMarkdown(clone); | |
| } else if (messageContent) { | |
| // 回退:使用整个 message-content | |
| const clone = messageContent.cloneNode(true); | |
| clone.querySelectorAll('button.retry-without-tool-button, model-thoughts, .model-thoughts, .thoughts-header, .cdk-visually-hidden').forEach(b => b.remove()); | |
| assistantText = htmlToMarkdown(clone); | |
| } | |
| // 过滤掉只有思考标题的短文本 | |
| if (assistantText.length < 50 && !assistantText.includes('\n') && !assistantText.includes('*') && !assistantText.includes('#')) { | |
| assistantText = ""; | |
| } | |
| let userImages = [], modelImages = []; | |
| if (includeImages) { | |
| const uImgs = container.querySelectorAll("user-query img, user-query-file-preview img, .file-preview-container img"); | |
| // 只从 message-content 获取图片 | |
| const mImgs = messageContent?.querySelectorAll("img") || []; | |
| userImages = (await Promise.all([...uImgs].map(gemini_processImageElement))).filter(Boolean); | |
| modelImages = (await Promise.all([...mImgs].map(gemini_processImageElement))).filter(Boolean); | |
| } | |
| if (humanText || assistantText || userImages.length || modelImages.length) { | |
| const human = { text: humanText }; | |
| const assistant = { text: assistantText }; | |
| if (userImages.length) human.images = userImages; | |
| if (modelImages.length) assistant.images = modelImages; | |
| data.push({ human, assistant }); | |
| } | |
| } | |
| return data; | |
| } | |
| }, | |
| aistudio: { | |
| getTitle: () => { | |
| return AiStudioXHR.getTitle() || 'AI_Studio_Chat'; | |
| }, | |
| extractData: async (includeImages = true) => { | |
| console.log('[Loominary AI Studio] extractData called, includeImages:', includeImages); | |
| // 优先使用 XHR 拦截数据(即时、完整) | |
| const xhrResult = AiStudioXHR.parseToConversation(); | |
| console.log('[Loominary AI Studio] XHR result:', xhrResult ? xhrResult.length + ' pairs' : 'null'); | |
| if (xhrResult && xhrResult.length > 0) { | |
| console.log('[Loominary AI Studio] Using XHR path'); | |
| // XHR 不含图片,通过滚动 DOM 补充提取 | |
| if (includeImages) { | |
| console.log('[Loominary AI Studio] Starting DOM image collection'); | |
| const turns = document.querySelectorAll('ms-chat-turn'); | |
| console.log('[Loominary AI Studio] ms-chat-turn elements found:', turns.length); | |
| if (turns.length > 0) { | |
| const scroller = getAIStudioScroller(); | |
| scroller.scrollTop = 0; | |
| await Utils.sleep(Config.TIMING.SCROLL_TOP_WAIT); | |
| const imageMap = new Map(); | |
| const collectImages = async () => { | |
| const currentTurns = document.querySelectorAll('ms-chat-turn'); | |
| for (const turn of currentTurns) { | |
| if (imageMap.has(turn)) continue; | |
| const allImgs = turn.querySelectorAll('ms-image-chunk img'); | |
| const userImgs = [...turn.querySelectorAll('.chat-turn-container.user ms-image-chunk img')] | |
| .filter(img => !img.src.includes('drive-thirdparty.googleusercontent.com')); | |
| const modelImgs = [...turn.querySelectorAll('.chat-turn-container.model ms-image-chunk img')] | |
| .filter(img => !img.src.includes('drive-thirdparty.googleusercontent.com')); | |
| if (allImgs.length) { | |
| console.log('[Loominary AI Studio] Turn has', allImgs.length, 'img(s), user:', userImgs.length, 'model:', modelImgs.length, | |
| [...allImgs].map(i => i.src?.slice(0, 60))); | |
| } | |
| if (userImgs.length || modelImgs.length) { | |
| imageMap.set(turn, { | |
| userImages: (await Promise.all(userImgs.map(gemini_processImageElement))).filter(Boolean), | |
| modelImages: (await Promise.all(modelImgs.map(gemini_processImageElement))).filter(Boolean) | |
| }); | |
| } else { | |
| imageMap.set(turn, null); | |
| } | |
| } | |
| }; | |
| let lastScrollTop = -1; | |
| while (true) { | |
| await collectImages(); | |
| if (scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 10) break; | |
| lastScrollTop = scroller.scrollTop; | |
| scroller.scrollTop += scroller.clientHeight * 0.85; | |
| await Utils.sleep(Config.TIMING.SCROLL_DELAY); | |
| if (scroller.scrollTop === lastScrollTop) break; | |
| } | |
| await collectImages(); | |
| const totalWithImages = [...imageMap.values()].filter(v => v !== null).length; | |
| console.log('[Loominary AI Studio] Image collection done, turns with images:', totalWithImages); | |
| // 按 DOM 顺序合并图片到 XHR pairs | |
| let pairIdx = 0; | |
| let pendingUserImages = null; | |
| for (const turn of document.querySelectorAll('ms-chat-turn')) { | |
| const data = imageMap.get(turn); | |
| const isUser = turn.querySelector('.chat-turn-container.user'); | |
| const isModel = turn.querySelector('.chat-turn-container.model'); | |
| if (isUser && data?.userImages?.length) { | |
| pendingUserImages = data.userImages; | |
| } | |
| if (isModel) { | |
| if (pairIdx < xhrResult.length) { | |
| if (pendingUserImages) { | |
| xhrResult[pairIdx].human.images = pendingUserImages; | |
| pendingUserImages = null; | |
| } | |
| if (data?.modelImages?.length) { | |
| xhrResult[pairIdx].assistant.images = data.modelImages; | |
| } | |
| } | |
| pairIdx++; | |
| } | |
| } | |
| console.log('[Loominary AI Studio] Image merge done, pairs processed:', pairIdx); | |
| } | |
| } | |
| return xhrResult; | |
| } | |
| console.log('[Loominary AI Studio] Using DOM fallback path'); | |
| // DOM 回退(滚动提取) | |
| collectedData.clear(); | |
| const scroller = getAIStudioScroller(); | |
| scroller.scrollTop = 0; | |
| await Utils.sleep(Config.TIMING.SCROLL_TOP_WAIT); | |
| let lastScrollTop = -1; | |
| while (true) { | |
| await extractDataIncremental_AiStudio(includeImages); | |
| if (scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 10) break; | |
| lastScrollTop = scroller.scrollTop; | |
| scroller.scrollTop += scroller.clientHeight * 0.85; | |
| await Utils.sleep(Config.TIMING.SCROLL_DELAY); | |
| if (scroller.scrollTop === lastScrollTop) break; | |
| } | |
| await extractDataIncremental_AiStudio(includeImages); | |
| await Utils.sleep(500); | |
| const sorted = []; | |
| document.querySelectorAll('ms-chat-turn').forEach(t => { | |
| if (collectedData.has(t)) sorted.push(collectedData.get(t)); | |
| }); | |
| const paired = []; | |
| let lastHuman = null; | |
| for (const item of sorted) { | |
| if (item.type === 'user') { | |
| lastHuman = lastHuman || { text: '', images: [] }; | |
| lastHuman.text = (lastHuman.text ? lastHuman.text + '\n' : '') + item.text; | |
| if (item.images?.length) lastHuman.images.push(...item.images); | |
| } else if (item.type === 'model') { | |
| const human = { text: lastHuman?.text || "[No preceding user prompt found]" }; | |
| if (lastHuman?.images?.length) human.images = lastHuman.images; | |
| const assistant = { text: item.text }; | |
| if (item.thinking) assistant.thinking = item.thinking; | |
| if (item.images?.length) assistant.images = item.images; | |
| paired.push({ human, assistant }); | |
| lastHuman = null; | |
| } | |
| } | |
| if (lastHuman) { | |
| const human = { text: lastHuman.text }; | |
| if (lastHuman.images?.length) human.images = lastHuman.images; | |
| paired.push({ human, assistant: { text: "[Model response is pending]" } }); | |
| } | |
| return paired; | |
| } | |
| } | |
| }, | |
| buildConversationJson: async (platform, title) => { | |
| const handler = ScraperHandler.handlers[platform]; | |
| if (!handler) throw new Error('Invalid platform handler'); | |
| if (platform === 'gemini' && document.getElementById(Config.CANVAS_SWITCH_ID)?.checked) { | |
| // 导出前强制扫描一次,避免因 URL 变更重置或时序问题导致数据为空 | |
| VersionTracker.isScanning = false; // 防止卡死 | |
| await VersionTracker.scanOnce(); | |
| VersionTracker.forceCommitAll(); | |
| const includeImagesForVersioned = document.getElementById(Config.IMAGE_SWITCH_ID)?.checked || false; | |
| const versionedData = VersionTracker.buildVersionedData(title, includeImagesForVersioned); | |
| if (versionedData.conversation.length > 0) return versionedData; | |
| // 版本追踪数据为空,回退到普通提取 | |
| } | |
| const includeImages = document.getElementById(Config.IMAGE_SWITCH_ID)?.checked || false; | |
| const conversation = await handler.extractData(includeImages); | |
| if (!conversation?.length) throw new Error(i18n.t('noContent')); | |
| return { title, platform, exportedAt: new Date().toISOString(), conversation }; | |
| }, | |
| addButtons: (controlsArea, platform) => { | |
| const handler = ScraperHandler.handlers[platform]; | |
| if (!handler) return; | |
| const colors = { gemini: '#1a73e8', aistudio: '#777779' }; | |
| const color = colors[platform] || '#4285f4'; | |
| const useInline = platform === 'gemini'; | |
| const createToggle = (label, id, state, onChange) => { | |
| const toggle = Utils.createToggle(label, id, state); | |
| const input = toggle.querySelector('.loominary-switch input'); | |
| if (input) { | |
| input.addEventListener('change', onChange); | |
| const slider = toggle.querySelector('.loominary-slider'); | |
| if (slider) slider.style.setProperty('--theme-color', color); | |
| } | |
| return toggle; | |
| }; | |
| if (platform === 'gemini') { | |
| controlsArea.appendChild(createToggle(i18n.t('versionTracking') || '版本追踪', Config.CANVAS_SWITCH_ID, State.includeCanvas, e => { | |
| State.includeCanvas = e.target.checked; | |
| localStorage.setItem('includeCanvas', State.includeCanvas); | |
| e.target.checked ? VersionTracker.startTracking() : VersionTracker.stopTracking(); | |
| })); | |
| if (State.includeCanvas) VersionTracker.startTracking(); | |
| } | |
| if (platform === 'gemini' || platform === 'aistudio') { | |
| controlsArea.appendChild(createToggle(i18n.t('includeImages'), Config.IMAGE_SWITCH_ID, State.includeImages, e => { | |
| State.includeImages = e.target.checked; | |
| localStorage.setItem('includeImages', State.includeImages); | |
| })); | |
| } | |
| const createActionBtn = (icon, label, action) => { | |
| const btn = Utils.createButton(`${icon} ${i18n.t(label)}`, action, useInline); | |
| if (useInline) Object.assign(btn.style, { backgroundColor: color, color: 'white' }); | |
| return btn; | |
| }; | |
| controlsArea.appendChild(createActionBtn(previewIcon, 'viewOnline', async btn => { | |
| const title = handler.getTitle(); | |
| if (!title) return; | |
| const original = btn.innerHTML; | |
| Utils.setButtonLoading(btn, i18n.t('loading')); | |
| let progress = platform === 'aistudio' ? Utils.createProgressElem(controlsArea) : null; | |
| if (progress) progress.textContent = i18n.t('loading'); | |
| try { | |
| const json = await ScraperHandler.buildConversationJson(platform, title); | |
| const filename = `${platform}_${Utils.sanitizeFilename(title)}_${new Date().toISOString().slice(0, 10)}.json`; | |
| await Communicator.open(JSON.stringify(json, null, 2), filename); | |
| } catch (e) { | |
| ErrorHandler.handle(e, 'Preview conversation', { userMessage: `${i18n.t('loadFailed')} ${e.message}` }); | |
| } finally { | |
| Utils.restoreButton(btn, original); | |
| progress?.remove(); | |
| } | |
| })); | |
| controlsArea.appendChild(createActionBtn(exportIcon, 'exportCurrentJSON', async btn => { | |
| const title = handler.getTitle(); | |
| if (!title) return; | |
| const original = btn.innerHTML; | |
| Utils.setButtonLoading(btn, i18n.t('exporting')); | |
| let progress = platform === 'aistudio' ? Utils.createProgressElem(controlsArea) : null; | |
| if (progress) progress.textContent = i18n.t('exporting'); | |
| try { | |
| const json = await ScraperHandler.buildConversationJson(platform, title); | |
| const baseName = `${platform}_${Utils.sanitizeFilename(title)}_${new Date().toISOString().slice(0, 10)}`; | |
| await loominaryExportMarkdown(json, baseName); | |
| } catch (e) { | |
| ErrorHandler.handle(e, 'Export conversation'); | |
| } finally { | |
| Utils.restoreButton(btn, original); | |
| progress?.remove(); | |
| } | |
| })); | |
| } | |
| }; | |
| const UI = { | |
| injectStyle: () => { | |
| const platformColors = { | |
| claude: '#141413', | |
| chatgpt: '#10A37F', | |
| grok: '#000000', | |
| gemini: '#1a73e8', | |
| aistudio: '#777779' | |
| }; | |
| const buttonColor = platformColors[State.currentPlatform] || '#4285f4'; | |
| console.log('[Loominary] Current platform:', State.currentPlatform); | |
| console.log('[Loominary] Button color:', buttonColor); | |
| document.documentElement.style.setProperty('--loominary-button-color', buttonColor); | |
| console.log('[Loominary] CSS variable --loominary-button-color set to:', buttonColor); | |
| const linkId = 'loominary-fetch-external-css'; | |
| GM_addStyle(` | |
| #loominary-controls { | |
| position: fixed !important; | |
| top: 50% !important; | |
| right: 0 !important; | |
| transform: translateY(-50%) translateX(10px) !important; | |
| background: white !important; | |
| border: 1px solid #dadce0 !important; | |
| border-radius: 8px !important; | |
| padding: 16px 16px 8px 16px !important; | |
| width: 136px !important; | |
| z-index: 999999 !important; | |
| font-family: 'Segoe UI', system-ui, -apple-system, sans-serif !important; | |
| transition: all 0.7s cubic-bezier(0.4, 0, 0.2, 1) !important; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.15) !important; | |
| } | |
| #loominary-controls.collapsed { | |
| transform: translateY(-50%) translateX(calc(100% - 35px + 6px)) !important; | |
| opacity: 0.6 !important; | |
| background: white !important; | |
| border-color: #dadce0 !important; | |
| border-radius: 8px 0 0 8px !important; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.15) !important; | |
| pointer-events: none !important; | |
| } | |
| #loominary-controls.collapsed .loominary-main-controls { | |
| opacity: 0 !important; | |
| pointer-events: none !important; | |
| } | |
| #loominary-controls:hover { | |
| opacity: 1 !important; | |
| } | |
| #loominary-toggle-button { | |
| position: absolute !important; | |
| left: 0 !important; | |
| top: 50% !important; | |
| transform: translateY(-50%) translateX(-50%) !important; | |
| cursor: pointer !important; | |
| width: 32px !important; | |
| height: 32px !important; | |
| display: flex !important; | |
| align-items: center !important; | |
| justify-content: center !important; | |
| background: #ffffff !important; | |
| color: var(--loominary-button-color) !important; | |
| border-radius: 50% !important; | |
| box-shadow: 0 1px 3px rgba(0,0,0,0.2) !important; | |
| border: 1px solid #dadce0 !important; | |
| transition: all 0.7s cubic-bezier(0.4, 0, 0.2, 1) !important; | |
| z-index: 1000 !important; | |
| pointer-events: all !important; | |
| } | |
| #loominary-controls.collapsed #loominary-toggle-button { | |
| z-index: 2 !important; | |
| left: 16px !important; | |
| transform: translateY(-50%) translateX(-50%) !important; | |
| width: 21px !important; | |
| height: 21px !important; | |
| background: var(--loominary-button-color) !important; | |
| color: white !important; | |
| } | |
| #loominary-controls.collapsed #loominary-toggle-button:hover { | |
| box-shadow: | |
| 0 4px 12px rgba(0,0,0,0.25), | |
| 0 0 0 3px rgba(255,255,255,0.9) !important; | |
| transform: translateY(-50%) translateX(-50%) scale(1.15) !important; | |
| opacity: 0.9 !important; | |
| } | |
| .loominary-main-controls { | |
| margin-left: 0px !important; | |
| padding: 0 3px !important; | |
| transition: opacity 0.7s !important; | |
| } | |
| .loominary-title { | |
| font-size: 16px !important; | |
| font-weight: 700 !important; | |
| color: #202124 !important; | |
| text-align: center; | |
| margin-bottom: 12px !important; | |
| padding-bottom: 0px !important; | |
| letter-spacing: 0.3px !important; | |
| } | |
| .loominary-input-trigger { | |
| display: flex !important; | |
| align-items: center !important; | |
| justify-content: center !important; | |
| gap: 3px !important; | |
| font-size: 10px !important; | |
| margin: 10px auto 0 auto !important; | |
| padding: 2px 6px !important; | |
| border-radius: 3px !important; | |
| background: transparent !important; | |
| cursor: pointer !important; | |
| transition: all 0.15s !important; | |
| white-space: nowrap !important; | |
| color: #5f6368 !important; | |
| border: none !important; | |
| font-weight: 500 !important; | |
| width: fit-content !important; | |
| } | |
| .loominary-input-trigger:hover { | |
| background: #f1f3f4 !important; | |
| color: #202124 !important; | |
| } | |
| .loominary-button { | |
| display: flex !important; | |
| align-items: center !important; | |
| justify-content: flex-start !important; | |
| gap: 8px !important; | |
| width: 100% !important; | |
| padding: 8px 12px !important; | |
| margin: 8px 0 !important; | |
| border: none !important; | |
| border-radius: 6px !important; | |
| background: var(--loominary-button-color) !important; | |
| color: white !important; | |
| font-size: 11px !important; | |
| font-weight: 500 !important; | |
| cursor: pointer !important; | |
| letter-spacing: 0.3px !important; | |
| height: 32px !important; | |
| box-sizing: border-box !important; | |
| } | |
| .loominary-button svg { | |
| width: 16px !important; | |
| height: 16px !important; | |
| flex-shrink: 0 !important; | |
| } | |
| .loominary-button:disabled { | |
| opacity: 0.6 !important; | |
| cursor: not-allowed !important; | |
| } | |
| .loominary-status { | |
| font-size: 10px !important; | |
| padding: 6px 8px !important; | |
| border-radius: 4px !important; | |
| margin: 4px 0 !important; | |
| text-align: center !important; | |
| } | |
| .loominary-status.success { | |
| background: #e8f5e9 !important; | |
| color: #2e7d32 !important; | |
| border: 1px solid #c8e6c9 !important; | |
| } | |
| .loominary-status.error { | |
| background: #ffebee !important; | |
| color: #c62828 !important; | |
| border: 1px solid #ffcdd2 !important; | |
| } | |
| .loominary-toggle { | |
| display: flex !important; | |
| align-items: center !important; | |
| justify-content: space-between !important; | |
| font-size: 11px !important; | |
| font-weight: 500 !important; | |
| color: #5f6368 !important; | |
| margin: 3px 0 !important; | |
| gap: 8px !important; | |
| padding: 4px 8px !important; | |
| } | |
| .loominary-toggle:last-of-type { | |
| margin-bottom: 14px !important; | |
| } | |
| .loominary-switch { | |
| position: relative !important; | |
| display: inline-block !important; | |
| width: 32px !important; | |
| height: 16px !important; | |
| flex-shrink: 0 !important; | |
| } | |
| .loominary-switch input { | |
| opacity: 0 !important; | |
| width: 0 !important; | |
| height: 0 !important; | |
| } | |
| .loominary-slider { | |
| position: absolute !important; | |
| cursor: pointer !important; | |
| top: 0 !important; | |
| left: 0 !important; | |
| right: 0 !important; | |
| bottom: 0 !important; | |
| background-color: #ccc !important; | |
| transition: .3s !important; | |
| border-radius: 34px !important; | |
| --theme-color: var(--loominary-button-color); | |
| } | |
| .loominary-slider:before { | |
| position: absolute !important; | |
| content: "" !important; | |
| height: 12px !important; | |
| width: 12px !important; | |
| left: 2px !important; | |
| bottom: 2px !important; | |
| background-color: white !important; | |
| transition: .3s !important; | |
| border-radius: 50% !important; | |
| } | |
| input:checked + .loominary-slider { | |
| background-color: var(--theme-color, var(--loominary-button-color)) !important; | |
| } | |
| input:checked + .loominary-slider:before { | |
| transform: translateX(16px) !important; | |
| } | |
| .loominary-loading { | |
| display: inline-block !important; | |
| width: 14px !important; | |
| height: 14px !important; | |
| border: 2px solid rgba(255, 255, 255, 0.3) !important; | |
| border-radius: 50% !important; | |
| border-top-color: #fff !important; | |
| animation: loominary-spin 0.8s linear infinite !important; | |
| } | |
| @keyframes loominary-spin { | |
| to { transform: rotate(360deg); } | |
| } | |
| .loominary-progress { | |
| font-size: 10px !important; | |
| color: #5f6368 !important; | |
| margin-top: 4px !important; | |
| text-align: center !important; | |
| padding: 4px !important; | |
| background: #f8f9fa !important; | |
| border-radius: 4px !important; | |
| } | |
| .loominary-lang-toggle { | |
| display: flex !important; | |
| align-items: center !important; | |
| justify-content: center !important; | |
| gap: 3px !important; | |
| font-size: 10px !important; | |
| margin: 4px auto 0 auto !important; | |
| padding: 2px 6px !important; | |
| border-radius: 3px !important; | |
| background: transparent !important; | |
| cursor: pointer !important; | |
| transition: all 0.15s !important; | |
| white-space: nowrap !important; | |
| color: #5f6368 !important; | |
| border: none !important; | |
| font-weight: 500 !important; | |
| width: fit-content !important; | |
| } | |
| .loominary-lang-toggle:hover { | |
| background: #f1f3f4 !important; | |
| color: #202124 !important; | |
| } | |
| `); | |
| }, | |
| toggleCollapsed: () => { | |
| State.isPanelCollapsed = !State.isPanelCollapsed; | |
| localStorage.setItem('exporterCollapsed', State.isPanelCollapsed); | |
| const panel = document.getElementById(Config.CONTROL_ID); | |
| const toggle = document.getElementById(Config.TOGGLE_ID); | |
| if (!panel || !toggle) return; | |
| if (State.isPanelCollapsed) { | |
| panel.classList.add('collapsed'); | |
| safeSetInnerHTML(toggle, collapseIcon); | |
| } else { | |
| panel.classList.remove('collapsed'); | |
| safeSetInnerHTML(toggle, expandIcon); | |
| } | |
| }, | |
| recreatePanel: () => { | |
| document.getElementById(Config.CONTROL_ID)?.remove(); | |
| State.panelInjected = false; | |
| UI.createPanel(); | |
| }, | |
| createPanel: () => { | |
| if (document.getElementById(Config.CONTROL_ID) || State.panelInjected) return false; | |
| const container = document.createElement('div'); | |
| container.id = Config.CONTROL_ID; | |
| const color = getComputedStyle(document.documentElement) | |
| .getPropertyValue('--loominary-button-color') | |
| .trim() || '#141413'; | |
| container.style.setProperty('--loominary-button-color', color); | |
| if (State.isPanelCollapsed) container.classList.add('collapsed'); | |
| if (State.currentPlatform === 'gemini') { | |
| Object.assign(container.style, { | |
| position: 'fixed', | |
| top: '50%', | |
| right: '0', | |
| transform: 'translateY(-50%) translateX(10px)', | |
| background: 'white', | |
| border: '1px solid #dadce0', | |
| borderRadius: '8px', | |
| padding: '16px 16px 8px 16px', | |
| width: '136px', | |
| zIndex: '999999', | |
| fontFamily: "'Segoe UI', system-ui, -apple-system, sans-serif", | |
| transition: 'all 0.7s cubic-bezier(0.4, 0, 0.2, 1)', | |
| boxShadow: '0 4px 12px rgba(0,0,0,0.15)', | |
| boxSizing: 'border-box' | |
| }); | |
| } | |
| const toggle = document.createElement('div'); | |
| toggle.id = Config.TOGGLE_ID; | |
| safeSetInnerHTML(toggle, State.isPanelCollapsed ? collapseIcon : expandIcon); | |
| toggle.addEventListener('click', UI.toggleCollapsed); | |
| container.appendChild(toggle); | |
| const controls = document.createElement('div'); | |
| controls.className = 'loominary-main-controls'; | |
| if (State.currentPlatform === 'gemini') { | |
| Object.assign(controls.style, { | |
| marginLeft: '0px', | |
| padding: '0 3px', | |
| transition: 'opacity 0.7s' | |
| }); | |
| } | |
| const title = document.createElement('div'); | |
| title.className = 'loominary-title'; | |
| const titles = { | |
| claude: 'Claude', | |
| chatgpt: 'ChatGPT', | |
| grok: 'Grok', | |
| gemini: 'Gemini', aistudio: 'AI Studio' | |
| }; | |
| title.textContent = titles[State.currentPlatform] || 'Exporter'; | |
| controls.appendChild(title); | |
| if (State.currentPlatform === 'claude') { | |
| ClaudeHandler.addUI(controls); | |
| ClaudeHandler.addButtons(controls); | |
| const inputLabel = document.createElement('div'); | |
| inputLabel.className = 'loominary-input-trigger'; | |
| inputLabel.textContent = `${i18n.t('manualUserId')}`; | |
| inputLabel.addEventListener('click', () => { | |
| const newId = prompt(i18n.t('enterUserId'), State.capturedUserId); | |
| if (newId?.trim()) { | |
| State.capturedUserId = newId.trim(); | |
| localStorage.setItem('claudeUserId', State.capturedUserId); | |
| alert(i18n.t('userIdSaved')); | |
| UI.recreatePanel(); | |
| } | |
| }); | |
| controls.appendChild(inputLabel); | |
| } | |
| if (State.currentPlatform === 'chatgpt') { | |
| ChatGPTHandler.addUI(controls); | |
| ChatGPTHandler.addButtons(controls); | |
| } | |
| if (State.currentPlatform === 'grok') { | |
| GrokHandler.addUI(controls); | |
| GrokHandler.addButtons(controls); | |
| } | |
| if (['gemini', 'aistudio'].includes(State.currentPlatform)) { | |
| ScraperHandler.addButtons(controls, State.currentPlatform); | |
| } | |
| const langToggle = document.createElement('div'); | |
| langToggle.className = 'loominary-lang-toggle'; | |
| langToggle.textContent = `🌐 ${i18n.getLanguageShort()}`; | |
| langToggle.addEventListener('click', () => { | |
| i18n.setLanguage(i18n.currentLang === 'zh' ? 'en' : 'zh'); | |
| UI.recreatePanel(); | |
| }); | |
| controls.appendChild(langToggle); | |
| container.appendChild(controls); | |
| document.body.appendChild(container); | |
| State.panelInjected = true; | |
| const panel = document.getElementById(Config.CONTROL_ID); | |
| if (State.isPanelCollapsed) { | |
| panel.classList.add('collapsed'); | |
| safeSetInnerHTML(toggle, collapseIcon); | |
| } else { | |
| panel.classList.remove('collapsed'); | |
| safeSetInnerHTML(toggle, expandIcon); | |
| } | |
| return true; | |
| } | |
| }; | |
| const init = () => { | |
| if (!State.currentPlatform) return; | |
| if (State.currentPlatform === 'claude') ClaudeHandler.init(); | |
| if (State.currentPlatform === 'chatgpt') ChatGPTHandler.init(); | |
| if (State.currentPlatform === 'grok') GrokHandler.init(); | |
| if (State.currentPlatform === 'aistudio') AiStudioXHR.init(); | |
| UI.injectStyle(); | |
| const initPanel = () => { | |
| UI.createPanel(); | |
| if (['claude', 'chatgpt', 'grok', 'gemini', 'aistudio'].includes(State.currentPlatform)) { | |
| let lastUrl = window.location.href; | |
| let panelCheckTimer = null; | |
| new MutationObserver(() => { | |
| // URL 变化时重建面板 | |
| if (window.location.href !== lastUrl) { | |
| lastUrl = window.location.href; | |
| setTimeout(() => { | |
| if (!document.getElementById(Config.CONTROL_ID)) { | |
| State.panelInjected = false; | |
| UI.createPanel(); | |
| } | |
| }, 1000); | |
| } | |
| // SPA 框架可能在初始化时移除我们的面板,防抖检测并重建 | |
| if (State.panelInjected && !document.getElementById(Config.CONTROL_ID)) { | |
| clearTimeout(panelCheckTimer); | |
| panelCheckTimer = setTimeout(() => { | |
| if (!document.getElementById(Config.CONTROL_ID)) { | |
| State.panelInjected = false; | |
| UI.createPanel(); | |
| } | |
| }, 500); | |
| } | |
| }).observe(document.body, { childList: true, subtree: true }); | |
| } | |
| }; | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', () => setTimeout(initPanel, Config.TIMING.PANEL_INIT_DELAY)); | |
| } else { | |
| setTimeout(initPanel, Config.TIMING.PANEL_INIT_DELAY); | |
| } | |
| }; | |
| init(); | |
| })(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name OmniChat Exporter - Export Any AI Chat Instantly | |
| // @name:fr OmniChat Exporter - Exporter instantanément n’importe quelle conversation IA | |
| // @name:es OmniChat Exporter - Exportar instantáneamente cualquier chat de IA | |
| // @name:de OmniChat Exporter - Jeden KI-Chat sofort exportieren | |
| // @name:ru OmniChat Exporter - Мгновенный экспорт любого ИИ-чата | |
| // @name:zh-CN OmniChat Exporter - 即时导出任何 AI 聊天 | |
| // @name:zh-TW OmniChat Exporter - 即時匯出任何 AI 聊天 | |
| // @name:ja OmniChat Exporter - あらゆるAIチャットを即時エクスポート | |
| // @name:pt OmniChat Exporter - Exportar instantaneamente qualquer chat de IA | |
| // @name:it OmniChat Exporter - Esporta istantaneamente qualsiasi chat IA | |
| // @name:ar OmniChat Exporter - تصدير أي دردشة ذكاء اصطناعي فورًا | |
| // @name:be OmniChat Exporter - Імгненны экспарт любога ІІ-чата | |
| // @name:bg OmniChat Exporter - Незабавно експортиране на всеки AI чат | |
| // @name:cs OmniChat Exporter - Okamžitý export jakéhokoli AI chatu | |
| // @name:da OmniChat Exporter - Eksportér enhver AI-chat med det samme | |
| // @name:el OmniChat Exporter - Άμεση εξαγωγή οποιασδήποτε συνομιλίας AI | |
| // @name:eo OmniChat Exporter - Tuj eksportu ajnan AI-babilejon | |
| // @name:fi OmniChat Exporter - Vie mikä tahansa AI-keskustelu heti | |
| // @name:he OmniChat Exporter - ייצוא מיידי של כל צ׳אט בינה מלאכותית | |
| // @name:hr OmniChat Exporter - Trenutni izvoz bilo kojeg AI chata | |
| // @name:hu OmniChat Exporter - Bármely AI-chat azonnali exportálása | |
| // @name:id OmniChat Exporter - Ekspor instan semua chat AI | |
| // @name:ka OmniChat Exporter - ნებისმიერი AI ჩატის მყისიერი ექსპორტი | |
| // @name:ko OmniChat Exporter - 모든 AI 채팅 즉시 내보내기 | |
| // @name:mr OmniChat Exporter - कोणताही AI चॅट त्वरित निर्यात करा | |
| // @name:nl OmniChat Exporter - Exporteer direct elke AI-chat | |
| // @name:nb OmniChat Exporter - Eksporter enhver AI-chat umiddelbart | |
| // @name:pl OmniChat Exporter - Natychmiastowy eksport dowolnego czatu AI | |
| // @name:pt-BR OmniChat Exporter - Exporte instantaneamente qualquer chat de IA | |
| // @name:ro OmniChat Exporter - Exportă instant orice chat AI | |
| // @name:sk OmniChat Exporter - Okamžitý export akéhokoľvek AI chatu | |
| // @name:sr OmniChat Exporter - Trenutni izvoz bilo kog AI chata | |
| // @name:sv OmniChat Exporter - Exportera valfri AI-chatt direkt | |
| // @name:th OmniChat Exporter - ส่งออกแชท AI ใดก็ได้ทันที | |
| // @name:tr OmniChat Exporter - Herhangi bir AI sohbetini anında dışa aktar | |
| // @name:ug OmniChat Exporter - ھەر قانداق AI سۆھبەتنى دەرھال چىقىرىش | |
| // @name:uk OmniChat Exporter - Миттєвий експорт будь-якого AI-чату | |
| // @name:vi OmniChat Exporter - Xuất ngay mọi cuộc trò chuyện AI | |
| // @name:fr-CA OmniChat Exporter - Exporter instantanément toute conversation IA | |
| // @name:ckb OmniChat Exporter - هەر گفتوگۆیەکی AI بە خێرایی هەناردە بکە | |
| // @name:es-419 OmniChat Exporter - Exporta al instante cualquier chat de IA | |
| // @namespace https://github.com/DREwX-code | |
| // @version 1.1.0 | |
| // @icon https://raw.githubusercontent.com/DREwX-code/omnichat-exporter/main/assets/logo.png | |
| // @description Export and download conversations from ChatGPT, Gemini, Claude, Grok, and DeepSeek in TXT, PDF, JSON, or Markdown format - per message or full thread. | |
| // @description:fr Exporter et télécharger des conversations depuis ChatGPT, Gemini, Claude, Grok et DeepSeek aux formats TXT, PDF, JSON ou Markdown - par message ou conversation complète. | |
| // @description:es Exportar y descargar conversaciones de ChatGPT, Gemini, Claude, Grok y DeepSeek en formato TXT, PDF, JSON o Markdown - por mensaje o conversación completa. | |
| // @description:de Gespräche von ChatGPT, Gemini, Claude, Grok und DeepSeek als TXT-, PDF-, JSON- oder Markdown-Datei exportieren und herunterladen - pro Nachricht oder gesamter Verlauf. | |
| // @description:ru Экспорт и загрузка диалогов из ChatGPT, Gemini, Claude, Grok и DeepSeek в форматах TXT, PDF, JSON или Markdown - по сообщениям или всей переписки. | |
| // @description:zh-CN 从 ChatGPT、Gemini、Claude、Grok 和 DeepSeek 导出并下载对话,支持 TXT、PDF、JSON 或 Markdown 格式--按单条消息或完整对话。 | |
| // @description:zh-TW 從 ChatGPT、Gemini、Claude、Grok 和 DeepSeek 匯出並下載對話,支援 TXT、PDF、JSON 或 Markdown 格式--單則訊息或完整對話。 | |
| // @description:ja ChatGPT、Gemini、Claude、Grok、DeepSeek の会話を TXT、PDF、JSON、Markdown 形式でエクスポートおよびダウンロード - メッセージ単位またはスレッド全体。 | |
| // @description:pt Exportar e baixar conversas do ChatGPT, Gemini, Claude, Grok e DeepSeek nos formatos TXT, PDF, JSON ou Markdown - por mensagem ou conversa completa. | |
| // @description:it Esportare e scaricare conversazioni da ChatGPT, Gemini, Claude, Grok e DeepSeek nei formati TXT, PDF, JSON o Markdown - per messaggio o conversazione completa. | |
| // @description:ar تصدير وتنزيل المحادثات من ChatGPT وGemini وClaude وGrok وDeepSeek بصيغ TXT أو PDF أو JSON أو Markdown - لكل رسالة أو للمحادثة كاملة. | |
| // @description:be Экспарт і спампоўка размоў з ChatGPT, Gemini, Claude, Grok і DeepSeek у фарматах TXT, PDF, JSON або Markdown - па паведамленнях або ўся размова. | |
| // @description:bg Експортиране и изтегляне на разговори от ChatGPT, Gemini, Claude, Grok и DeepSeek във формати TXT, PDF, JSON или Markdown - по съобщение или цял разговор. | |
| // @description:cs Export a stažení konverzací z ChatGPT, Gemini, Claude, Grok a DeepSeek ve formátu TXT, PDF, JSON nebo Markdown - po zprávách nebo celé vlákno. | |
| // @description:da Eksportér og download samtaler fra ChatGPT, Gemini, Claude, Grok og DeepSeek i TXT-, PDF-, JSON- eller Markdown-format - pr. besked eller hele tråden. | |
| // @description:el Εξαγωγή και λήψη συνομιλιών από ChatGPT, Gemini, Claude, Grok και DeepSeek σε μορφή TXT, PDF, JSON ή Markdown - ανά μήνυμα ή ολόκληρη συνομιλία. | |
| // @description:eo Eksporti kaj elŝuti konversaciojn el ChatGPT, Gemini, Claude, Grok kaj DeepSeek en formato TXT, PDF, JSON aŭ Markdown - laŭ mesaĝo aŭ tuta fadeno. | |
| // @description:fi Vie ja lataa keskustelut ChatGPT:stä, Geministä, Claudesta, Grokista ja DeepSeekistä TXT-, PDF-, JSON- tai Markdown-muodossa - viestikohtaisesti tai koko keskustelu. | |
| // @description:he ייצוא והורדת שיחות מ-ChatGPT, Gemini, Claude, Grok ו-DeepSeek בפורמט TXT, PDF, JSON או Markdown - לפי הודעה או כל השיחה. | |
| // @description:hr Izvoz i preuzimanje razgovora iz ChatGPT-a, Geminija, Claudea, Groka i DeepSeeka u TXT, PDF, JSON ili Markdown formatu - po poruci ili cijelom razgovoru. | |
| // @description:hu Beszélgetések exportálása és letöltése a ChatGPT, Gemini, Claude, Grok és DeepSeek rendszerekből TXT, PDF, JSON vagy Markdown formátumban - üzenetenként vagy teljes beszélgetés. | |
| // @description:id Ekspor dan unduh percakapan dari ChatGPT, Gemini, Claude, Grok, dan DeepSeek dalam format TXT, PDF, JSON, atau Markdown - per pesan atau seluruh percakapan. | |
| // @description:ka ჩათGPT, Gemini, Claude, Grok და DeepSeek საუბრების ექსპორტი და ჩამოტვირთვა TXT, PDF, JSON ან Markdown ფორმატში - თითოეული შეტყობინებით ან სრული საუბარი. | |
| // @description:ko ChatGPT, Gemini, Claude, Grok 및 DeepSeek의 대화를 TXT, PDF, JSON 또는 Markdown 형식으로 내보내기 및 다운로드 - 메시지별 또는 전체 대화. | |
| // @description:mr ChatGPT, Gemini, Claude, Grok आणि DeepSeek मधील संभाषणे TXT, PDF, JSON किंवा Markdown स्वरूपात निर्यात व डाउनलोड करा - प्रत्येक संदेशानुसार किंवा पूर्ण संभाषण. | |
| // @description:nl Exporteer en download gesprekken van ChatGPT, Gemini, Claude, Grok en DeepSeek in TXT-, PDF-, JSON- of Markdown-formaat - per bericht of volledige conversatie. | |
| // @description:nb Eksporter og last ned samtaler fra ChatGPT, Gemini, Claude, Grok og DeepSeek i TXT-, PDF-, JSON- eller Markdown-format - per melding eller hele tråden. | |
| // @description:pl Eksportuj i pobieraj rozmowy z ChatGPT, Gemini, Claude, Grok i DeepSeek w formacie TXT, PDF, JSON lub Markdown - według wiadomości lub cała rozmowa. | |
| // @description:pt-BR Exporte e baixe conversas do ChatGPT, Gemini, Claude, Grok e DeepSeek nos formatos TXT, PDF, JSON ou Markdown - por mensagem ou conversa completa. | |
| // @description:ro Exportă și descarcă conversații din ChatGPT, Gemini, Claude, Grok și DeepSeek în format TXT, PDF, JSON sau Markdown - per mesaj sau întreaga conversație. | |
| // @description:sk Export a stiahnutie konverzácií z ChatGPT, Gemini, Claude, Grok a DeepSeek vo formáte TXT, PDF, JSON alebo Markdown - po správach alebo celé vlákno. | |
| // @description:sr Izvoz i preuzimanje razgovora iz ChatGPT-a, Geminija, Claudea, Groka i DeepSeeka u TXT, PDF, JSON ili Markdown formatu - po poruci ili ceo razgovor. | |
| // @description:sv Exportera och ladda ner konversationer från ChatGPT, Gemini, Claude, Grok och DeepSeek i TXT-, PDF-, JSON- eller Markdown-format - per meddelande eller hela tråden. | |
| // @description:th ส่งออกและดาวน์โหลดบทสนทนาจาก ChatGPT, Gemini, Claude, Grok และ DeepSeek ในรูปแบบ TXT, PDF, JSON หรือ Markdown - แยกตามข้อความหรือทั้งบทสนทนา. | |
| // @description:tr ChatGPT, Gemini, Claude, Grok ve DeepSeek konuşmalarını TXT, PDF, JSON veya Markdown formatında dışa aktarın ve indirin - mesaj bazında veya tüm konuşma. | |
| // @description:ug ChatGPT، Gemini، Claude، Grok ۋە DeepSeek دىكى سۆھبەتلەرنى TXT، PDF، JSON ياكى Markdown فورماتىدا چىقىرىش ۋە چۈشۈرۈش - ھەر بىر ئۇچۇر ياكى پۈتۈن سۆھبەت. | |
| // @description:uk Експорт і завантаження розмов із ChatGPT, Gemini, Claude, Grok та DeepSeek у форматах TXT, PDF, JSON або Markdown - за повідомленням або вся розмова. | |
| // @description:vi Xuất và tải xuống cuộc trò chuyện từ ChatGPT, Gemini, Claude, Grok và DeepSeek ở định dạng TXT, PDF, JSON hoặc Markdown - theo từng tin nhắn hoặc toàn bộ cuộc trò chuyện. | |
| // @description:fr-CA Exporter et télécharger des conversations depuis ChatGPT, Gemini, Claude, Grok et DeepSeek aux formats TXT, PDF, JSON ou Markdown - par message ou conversation complète. | |
| // @description:ckb هەناردن و داگرتنی گفتوگۆکان لە ChatGPT، Gemini، Claude، Grok و DeepSeek بە شێوەی TXT، PDF، JSON یان Markdown - بۆ هەر نامەیەک یان تەواوی گفتوگۆ. | |
| // @description:es-419 Exportar y descargar conversaciones de ChatGPT, Gemini, Claude, Grok y DeepSeek en formato TXT, PDF, JSON o Markdown - por mensaje o conversación completa. | |
| // @author Dℝ∃wX | |
| // @license Apache-2.0 | |
| // @copyright 2026 Dℝ∃wX | |
| // @match https://chat.openai.com/* | |
| // @match https://chatgpt.com/* | |
| // @match https://gemini.google.com/* | |
| // @match https://claude.ai/* | |
| // @match https://grok.com/* | |
| // @match https://grok.x.ai/* | |
| // @match https://chat.deepseek.com/* | |
| // @grant GM_xmlhttpRequest | |
| // @connect raw.githubusercontent.com | |
| // @connect esm.sh | |
| // @connect cdn.jsdelivr.net | |
| // @connect github.com | |
| // @require https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.9/pdfmake.min.js | |
| // @require https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.9/vfs_fonts.js | |
| // @run-at document-idle | |
| // @tag utilities | |
| // @downloadURL https://update.greasyfork.org/scripts/567743/OmniChat%20Exporter%20-%20Export%20Any%20AI%20Chat%20Instantly.user.js | |
| // @updateURL https://update.greasyfork.org/scripts/567743/OmniChat%20Exporter%20-%20Export%20Any%20AI%20Chat%20Instantly.meta.js | |
| // ==/UserScript== | |
| /* | |
| Copyright 2026 Dℝ∃wX | |
| Licensed under the Apache License, Version 2.0 (the "License"); | |
| you may not use this file except in compliance with the License. | |
| You may obtain a copy of the License at | |
| http://www.apache.org/licenses/LICENSE-2.0 | |
| Unless required by applicable law or agreed to in writing, software | |
| distributed under the License is distributed on an "AS IS" BASIS, | |
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| See the License for the specific language governing permissions and | |
| limitations under the License. | |
| */ | |
| /* | |
| Third-Party Libraries used by this userscript | |
| ============================================= | |
| PDF generation — pdfmake | |
| ------------------------ | |
| Used to generate PDF files directly in the browser. | |
| No chat content is sent to any external PDF service. | |
| Website: https://pdfmake.github.io/docs/ | |
| CDN: https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.9/pdfmake.min.js | |
| Virtual fonts: https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.9/vfs_fonts.js | |
| Source: https://github.com/bpampuch/pdfmake | |
| License: MIT | |
| Language detection — franc-min | |
| ------------------------------ | |
| Used to detect the primary language of exported chat text locally. | |
| Source: https://github.com/wooorm/franc/tree/main/packages/franc-min | |
| License: MIT | |
| Font resources | |
| -------------- | |
| Noto fonts may be downloaded on demand to ensure full script coverage during PDF export. | |
| Fonts are fetched from upstream open-source repositories only when a matching script is detected. | |
| Sources: | |
| - https://github.com/notofonts | |
| - https://github.com/google/fonts | |
| Licenses: | |
| - SIL Open Font License 1.1 | |
| - Apache License 2.0 (depending on the font family) | |
| */ | |
| (function () { | |
| 'use strict'; | |
| const host = location.hostname; | |
| const platform = detectPlatform(host); | |
| if (!platform) { | |
| return; | |
| } | |
| const STYLE_ID = 'omni-exporter-style'; | |
| const EXPORT_BUTTON_CLASS = 'omni-exporter-btn'; | |
| const SHARE_BUTTON_SELECTOR = | |
| 'button[data-testid="copy-turn-action-button"], button[data-testid="share-chat-button"], [aria-label="Partager"], [aria-label="Share"]'; | |
| const TURN_SELECTOR = '[data-testid^="conversation-turn"]'; | |
| const HEADER_ACTIONS_SELECTOR = '#conversation-header-actions'; | |
| const HEADER_EXPORT_ATTR = 'data-omni-export-header'; | |
| const EXPORT_SCOPE_ATTR = 'data-omni-scope'; | |
| const GROK_SHARE_BUTTON_SELECTOR = | |
| 'button[aria-label*="lien de partage"], button[aria-label*="share link"], button[aria-label*="share"], button[aria-label*="partager"]'; | |
| const GROK_EXPORT_ATTR = 'data-omni-export-grok'; | |
| const GROK_HEADER_SELECTOR = '.absolute.flex.flex-row.items-center.gap-0\\.5.ms-auto.end-3'; | |
| const GROK_THREAD_EXPORT_ATTR = 'data-omni-export-grok-thread'; | |
| const GROK_THREAD_EXPORT_CLASS = | |
| `inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium leading-[normal] ` + | |
| `cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-60 disabled:cursor-not-allowed transition-colors duration-100 ` + | |
| `[&_svg]:shrink-0 select-none hover:bg-button-ghost-hover hover:text-fg-primary disabled:hover:bg-transparent border border-transparent rounded-full overflow-hidden ` + | |
| `h-10 w-10 p-2 text-fg-primary`; | |
| const GEMINI_ACTIONS_SELECTOR = '.actions-container-v2'; | |
| const GEMINI_CONVERSATION_SELECTOR = '.conversation-container'; | |
| const GEMINI_TURN_EXPORT_ATTR = 'data-omni-export-gemini-turn'; | |
| const GEMINI_TURN_NATIVE_ATTR = 'data-omni-gemini-native-turn'; | |
| const GEMINI_TURN_HOST_ATTR = 'data-omni-gemini-turn-host'; | |
| const GEMINI_SHARE_BUTTON_SELECTOR = | |
| 'button[data-test-id="share-and-export-menu-button"], button[data-test-id="share-button"], button[aria-label*="Partager et exporter"], button[aria-label*="Share and export"], button[aria-label*="Partager la conversation"], button[aria-label*="Share conversation"]'; | |
| const GEMINI_MENU_BUTTON_SELECTOR = | |
| 'button[data-test-id="more-menu-button"], button[data-test-id="conversation-actions-menu-icon-button"]'; | |
| const GEMINI_HEADER_SELECTOR = '.buttons-container.share'; | |
| const GEMINI_THREAD_EXPORT_ATTR = 'data-omni-export-gemini-thread'; | |
| const GEMINI_THREAD_NATIVE_ATTR = 'data-omni-gemini-native-thread'; | |
| const CLAUDE_HEADER_SELECTOR = '[data-testid="wiggle-controls-actions"]'; | |
| const CLAUDE_SHARE_SELECTOR = '[data-testid="wiggle-controls-actions-share"]'; | |
| const CLAUDE_THREAD_EXPORT_ATTR = 'data-omni-export-claude-thread'; | |
| const CLAUDE_ACTIONS_SELECTOR = '[role="group"][aria-label="Message actions"]'; | |
| const CLAUDE_COPY_SELECTOR = '[data-testid="action-bar-copy"], button[aria-label="Copy"]'; | |
| const CLAUDE_TURN_EXPORT_ATTR = 'data-omni-export-claude-turn'; | |
| const DEEPSEEK_ACTIONS_SELECTOR = 'div.ds-flex._0a3d93b'; | |
| const DEEPSEEK_GROUP_SELECTOR = 'div.ds-flex._965abe9'; | |
| const DEEPSEEK_ROLE_BUTTON_SELECTOR = '[role="button"]'; | |
| const DEEPSEEK_TURN_BUTTON_CLASSNAME = | |
| 'db183363 ds-icon-button ds-icon-button--m ds-icon-button--sizing-container'; | |
| const DEEPSEEK_THREAD_BUTTON_CLASSNAME = | |
| '_57370c5 _5dedc1e ds-icon-button ds-icon-button--l ds-icon-button--sizing-container'; | |
| const DEEPSEEK_EXPORT_ATTR = 'data-omni-export-deepseek'; | |
| const DEEPSEEK_THREAD_BUTTON_SELECTOR = | |
| 'div._57370c5._5dedc1e.ds-icon-button.ds-icon-button--l.ds-icon-button--sizing-container[role="button"]'; | |
| const DEEPSEEK_THREAD_EXPORT_ATTR = 'data-omni-export-deepseek-thread'; | |
| const MENU_CLASS = 'omni-exporter-menu'; | |
| const MENU_ITEM_CLASS = 'omni-exporter-menu-item'; | |
| const MENU_OPEN_CLASS = 'omni-exporter-menu-open'; | |
| const STATUS_DURATION_MS = 1400; | |
| const PDF_EXPORT_LOADER_ID = 'omni-exporter-pdf-loader'; | |
| const PDF_EXPORT_LOADER_STAGE_ATTR = 'data-omni-pdf-stage'; | |
| const PDF_LANGUAGE_DETECTOR_URL = 'https://esm.sh/franc-min@6.2.0/es2022/franc-min.bundle.mjs'; | |
| const PDF_LANGUAGE_SAMPLE_LIMIT = 180; | |
| const PDF_LANGUAGE_SAMPLE_LENGTH = 1600; | |
| const PDF_LANGUAGE_MIN_LENGTH = 24; | |
| const PDF_ENABLE_EMOJI_FONT = true; | |
| const PDF_EMOJI_FONT_FAMILY = 'OpenMojiBlack'; | |
| const PDF_EMOJI_FONT_FILE = 'OpenMoji-black-glyf.ttf'; | |
| const PDF_CODE_DEFAULT_TEXT_COLOR = '#f8fafc'; | |
| const NON_EXPORTABLE_NODE_SELECTOR = | |
| 'button, svg, [role="button"], script, style, .omni-exporter-btn, [data-test-id="action-bar-copy"], ' + | |
| '.cdk-visually-hidden, .visually-hidden, .sr-only, [hidden]'; | |
| const PDF_EMOJI_FONT_URLS = [ | |
| 'https://raw.githubusercontent.com/hfg-gmuend/openmoji/master/font/OpenMoji-black-glyf/OpenMoji-black-glyf.ttf' | |
| ]; | |
| const PDF_SCRIPT_FONT_SPECS = { | |
| symbolsText: { | |
| family: 'NotoSansSymbols', | |
| file: 'NotoSansSymbols-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansSymbols/NotoSansSymbols-Regular.ttf' | |
| ] | |
| }, | |
| latinExtended: { | |
| family: 'NotoSansExtended', | |
| file: 'NotoSans-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSans/NotoSans-Regular.ttf' | |
| ] | |
| }, | |
| greek: { | |
| family: 'NotoSansGreek', | |
| file: 'NotoSans-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSans/NotoSans-Regular.ttf' | |
| ] | |
| }, | |
| cyrillic: { | |
| family: 'NotoSansCyrillic', | |
| file: 'NotoSans-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSans/NotoSans-Regular.ttf' | |
| ] | |
| }, | |
| chinese: { | |
| family: 'NotoSansSC', | |
| file: 'NotoSansCJKsc-Regular.otf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-cjk/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf' | |
| ] | |
| }, | |
| japanese: { | |
| family: 'NotoSansJP', | |
| file: 'NotoSansCJKjp-Regular.otf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-cjk/main/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf' | |
| ] | |
| }, | |
| korean: { | |
| family: 'NotoSansKR', | |
| file: 'NotoSansCJKkr-Regular.otf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-cjk/main/Sans/OTF/Korean/NotoSansCJKkr-Regular.otf' | |
| ] | |
| }, | |
| arabic: { | |
| family: 'NotoSansArabic', | |
| file: 'NotoSansArabic-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansArabic/NotoSansArabic-Regular.ttf' | |
| ] | |
| }, | |
| devanagari: { | |
| family: 'Hind', | |
| file: 'Hind-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/google/fonts/main/ofl/hind/Hind-Regular.ttf' | |
| ] | |
| }, | |
| bengali: { | |
| family: 'NotoSansBengali', | |
| file: 'NotoSansBengali-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansBengali/NotoSansBengali-Regular.ttf' | |
| ] | |
| }, | |
| gurmukhi: { | |
| family: 'NotoSansGurmukhi', | |
| file: 'NotoSansGurmukhi-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansGurmukhi/NotoSansGurmukhi-Regular.ttf' | |
| ] | |
| }, | |
| gujarati: { | |
| family: 'NotoSansGujarati', | |
| file: 'NotoSansGujarati-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansGujarati/NotoSansGujarati-Regular.ttf' | |
| ] | |
| }, | |
| odia: { | |
| family: 'NotoSansOriya', | |
| file: 'NotoSansOriya-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansOriya/NotoSansOriya-Regular.ttf' | |
| ] | |
| }, | |
| tamil: { | |
| family: 'NotoSansTamil', | |
| file: 'NotoSansTamil-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansTamil/NotoSansTamil-Regular.ttf' | |
| ] | |
| }, | |
| telugu: { | |
| family: 'Mandali', | |
| file: 'Mandali-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/google/fonts/main/ofl/mandali/Mandali-Regular.ttf' | |
| ] | |
| }, | |
| kannada: { | |
| family: 'NotoSansKannada', | |
| file: 'NotoSansKannada-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansKannada/NotoSansKannada-Regular.ttf' | |
| ] | |
| }, | |
| malayalam: { | |
| family: 'NotoSansMalayalam', | |
| file: 'NotoSansMalayalam-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansMalayalam/NotoSansMalayalam-Regular.ttf' | |
| ] | |
| }, | |
| sinhala: { | |
| family: 'NotoSansSinhala', | |
| file: 'NotoSansSinhala-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansSinhala/NotoSansSinhala-Regular.ttf' | |
| ] | |
| }, | |
| thai: { | |
| family: 'NotoSansThai', | |
| file: 'NotoSansThai-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansThai/NotoSansThai-Regular.ttf' | |
| ] | |
| }, | |
| lao: { | |
| family: 'NotoSansLao', | |
| file: 'NotoSansLao-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansLao/NotoSansLao-Regular.ttf' | |
| ] | |
| }, | |
| khmer: { | |
| family: 'NotoSansKhmer', | |
| file: 'NotoSansKhmer-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansKhmer/NotoSansKhmer-Regular.ttf' | |
| ] | |
| }, | |
| myanmar: { | |
| family: 'NotoSansMyanmar', | |
| file: 'NotoSansMyanmar-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansMyanmar/NotoSansMyanmar-Regular.ttf' | |
| ] | |
| }, | |
| hebrew: { | |
| family: 'NotoSansHebrew', | |
| file: 'NotoSansHebrew-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansHebrew/NotoSansHebrew-Regular.ttf' | |
| ] | |
| }, | |
| armenian: { | |
| family: 'NotoSansArmenian', | |
| file: 'NotoSansArmenian-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansArmenian/NotoSansArmenian-Regular.ttf' | |
| ] | |
| }, | |
| georgian: { | |
| family: 'NotoSansGeorgian', | |
| file: 'NotoSansGeorgian-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansGeorgian/NotoSansGeorgian-Regular.ttf' | |
| ] | |
| }, | |
| ethiopic: { | |
| family: 'NotoSansEthiopic', | |
| file: 'NotoSansEthiopic-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansEthiopic/NotoSansEthiopic-Regular.ttf' | |
| ] | |
| }, | |
| egyptianHieroglyphs: { | |
| family: 'NotoSansEgyptianHieroglyphs', | |
| file: 'NotoSansEgyptianHieroglyphs-Regular.ttf', | |
| urls: [ | |
| 'https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansEgyptianHieroglyphs/NotoSansEgyptianHieroglyphs-Regular.ttf' | |
| ] | |
| } | |
| }; | |
| const PDF_SCRIPT_DETECTION_PATTERNS = { | |
| symbolsText: /[\u2190-\u21FF\u2300-\u23FF\u2460-\u24FF\u2600-\u27BF\u2900-\u297F\u2B00-\u2BFF\u3000-\u303D\u3200-\u32FF\u{1F100}-\u{1F2FF}]/u, | |
| latin: /[A-Za-z\u00C0-\u024F]/u, | |
| latinExtended: /[\u0100-\u024F\u1E00-\u1EFF\u2C60-\u2C7F\uA720-\uA7FF\uAB30-\uAB6F]/u, | |
| chinese: /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u{20000}-\u{2EBEF}\u{30000}-\u{323AF}]/u, | |
| japanese: /[\u3040-\u309F\u30A0-\u30FF\u31F0-\u31FF]/u, | |
| korean: /[\u1100-\u11FF\u3130-\u318F\uAC00-\uD7AF]/u, | |
| arabic: /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/u, | |
| devanagari: /[\u0900-\u097F\uA8E0-\uA8FF]/u, | |
| bengali: /[\u0980-\u09FF]/u, | |
| gurmukhi: /[\u0A00-\u0A7F]/u, | |
| gujarati: /[\u0A80-\u0AFF]/u, | |
| odia: /[\u0B00-\u0B7F]/u, | |
| tamil: /[\u0B80-\u0BFF]/u, | |
| telugu: /[\u0C00-\u0C7F]/u, | |
| kannada: /[\u0C80-\u0CFF]/u, | |
| malayalam: /[\u0D00-\u0D7F]/u, | |
| sinhala: /[\u0D80-\u0DFF]/u, | |
| thai: /[\u0E00-\u0E7F]/u, | |
| lao: /[\u0E80-\u0EFF]/u, | |
| myanmar: /[\u1000-\u109F\uA9E0-\uA9FF\uAA60-\uAA7F]/u, | |
| georgian: /[\u10A0-\u10FF\u1C90-\u1CBF\u2D00-\u2D2F]/u, | |
| ethiopic: /[\u1200-\u137F\u1380-\u139F\u2D80-\u2DDF\uAB00-\uAB2F]/u, | |
| khmer: /[\u1780-\u17FF\u19E0-\u19FF]/u, | |
| armenian: /[\u0530-\u058F\uFB13-\uFB17]/u, | |
| hebrew: /[\u0590-\u05FF\uFB1D-\uFB4F]/u, | |
| egyptianHieroglyphs: /[\u{13000}-\u{1345F}]/u, | |
| greek: /[\u0370-\u03FF\u1F00-\u1FFF]/u, | |
| cyrillic: /[\u0400-\u04FF\u0500-\u052F\u2DE0-\u2DFF\uA640-\uA69F]/u | |
| }; | |
| const PDF_DIRECT_SCRIPT_SCAN_ORDER = [ | |
| 'symbolsText', | |
| 'latinExtended', | |
| 'arabic', | |
| 'devanagari', | |
| 'bengali', | |
| 'gurmukhi', | |
| 'gujarati', | |
| 'odia', | |
| 'tamil', | |
| 'telugu', | |
| 'kannada', | |
| 'malayalam', | |
| 'sinhala', | |
| 'thai', | |
| 'lao', | |
| 'myanmar', | |
| 'khmer', | |
| 'hebrew', | |
| 'armenian', | |
| 'georgian', | |
| 'ethiopic', | |
| 'egyptianHieroglyphs', | |
| 'greek', | |
| 'cyrillic' | |
| ]; | |
| const PDF_SCRIPT_RESOURCE_LABELS = { | |
| symbolsText: 'Symbols font', | |
| latinExtended: 'Extended Latin font', | |
| chinese: 'Chinese font', | |
| japanese: 'Japanese font', | |
| korean: 'Korean font', | |
| arabic: 'Arabic font', | |
| devanagari: 'Devanagari font', | |
| bengali: 'Bengali font', | |
| gurmukhi: 'Gurmukhi font', | |
| gujarati: 'Gujarati font', | |
| odia: 'Odia font', | |
| tamil: 'Tamil font', | |
| telugu: 'Telugu font', | |
| kannada: 'Kannada font', | |
| malayalam: 'Malayalam font', | |
| sinhala: 'Sinhala font', | |
| thai: 'Thai font', | |
| lao: 'Lao font', | |
| myanmar: 'Myanmar font', | |
| khmer: 'Khmer font', | |
| hebrew: 'Hebrew font', | |
| armenian: 'Armenian font', | |
| georgian: 'Georgian font', | |
| ethiopic: 'Amharic / Ethiopic font', | |
| egyptianHieroglyphs: 'Egyptian hieroglyph font', | |
| greek: 'Greek font', | |
| cyrillic: 'Cyrillic font', | |
| emoji: 'Emoji / symbols font' | |
| }; | |
| const PDF_SCRIPT_FALLBACK_LANGUAGE_MAP = { | |
| chinese: 'zh', | |
| japanese: 'ja', | |
| korean: 'ko', | |
| arabic: 'ar', | |
| devanagari: 'hi', | |
| bengali: 'bn', | |
| gurmukhi: 'pa', | |
| gujarati: 'gu', | |
| odia: 'or', | |
| tamil: 'ta', | |
| telugu: 'te', | |
| kannada: 'kn', | |
| malayalam: 'ml', | |
| sinhala: 'si', | |
| thai: 'th', | |
| lao: 'lo', | |
| myanmar: 'my', | |
| khmer: 'km', | |
| hebrew: 'he', | |
| armenian: 'hy', | |
| georgian: 'ka', | |
| ethiopic: 'am', | |
| greek: 'el', | |
| cyrillic: 'ru' | |
| }; | |
| const PDF_SCRIPT_FALLBACK_PRIORITY = [ | |
| 'japanese', | |
| 'korean', | |
| 'chinese', | |
| 'arabic', | |
| 'devanagari', | |
| 'bengali', | |
| 'gurmukhi', | |
| 'gujarati', | |
| 'odia', | |
| 'tamil', | |
| 'telugu', | |
| 'kannada', | |
| 'malayalam', | |
| 'sinhala', | |
| 'thai', | |
| 'lao', | |
| 'myanmar', | |
| 'khmer', | |
| 'hebrew', | |
| 'armenian', | |
| 'georgian', | |
| 'ethiopic', | |
| 'greek', | |
| 'cyrillic', | |
| 'latin' | |
| ]; | |
| const PDF_SCRIPT_FONT_RETRY_ORDER = [ | |
| 'arabic', | |
| 'devanagari', | |
| 'bengali', | |
| 'gurmukhi', | |
| 'gujarati', | |
| 'odia', | |
| 'tamil', | |
| 'telugu', | |
| 'kannada', | |
| 'malayalam', | |
| 'sinhala', | |
| 'thai', | |
| 'lao', | |
| 'myanmar', | |
| 'khmer', | |
| 'hebrew', | |
| 'ethiopic', | |
| 'armenian', | |
| 'georgian', | |
| 'japanese', | |
| 'korean', | |
| 'chinese', | |
| 'cyrillic' | |
| ]; | |
| const PDF_HAN_PATTERN = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u{20000}-\u{2EBEF}\u{30000}-\u{323AF}]/u; | |
| const PDF_CJK_SYMBOL_PATTERN = /[\u3000-\u303F\uFF00-\uFFEF]/u; | |
| const PDF_SYMBOL_TEXT_PATTERN = /[\u2190-\u21FF\u2300-\u23FF\u2460-\u24FF\u2600-\u27BF\u2900-\u297F\u2B00-\u2BFF\u3000-\u303D\u3200-\u32FF\u{1F100}-\u{1F2FF}]/u; | |
| const PDF_EMOJI_STYLE_PATTERN = /(?:\p{Extended_Pictographic}|\p{Regional_Indicator}|\p{Emoji_Modifier}|\u{FE0F}|\u{20E3}|\u{200D}|[\u{1F100}-\u{1F2FF}])/u; | |
| const PDF_SAFE_SEGMENTATION_SCRIPTS = []; | |
| const PDF_LATIN_COMBINING_MARK_PATTERN = /[\u0300-\u036F\u1AB0-\u1AFF\u1DC0-\u1DFF]/u; | |
| const PDF_TOKEN_BREAK_PATTERN = /[\s\u0000-\u002F\u003A-\u0040\u005B-\u0060\u007B-\u007E\u2000-\u206F\u3000-\u303F]/u; | |
| const PDF_LANGUAGE_CODE_MAP = { | |
| amh: 'am', | |
| afr: 'af', | |
| ara: 'ar', | |
| arb: 'ar', | |
| arm: 'hy', | |
| ben: 'bn', | |
| bul: 'bg', | |
| cat: 'ca', | |
| ces: 'cs', | |
| cmn: 'zh', | |
| cym: 'cy', | |
| dan: 'da', | |
| deu: 'de', | |
| ell: 'el', | |
| eng: 'en', | |
| est: 'et', | |
| fas: 'fa', | |
| fin: 'fi', | |
| fra: 'fr', | |
| guj: 'gu', | |
| heb: 'he', | |
| hin: 'hi', | |
| hrv: 'hr', | |
| hun: 'hu', | |
| ind: 'id', | |
| ita: 'it', | |
| jav: 'jv', | |
| jpn: 'ja', | |
| kat: 'ka', | |
| kan: 'kn', | |
| khm: 'km', | |
| kor: 'ko', | |
| lao: 'lo', | |
| lit: 'lt', | |
| lvs: 'lv', | |
| mal: 'ml', | |
| mar: 'mr', | |
| mya: 'my', | |
| mon: 'mn', | |
| nld: 'nl', | |
| nep: 'ne', | |
| nor: 'no', | |
| npi: 'ne', | |
| ori: 'or', | |
| ory: 'or', | |
| pan: 'pa', | |
| pol: 'pl', | |
| por: 'pt', | |
| ron: 'ro', | |
| rus: 'ru', | |
| slk: 'sk', | |
| slv: 'sl', | |
| spa: 'es', | |
| srp: 'sr', | |
| sin: 'si', | |
| swe: 'sv', | |
| tam: 'ta', | |
| tel: 'te', | |
| tha: 'th', | |
| tur: 'tr', | |
| ukr: 'uk', | |
| urd: 'ur', | |
| vie: 'vi', | |
| khk: 'mn', | |
| hye: 'hy', | |
| zho: 'zh' | |
| }; | |
| let iconCounter = 0; | |
| let activeMenu = null; | |
| let activeMenuButton = null; | |
| let menuCleanup = null; | |
| let pdfMakeRef = null; | |
| let activePdfFontContext = null; | |
| let activePdfEmojiFontFamily = ''; | |
| let languageDetectorModulePromise = null; | |
| let pdfFontBase64Promises = Object.create(null); | |
| let emojiRegexRef = null; | |
| let graphemeSegmenterRef = null; | |
| const styles = ` | |
| .${EXPORT_BUTTON_CLASS}:not(.omni-exporter-grok) { | |
| pointer-events: auto; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| height: 2rem; | |
| width: 2rem; | |
| padding: 0; | |
| border-radius: 0.5rem; | |
| border: none; | |
| color: var(--text-token-text-secondary, #8e8ea0); | |
| cursor: pointer; | |
| } | |
| .${EXPORT_BUTTON_CLASS}:not(.omni-exporter-grok) svg { | |
| width: 18px; | |
| height: 18px; | |
| display: block; | |
| color: currentColor; | |
| } | |
| .${EXPORT_BUTTON_CLASS}[data-omni-status="success"] { | |
| background: rgba(34, 197, 94, 0.15); | |
| } | |
| .${EXPORT_BUTTON_CLASS}[data-omni-status="error"] { | |
| background: rgba(239, 68, 68, 0.15); | |
| } | |
| .${EXPORT_BUTTON_CLASS}[disabled] { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| } | |
| .${EXPORT_BUTTON_CLASS}.omni-exporter-grok { | |
| pointer-events: auto; | |
| color: inherit; | |
| background: transparent; | |
| border: none; | |
| padding: 0; | |
| width: auto; | |
| height: auto; | |
| border-radius: inherit; | |
| } | |
| .omni-exporter-grok.${EXPORT_BUTTON_CLASS} svg { | |
| width: 18px; | |
| height: 18px; | |
| display: block; | |
| color: currentColor; | |
| } | |
| .${MENU_CLASS} { | |
| position: absolute; | |
| z-index: 9999; | |
| min-width: 140px; | |
| padding: 6px; | |
| border-radius: 12px; | |
| border: 1px solid rgba(148, 163, 184, 0.2); | |
| background: rgba(15, 23, 42, 0.94); | |
| box-shadow: 0 12px 24px rgba(15, 23, 42, 0.35); | |
| backdrop-filter: blur(70px); | |
| opacity: 0; | |
| transform: translateY(-4px) scale(0.98); | |
| transition: opacity 0.12s ease, transform 0.12s ease; | |
| font-family: inherit; | |
| } | |
| .${MENU_OPEN_CLASS} { | |
| opacity: 1; | |
| transform: translateY(0) scale(1); | |
| } | |
| .${MENU_ITEM_CLASS} { | |
| width: 100%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 8px; | |
| padding: 8px 10px; | |
| border-radius: 8px; | |
| border: none; | |
| background: transparent; | |
| color: #e2e8f0; | |
| font-size: 12px; | |
| font-weight: 600; | |
| letter-spacing: 0.2px; | |
| cursor: pointer; | |
| } | |
| .${MENU_ITEM_CLASS}:hover { | |
| background: linear-gradient(135deg, rgba(56, 189, 248, 0.2) 0%, rgba(37, 99, 235, 0.2) 100%); | |
| color: #f8fafc; | |
| } | |
| .${MENU_ITEM_CLASS}:focus-visible { | |
| outline: 2px solid rgba(56, 189, 248, 0.5); | |
| outline-offset: 2px; | |
| } | |
| .omni-exporter-btn:not(.omni-exporter-grok) { color: #f3f3f3 !important; } | |
| .omni-exporter-btn[data-omni-scope="turn"] svg path { | |
| stroke-width: 1.6; | |
| } | |
| .omni-exporter-btn[data-omni-scope="thread"]:hover { | |
| background-color: var(--token-bg-secondary); | |
| border-radius: 8px; | |
| } | |
| .omni-exporter-btn[data-omni-export-claude-turn] { | |
| color: #9c9a92 !important; | |
| } | |
| .omni-exporter-btn[data-omni-export-claude-turn]:hover { | |
| color: #faf9f5 !important; | |
| background-color: rgba(156, 154, 146, 0.15); | |
| } | |
| .omni-exporter-pdf-loader { | |
| position: fixed; | |
| left: max(12px, env(safe-area-inset-left)); | |
| right: max(12px, env(safe-area-inset-right)); | |
| bottom: max(12px, env(safe-area-inset-bottom)); | |
| z-index: 2147483646; | |
| display: flex; | |
| justify-content: flex-end; | |
| pointer-events: none; | |
| } | |
| .omni-exporter-pdf-loader-panel { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| width: min(100%, 390px); | |
| max-width: calc(100vw - 24px); | |
| padding: 16px 18px; | |
| border-radius: 16px; | |
| border: 1px solid rgba(148, 163, 184, 0.24); | |
| background: rgba(15, 23, 42, 0.96); | |
| color: #f8fafc; | |
| box-shadow: 0 18px 48px rgba(15, 23, 42, 0.34); | |
| backdrop-filter: blur(14px); | |
| pointer-events: auto; | |
| box-sizing: border-box; | |
| } | |
| .omni-exporter-pdf-loader-head { | |
| display: flex; | |
| align-items: flex-start; | |
| gap: 14px; | |
| width: 100%; | |
| } | |
| .omni-exporter-pdf-loader-spinner { | |
| flex: 0 0 auto; | |
| width: 20px; | |
| height: 20px; | |
| border-radius: 999px; | |
| border: 2px solid rgba(248, 250, 252, 0.2); | |
| border-top-color: #38bdf8; | |
| animation: omni-exporter-loader-spin 0.85s linear infinite; | |
| } | |
| .omni-exporter-pdf-loader-copy { | |
| min-width: 0; | |
| flex: 1 1 auto; | |
| } | |
| .omni-exporter-pdf-loader-close { | |
| flex: 0 0 auto; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 28px; | |
| height: 28px; | |
| padding: 0; | |
| margin: -4px -6px 0 0; | |
| border: none; | |
| border-radius: 999px; | |
| background: transparent; | |
| color: #94a3b8; | |
| cursor: pointer; | |
| transition: background-color 0.12s ease, color 0.12s ease; | |
| } | |
| .omni-exporter-pdf-loader-close:hover { | |
| background: rgba(148, 163, 184, 0.12); | |
| color: #f8fafc; | |
| } | |
| .omni-exporter-pdf-loader-close:focus-visible { | |
| outline: 2px solid rgba(56, 189, 248, 0.55); | |
| outline-offset: 2px; | |
| } | |
| .omni-exporter-pdf-loader-title { | |
| font-size: 13px; | |
| font-weight: 700; | |
| letter-spacing: 0.01em; | |
| color: #f8fafc; | |
| } | |
| .omni-exporter-pdf-loader-stage { | |
| margin-top: 3px; | |
| font-size: 12px; | |
| line-height: 1.35; | |
| color: #94a3b8; | |
| } | |
| .omni-exporter-pdf-loader-detail { | |
| margin-top: 6px; | |
| font-size: 11px; | |
| line-height: 1.45; | |
| color: #cbd5e1; | |
| } | |
| .omni-exporter-pdf-loader-progress { | |
| width: 100%; | |
| } | |
| .omni-exporter-pdf-loader-progress-track { | |
| position: relative; | |
| overflow: hidden; | |
| width: 100%; | |
| height: 8px; | |
| border-radius: 999px; | |
| background: rgba(148, 163, 184, 0.16); | |
| } | |
| .omni-exporter-pdf-loader-progress-bar { | |
| height: 100%; | |
| width: 0%; | |
| border-radius: inherit; | |
| background: linear-gradient(90deg, #38bdf8 0%, #22c55e 100%); | |
| transition: width 0.18s ease; | |
| } | |
| .omni-exporter-pdf-loader-progress-track[data-indeterminate="true"] .omni-exporter-pdf-loader-progress-bar { | |
| width: 38%; | |
| animation: omni-exporter-loader-progress 1.1s ease-in-out infinite; | |
| } | |
| .omni-exporter-pdf-loader-progress-meta { | |
| margin-top: 6px; | |
| font-size: 11px; | |
| color: #94a3b8; | |
| } | |
| @media (max-width: 640px) { | |
| .omni-exporter-pdf-loader-panel { | |
| width: 100%; | |
| padding: 14px 14px 13px; | |
| border-radius: 14px; | |
| } | |
| .omni-exporter-pdf-loader-head { | |
| gap: 12px; | |
| } | |
| } | |
| @keyframes omni-exporter-loader-spin { | |
| to { | |
| transform: rotate(360deg); | |
| } | |
| } | |
| @keyframes omni-exporter-loader-progress { | |
| 0% { | |
| transform: translateX(-115%); | |
| } | |
| 100% { | |
| transform: translateX(315%); | |
| } | |
| } | |
| `; | |
| function buildExportIcon() { | |
| return ` | |
| <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" class="icon"> | |
| <path d="M12 3v10m0 0 4-4m-4 4-4-4M4 15v4h16v-4" | |
| fill="none" stroke="currentColor" stroke-width="2" | |
| stroke-linecap="round" stroke-linejoin="round"></path> | |
| </svg>`; | |
| } | |
| function buildExportIconElement() { | |
| const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); | |
| svg.setAttribute('viewBox', '0 0 24 24'); | |
| svg.setAttribute('width', '18'); | |
| svg.setAttribute('height', '18'); | |
| svg.setAttribute('aria-hidden', 'true'); | |
| svg.setAttribute('class', 'icon'); | |
| const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); | |
| path.setAttribute('d', 'M12 3v10m0 0 4-4m-4 4-4-4M4 15v4h16v-4'); | |
| path.setAttribute('fill', 'none'); | |
| path.setAttribute('stroke', 'currentColor'); | |
| path.setAttribute('stroke-width', '2'); | |
| path.setAttribute('stroke-linecap', 'round'); | |
| path.setAttribute('stroke-linejoin', 'round'); | |
| svg.appendChild(path); | |
| return svg; | |
| } | |
| let scanQueued = null; | |
| const pendingScanRoots = new Set(); | |
| function injectStyles() { | |
| if (document.getElementById(STYLE_ID)) { | |
| return; | |
| } | |
| const style = document.createElement('style'); | |
| style.id = STYLE_ID; | |
| style.textContent = styles; | |
| document.head.appendChild(style); | |
| } | |
| function showPdfExportLoader(stage) { | |
| injectStyles(); | |
| let loader = document.getElementById(PDF_EXPORT_LOADER_ID); | |
| if (!loader) { | |
| loader = document.createElement('div'); | |
| loader.id = PDF_EXPORT_LOADER_ID; | |
| loader.className = 'omni-exporter-pdf-loader'; | |
| const panel = document.createElement('div'); | |
| panel.className = 'omni-exporter-pdf-loader-panel'; | |
| panel.setAttribute('role', 'status'); | |
| panel.setAttribute('aria-live', 'polite'); | |
| panel.setAttribute('aria-busy', 'true'); | |
| const head = document.createElement('div'); | |
| head.className = 'omni-exporter-pdf-loader-head'; | |
| const spinner = document.createElement('div'); | |
| spinner.className = 'omni-exporter-pdf-loader-spinner'; | |
| spinner.setAttribute('aria-hidden', 'true'); | |
| const copy = document.createElement('div'); | |
| copy.className = 'omni-exporter-pdf-loader-copy'; | |
| const closeButton = document.createElement('button'); | |
| closeButton.className = 'omni-exporter-pdf-loader-close'; | |
| closeButton.type = 'button'; | |
| closeButton.setAttribute('aria-label', 'Close export loader'); | |
| closeButton.textContent = '×'; | |
| closeButton.addEventListener('click', () => { | |
| loader.remove(); | |
| }); | |
| const title = document.createElement('div'); | |
| title.className = 'omni-exporter-pdf-loader-title'; | |
| title.textContent = 'Preparing PDF export...'; | |
| const stageNode = document.createElement('div'); | |
| stageNode.className = 'omni-exporter-pdf-loader-stage'; | |
| const detailNode = document.createElement('div'); | |
| detailNode.className = 'omni-exporter-pdf-loader-detail'; | |
| const progress = document.createElement('div'); | |
| progress.className = 'omni-exporter-pdf-loader-progress'; | |
| const track = document.createElement('div'); | |
| track.className = 'omni-exporter-pdf-loader-progress-track'; | |
| track.setAttribute('data-indeterminate', 'true'); | |
| const bar = document.createElement('div'); | |
| bar.className = 'omni-exporter-pdf-loader-progress-bar'; | |
| const meta = document.createElement('div'); | |
| meta.className = 'omni-exporter-pdf-loader-progress-meta'; | |
| track.appendChild(bar); | |
| progress.appendChild(track); | |
| progress.appendChild(meta); | |
| copy.appendChild(title); | |
| copy.appendChild(stageNode); | |
| copy.appendChild(detailNode); | |
| head.appendChild(spinner); | |
| head.appendChild(copy); | |
| head.appendChild(closeButton); | |
| panel.appendChild(head); | |
| panel.appendChild(progress); | |
| loader.appendChild(panel); | |
| document.body.appendChild(loader); | |
| } | |
| updatePdfExportLoader(stage || 'Scanning chat content...'); | |
| return loader; | |
| } | |
| function updatePdfExportLoader(state) { | |
| const loader = document.getElementById(PDF_EXPORT_LOADER_ID); | |
| if (!loader) { | |
| return; | |
| } | |
| const next = normalizePdfExportLoaderState(state); | |
| const stageNode = loader.querySelector('.omni-exporter-pdf-loader-stage'); | |
| const detailNode = loader.querySelector('.omni-exporter-pdf-loader-detail'); | |
| const progressTrack = loader.querySelector('.omni-exporter-pdf-loader-progress-track'); | |
| const progressBar = loader.querySelector('.omni-exporter-pdf-loader-progress-bar'); | |
| const progressMeta = loader.querySelector('.omni-exporter-pdf-loader-progress-meta'); | |
| if (stageNode) { | |
| stageNode.textContent = next.stage; | |
| } | |
| if (detailNode) { | |
| detailNode.textContent = next.detail; | |
| detailNode.style.display = next.detail ? '' : 'none'; | |
| } | |
| if (progressTrack) { | |
| progressTrack.setAttribute('data-indeterminate', next.indeterminate ? 'true' : 'false'); | |
| } | |
| if (progressBar) { | |
| progressBar.style.width = next.indeterminate ? '38%' : `${Math.round(clampPdfLoaderProgress(next.progress) * 100)}%`; | |
| progressBar.style.transform = next.indeterminate ? '' : 'translateX(0)'; | |
| } | |
| if (progressMeta) { | |
| progressMeta.textContent = next.progressText; | |
| progressMeta.style.display = next.progressText ? '' : 'none'; | |
| } | |
| loader.setAttribute(PDF_EXPORT_LOADER_STAGE_ATTR, next.stage); | |
| } | |
| function hidePdfExportLoader() { | |
| const loader = document.getElementById(PDF_EXPORT_LOADER_ID); | |
| if (loader) { | |
| loader.remove(); | |
| } | |
| } | |
| function normalizePdfExportLoaderState(state) { | |
| if (typeof state === 'string') { | |
| return { | |
| stage: ensureString(state || 'Preparing PDF export...'), | |
| detail: '', | |
| progress: 0, | |
| progressText: '', | |
| indeterminate: true | |
| }; | |
| } | |
| const next = state && typeof state === 'object' ? state : {}; | |
| return { | |
| stage: ensureString(next.stage || 'Preparing PDF export...'), | |
| detail: ensureString(next.detail), | |
| progress: clampPdfLoaderProgress(next.progress), | |
| progressText: ensureString(next.progressText), | |
| indeterminate: next.indeterminate !== false | |
| }; | |
| } | |
| function clampPdfLoaderProgress(value) { | |
| const numeric = Number(value); | |
| if (!Number.isFinite(numeric)) { | |
| return 0; | |
| } | |
| if (numeric < 0) { | |
| return 0; | |
| } | |
| if (numeric > 1) { | |
| return 1; | |
| } | |
| return numeric; | |
| } | |
| function waitForNextPaint() { | |
| return new Promise((resolve) => { | |
| if (typeof window.requestAnimationFrame !== 'function') { | |
| window.setTimeout(resolve, 0); | |
| return; | |
| } | |
| window.requestAnimationFrame(() => { | |
| window.setTimeout(resolve, 0); | |
| }); | |
| }); | |
| } | |
| function queueScanForNode(node) { | |
| if (!node) { | |
| return; | |
| } | |
| if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { | |
| node.childNodes.forEach(queueScanForNode); | |
| return; | |
| } | |
| if (node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.DOCUMENT_NODE) { | |
| return; | |
| } | |
| const scanRoot = resolveScanRoot(node); | |
| if (!scanRoot) { | |
| return; | |
| } | |
| pendingScanRoots.add(scanRoot); | |
| if (scanQueued) { | |
| return; | |
| } | |
| scanQueued = setTimeout(() => { | |
| scanQueued = null; | |
| const roots = Array.from(pendingScanRoots); | |
| pendingScanRoots.clear(); | |
| if (roots.length > 50) { | |
| attachButtons(document); | |
| } else { | |
| roots.forEach((root) => attachButtons(root)); | |
| } | |
| }, 80); | |
| } | |
| function resolveScanRoot(node) { | |
| const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; | |
| if (!element) { | |
| return null; | |
| } | |
| if (platform === 'chatgpt') { | |
| return element.closest(TURN_SELECTOR) || | |
| element.closest(HEADER_ACTIONS_SELECTOR) || | |
| element; | |
| } | |
| if (platform === 'deepseek') { | |
| return element.closest(DEEPSEEK_ACTIONS_SELECTOR) || | |
| element.closest(DEEPSEEK_THREAD_BUTTON_SELECTOR) || | |
| element; | |
| } | |
| if (platform === 'grok') { | |
| return element.closest(GROK_HEADER_SELECTOR) || | |
| element.closest(GROK_SHARE_BUTTON_SELECTOR) || | |
| element; | |
| } | |
| if (platform === 'gemini') { | |
| return element.closest(GEMINI_ACTIONS_SELECTOR) || element; | |
| } | |
| if (platform === 'claude') { | |
| return getClaudeActionContainer(element) || | |
| element.closest(CLAUDE_HEADER_SELECTOR) || | |
| element; | |
| } | |
| return element; | |
| } | |
| function attachButtons(root) { | |
| if (platform === 'chatgpt') { | |
| attachChatGptButtons(root); | |
| attachHeaderButton(root); | |
| } | |
| if (platform === 'grok') { | |
| attachGrokButtons(root); | |
| attachGrokThreadButton(root); | |
| } | |
| if (platform === 'gemini') { | |
| attachGeminiThreadButton(root); | |
| attachGeminiTurnButtons(root); | |
| } | |
| if (platform === 'claude') { | |
| attachClaudeThreadButton(root); | |
| attachClaudeTurnButtons(root); | |
| } | |
| if (platform === 'deepseek') { | |
| attachDeepSeekButtons(root); | |
| } | |
| } | |
| function attachChatGptButtons(root) { | |
| const scope = root || document; | |
| let turns = []; | |
| if (scope.matches && scope.matches(TURN_SELECTOR)) { | |
| turns.push(scope); | |
| } | |
| if (scope.querySelectorAll) { | |
| const found = scope.querySelectorAll(TURN_SELECTOR); | |
| if (found.length > 0) { | |
| turns = Array.from(found); | |
| } | |
| } | |
| turns.forEach((turn) => { | |
| if (turn.hasAttribute('data-omni-processed')) { | |
| return; | |
| } | |
| const role = getTurnRole(turn); | |
| if (role !== 'assistant') { | |
| return; | |
| } | |
| const shareButton = turn.querySelector(SHARE_BUTTON_SELECTOR); | |
| if (!shareButton) { | |
| return; | |
| } | |
| if (turn.querySelector(`.${EXPORT_BUTTON_CLASS}`)) { | |
| turn.setAttribute('data-omni-processed', 'true'); | |
| return; | |
| } | |
| const button = buildExportButton('turn'); | |
| shareButton.insertAdjacentElement('afterend', button); | |
| turn.setAttribute('data-omni-processed', 'true'); | |
| }); | |
| } | |
| function attachGrokButtons(root) { | |
| const shareButtons = []; | |
| if (root.matches && root.matches(GROK_SHARE_BUTTON_SELECTOR)) { | |
| shareButtons.push(root); | |
| } | |
| shareButtons.push(...root.querySelectorAll(GROK_SHARE_BUTTON_SELECTOR)); | |
| shareButtons.forEach((shareButton) => { | |
| if (shareButton.closest(GROK_HEADER_SELECTOR)) { | |
| return; | |
| } | |
| const actionBar = shareButton.parentElement; | |
| if (!actionBar || actionBar.querySelector(`[${GROK_EXPORT_ATTR}]`)) { | |
| return; | |
| } | |
| const button = buildGrokNativeTurnButton(shareButton); | |
| button.setAttribute(GROK_EXPORT_ATTR, 'true'); | |
| shareButton.insertAdjacentElement('afterend', button); | |
| }); | |
| } | |
| function buildGrokNativeTurnButton(referenceButton) { | |
| const button = referenceButton.cloneNode(true); | |
| button.removeAttribute('id'); | |
| button.removeAttribute('aria-controls'); | |
| button.removeAttribute('aria-describedby'); | |
| button.removeAttribute('data-radix-collection-item'); | |
| button.setAttribute('type', 'button'); | |
| button.setAttribute('aria-label', 'Exporter ce chat'); | |
| button.setAttribute(EXPORT_SCOPE_ATTR, 'turn'); | |
| button.setAttribute('aria-haspopup', 'menu'); | |
| button.setAttribute('aria-expanded', 'false'); | |
| button.setAttribute('data-state', 'closed'); | |
| button.innerHTML = `<span style="opacity: 1; transform: none;">${buildExportIcon()}</span>`; | |
| button.addEventListener('click', (event) => { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| toggleMenu(button); | |
| }); | |
| return button; | |
| } | |
| function attachGeminiTurnButtons(root) { | |
| const scope = root || document; | |
| if (!scope.querySelectorAll) { | |
| return; | |
| } | |
| const containers = []; | |
| if (scope.matches && scope.matches(GEMINI_ACTIONS_SELECTOR)) { | |
| containers.push(scope); | |
| } | |
| containers.push(...scope.querySelectorAll(GEMINI_ACTIONS_SELECTOR)); | |
| containers.forEach((container) => { | |
| const shareButton = container.querySelector(GEMINI_SHARE_BUTTON_SELECTOR); | |
| const referenceButton = shareButton || getGeminiTurnReferenceButton(container); | |
| if (!referenceButton) { | |
| return; | |
| } | |
| const shareAnchor = shareButton ? shareButton.closest('.tooltip-anchor-point') : null; | |
| const moreMenuBlock = getGeminiTurnMenuBlock(container); | |
| const referenceAnchor = referenceButton.closest('.tooltip-anchor-point'); | |
| const existingButton = container.querySelector(`[${GEMINI_TURN_EXPORT_ATTR}]`); | |
| const existingNative = existingButton && existingButton.hasAttribute(GEMINI_TURN_NATIVE_ATTR); | |
| if (existingButton && !existingNative) { | |
| const staleWrapper = existingButton.closest('.tooltip-anchor-point'); | |
| if (staleWrapper && staleWrapper !== shareAnchor && staleWrapper.childElementCount === 1) { | |
| staleWrapper.remove(); | |
| } else { | |
| existingButton.remove(); | |
| } | |
| } | |
| if (!existingNative) { | |
| const nativeButton = buildGeminiNativeTurnButton(referenceButton); | |
| if (shareAnchor) { | |
| const wrapper = shareAnchor.cloneNode(false); | |
| wrapper.setAttribute(GEMINI_TURN_HOST_ATTR, 'true'); | |
| wrapper.appendChild(nativeButton); | |
| shareAnchor.insertAdjacentElement('afterend', wrapper); | |
| } else if (moreMenuBlock) { | |
| if (moreMenuBlock.matches('button')) { | |
| moreMenuBlock.insertAdjacentElement('beforebegin', nativeButton); | |
| } else { | |
| const wrapper = moreMenuBlock.cloneNode(false); | |
| wrapper.setAttribute(GEMINI_TURN_HOST_ATTR, 'true'); | |
| wrapper.appendChild(nativeButton); | |
| moreMenuBlock.insertAdjacentElement('beforebegin', wrapper); | |
| } | |
| } else if (referenceAnchor) { | |
| const wrapper = referenceAnchor.cloneNode(false); | |
| wrapper.setAttribute(GEMINI_TURN_HOST_ATTR, 'true'); | |
| wrapper.appendChild(nativeButton); | |
| referenceAnchor.insertAdjacentElement('beforebegin', wrapper); | |
| } else { | |
| referenceButton.insertAdjacentElement('beforebegin', nativeButton); | |
| } | |
| return; | |
| } | |
| const existingWrapper = existingButton.closest(`[${GEMINI_TURN_HOST_ATTR}]`) || | |
| existingButton.closest('.tooltip-anchor-point'); | |
| if (shareAnchor) { | |
| const correctTarget = shareAnchor.nextElementSibling; | |
| if (existingWrapper) { | |
| if (correctTarget !== existingWrapper) { | |
| shareAnchor.insertAdjacentElement('afterend', existingWrapper); | |
| } | |
| } else if (correctTarget !== existingButton) { | |
| shareAnchor.insertAdjacentElement('afterend', existingButton); | |
| } | |
| } else if (moreMenuBlock) { | |
| let nodeToPlace = existingWrapper || existingButton; | |
| if (!existingWrapper && !moreMenuBlock.matches('button')) { | |
| const wrapper = moreMenuBlock.cloneNode(false); | |
| wrapper.setAttribute(GEMINI_TURN_HOST_ATTR, 'true'); | |
| wrapper.appendChild(existingButton); | |
| nodeToPlace = wrapper; | |
| } | |
| if (moreMenuBlock.previousElementSibling !== nodeToPlace) { | |
| moreMenuBlock.insertAdjacentElement('beforebegin', nodeToPlace); | |
| } | |
| } else if (shareButton && shareButton.nextElementSibling !== existingButton) { | |
| shareButton.insertAdjacentElement('afterend', existingButton); | |
| } else if (referenceAnchor) { | |
| const correctTarget = referenceAnchor.previousElementSibling; | |
| if (existingWrapper) { | |
| if (correctTarget !== existingWrapper) { | |
| referenceAnchor.insertAdjacentElement('beforebegin', existingWrapper); | |
| } | |
| } else if (correctTarget !== existingButton) { | |
| referenceAnchor.insertAdjacentElement('beforebegin', existingButton); | |
| } | |
| } else if (referenceButton.previousElementSibling !== existingButton) { | |
| referenceButton.insertAdjacentElement('beforebegin', existingButton); | |
| } | |
| }); | |
| } | |
| function getGeminiTurnMenuBlock(container) { | |
| if (!container || !container.querySelector) { | |
| return null; | |
| } | |
| const moreButton = container.querySelector(GEMINI_MENU_BUTTON_SELECTOR); | |
| if (!moreButton) { | |
| return null; | |
| } | |
| const menuWrapper = moreButton.closest('.menu-button-wrapper'); | |
| if (menuWrapper && menuWrapper.parentElement && menuWrapper.parentElement !== container) { | |
| return menuWrapper.parentElement; | |
| } | |
| return menuWrapper || moreButton; | |
| } | |
| function getGeminiTurnReferenceButton(container) { | |
| if (!container || !container.querySelectorAll) { | |
| return null; | |
| } | |
| const buttons = Array.from(container.querySelectorAll('button')); | |
| if (!buttons.length) { | |
| return null; | |
| } | |
| const menuButton = buttons.find((button) => { | |
| const testId = button.getAttribute('data-test-id'); | |
| return testId === 'more-menu-button' || testId === 'conversation-actions-menu-icon-button'; | |
| }); | |
| if (menuButton) { | |
| return menuButton; | |
| } | |
| return buttons.find((button) => !button.hasAttribute(GEMINI_TURN_EXPORT_ATTR)) || null; | |
| } | |
| function buildGeminiNativeTurnButton(referenceButton) { | |
| const button = referenceButton.cloneNode(true); | |
| button.removeAttribute('data-test-id'); | |
| button.removeAttribute('aria-describedby'); | |
| button.removeAttribute('cdk-describedby-host'); | |
| button.removeAttribute('jslog'); | |
| button.setAttribute('type', 'button'); | |
| button.setAttribute('aria-label', 'Exporter ce chat'); | |
| button.setAttribute(EXPORT_SCOPE_ATTR, 'turn'); | |
| button.setAttribute(GEMINI_TURN_EXPORT_ATTR, 'true'); | |
| button.setAttribute(GEMINI_TURN_NATIVE_ATTR, 'true'); | |
| button.setAttribute('aria-haspopup', 'menu'); | |
| button.setAttribute('aria-expanded', 'false'); | |
| const matIcon = button.querySelector('mat-icon'); | |
| if (matIcon) { | |
| while (matIcon.firstChild) { | |
| matIcon.removeChild(matIcon.firstChild); | |
| } | |
| matIcon.removeAttribute('fonticon'); | |
| matIcon.removeAttribute('data-mat-icon-name'); | |
| matIcon.appendChild(buildExportIconElement()); | |
| } else { | |
| button.appendChild(buildExportIconElement()); | |
| } | |
| button.addEventListener('click', (event) => { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| toggleMenu(button); | |
| }); | |
| return button; | |
| } | |
| function attachGeminiThreadButton(root) { | |
| const shareContainer = document.querySelector(GEMINI_HEADER_SELECTOR); | |
| if (!shareContainer) { | |
| return; | |
| } | |
| const shareButton = shareContainer.querySelector(GEMINI_SHARE_BUTTON_SELECTOR); | |
| if (!shareButton) { | |
| return; | |
| } | |
| const existingButton = shareContainer.querySelector(`[${GEMINI_THREAD_EXPORT_ATTR}]`); | |
| const existingNative = existingButton && existingButton.hasAttribute(GEMINI_THREAD_NATIVE_ATTR); | |
| if (existingButton && !existingNative) { | |
| existingButton.remove(); | |
| } | |
| if (!existingNative) { | |
| const button = buildGeminiNativeThreadButton(shareButton); | |
| shareContainer.insertBefore(button, shareButton); | |
| return; | |
| } | |
| if (existingButton && existingButton.nextElementSibling !== shareButton) { | |
| shareContainer.insertBefore(existingButton, shareButton); | |
| } | |
| } | |
| function buildGeminiNativeThreadButton(referenceButton) { | |
| const button = referenceButton.cloneNode(true); | |
| button.removeAttribute('data-test-id'); | |
| button.removeAttribute('aria-describedby'); | |
| button.removeAttribute('cdk-describedby-host'); | |
| button.removeAttribute('jslog'); | |
| button.setAttribute('type', 'button'); | |
| button.setAttribute('aria-label', 'Exporter la conversation'); | |
| button.setAttribute(EXPORT_SCOPE_ATTR, 'thread'); | |
| button.setAttribute(GEMINI_THREAD_EXPORT_ATTR, 'true'); | |
| button.setAttribute(GEMINI_THREAD_NATIVE_ATTR, 'true'); | |
| button.setAttribute('aria-haspopup', 'menu'); | |
| button.setAttribute('aria-expanded', 'false'); | |
| const matIcon = button.querySelector('mat-icon'); | |
| if (matIcon) { | |
| while (matIcon.firstChild) { | |
| matIcon.removeChild(matIcon.firstChild); | |
| } | |
| matIcon.removeAttribute('fonticon'); | |
| matIcon.removeAttribute('data-mat-icon-name'); | |
| matIcon.appendChild(buildExportIconElement()); | |
| } else { | |
| button.appendChild(buildExportIconElement()); | |
| } | |
| button.addEventListener('click', (event) => { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| toggleMenu(button); | |
| }); | |
| return button; | |
| } | |
| function attachGrokThreadButton(root) { | |
| if (document.querySelector(`[${GROK_THREAD_EXPORT_ATTR}]`)) { | |
| return; | |
| } | |
| const header = document.querySelector(GROK_HEADER_SELECTOR); | |
| if (!header) { | |
| return; | |
| } | |
| const shareButton = header.querySelector('button[aria-label="Créer un lien de partage"], button[aria-label="Partager"]'); | |
| const plusButton = header.querySelector('button[aria-label="Plus"]'); | |
| const referenceButton = shareButton || plusButton || header.querySelector('button'); | |
| if (!referenceButton) { | |
| return; | |
| } | |
| const button = buildExportButton('thread', { | |
| overrideClassName: GROK_THREAD_EXPORT_CLASS | |
| }); | |
| button.setAttribute(GROK_THREAD_EXPORT_ATTR, 'true'); | |
| if (shareButton) { | |
| shareButton.insertAdjacentElement('beforebegin', button); | |
| } else { | |
| header.insertBefore(button, header.firstChild); | |
| } | |
| } | |
| function attachClaudeThreadButton(root) { | |
| const scope = root || document; | |
| const header = scope.matches && scope.matches(CLAUDE_HEADER_SELECTOR) | |
| ? scope | |
| : scope.querySelector(CLAUDE_HEADER_SELECTOR); | |
| if (!header || header.querySelector(`[${CLAUDE_THREAD_EXPORT_ATTR}]`)) { | |
| return; | |
| } | |
| const shareButton = header.querySelector(CLAUDE_SHARE_SELECTOR); | |
| const referenceButton = shareButton || header.querySelector('button'); | |
| const button = buildExportButton('thread', { | |
| extraClasses: referenceButton ? referenceButton.className : '' | |
| }); | |
| button.setAttribute(CLAUDE_THREAD_EXPORT_ATTR, 'true'); | |
| if (shareButton) { | |
| shareButton.insertAdjacentElement('beforebegin', button); | |
| } else { | |
| header.insertAdjacentElement('afterbegin', button); | |
| } | |
| } | |
| function attachClaudeTurnButtons(root) { | |
| const scope = root || document; | |
| const containers = collectClaudeActionContainers(scope); | |
| containers.forEach((container) => { | |
| if (container.querySelector(`[${CLAUDE_TURN_EXPORT_ATTR}]`)) { | |
| return; | |
| } | |
| const messageNode = findClaudeMessageForActions(container); | |
| const isUserContext = messageNode && | |
| (messageNode.matches('[data-testid="user-message"]') || | |
| messageNode.querySelector('[data-testid="user-message"]')); | |
| if (!messageNode || isUserContext) { | |
| return; | |
| } | |
| const copyButton = container.querySelector(CLAUDE_COPY_SELECTOR) || | |
| container.querySelector('button:last-of-type') || | |
| container.querySelector('button'); | |
| if (!copyButton) { | |
| return; | |
| } | |
| const button = buildExportButton('turn', { | |
| extraClasses: copyButton.className | |
| }); | |
| button.setAttribute('aria-label', 'Export'); | |
| button.setAttribute(CLAUDE_TURN_EXPORT_ATTR, 'true'); | |
| const wrapper = document.createElement('div'); | |
| wrapper.className = 'w-fit'; | |
| wrapper.setAttribute('data-state', 'closed'); | |
| attachClaudeTooltip(wrapper, button, 'Export'); | |
| wrapper.appendChild(button); | |
| const parentWrapper = copyButton.closest('.w-fit'); | |
| if (parentWrapper) { | |
| parentWrapper.insertAdjacentElement('afterend', wrapper); | |
| } else { | |
| copyButton.insertAdjacentElement('afterend', wrapper); | |
| } | |
| }); | |
| } | |
| function attachClaudeTooltip(wrapper, button, label) { | |
| let tooltipEl = null; | |
| let tooltipId = null; | |
| const show = () => { | |
| if (tooltipEl) { | |
| return; | |
| } | |
| tooltipId = `radix_${Math.random().toString(36).slice(2, 9)}`; | |
| wrapper.setAttribute('data-state', 'delayed-open'); | |
| wrapper.setAttribute('aria-describedby', tooltipId); | |
| const popperWrapper = document.createElement('div'); | |
| popperWrapper.setAttribute('data-radix-popper-content-wrapper', ''); | |
| popperWrapper.style.position = 'fixed'; | |
| popperWrapper.style.left = '0px'; | |
| popperWrapper.style.top = '0px'; | |
| popperWrapper.style.transform = 'translate(0px, -200%)'; | |
| popperWrapper.style.minWidth = 'max-content'; | |
| popperWrapper.style.willChange = 'transform'; | |
| popperWrapper.style.zIndex = '50'; | |
| const tooltip = document.createElement('div'); | |
| tooltip.setAttribute('data-side', 'top'); | |
| tooltip.setAttribute('data-align', 'center'); | |
| tooltip.setAttribute('data-state', 'delayed-open'); | |
| tooltip.className = 'px-2 py-1 text-xs font-normal font-ui leading-tight rounded-md shadow-md text-always-white bg-always-black/80 backdrop-blur break-words z-tooltip max-w-[13rem] text-pretty [*:disabled_&]:hidden'; | |
| tooltip.textContent = label; | |
| const sr = document.createElement('span'); | |
| sr.id = tooltipId; | |
| sr.setAttribute('role', 'tooltip'); | |
| sr.style.position = 'absolute'; | |
| sr.style.border = '0px'; | |
| sr.style.width = '1px'; | |
| sr.style.height = '1px'; | |
| sr.style.padding = '0px'; | |
| sr.style.margin = '-1px'; | |
| sr.style.overflow = 'hidden'; | |
| sr.style.clip = 'rect(0px, 0px, 0px, 0px)'; | |
| sr.style.whiteSpace = 'nowrap'; | |
| sr.style.overflowWrap = 'normal'; | |
| sr.textContent = label; | |
| tooltip.appendChild(sr); | |
| popperWrapper.appendChild(tooltip); | |
| document.body.appendChild(popperWrapper); | |
| tooltipEl = popperWrapper; | |
| const rect = button.getBoundingClientRect(); | |
| const x = rect.left + rect.width / 2; | |
| const y = rect.bottom; | |
| popperWrapper.style.transform = `translate(${Math.round(x)}px, ${Math.round(y + 8)}px) translate(-50%, 0)`; | |
| }; | |
| const hide = () => { | |
| wrapper.setAttribute('data-state', 'closed'); | |
| wrapper.removeAttribute('aria-describedby'); | |
| if (tooltipEl) { | |
| tooltipEl.remove(); | |
| tooltipEl = null; | |
| tooltipId = null; | |
| } | |
| }; | |
| wrapper.addEventListener('mouseenter', show); | |
| wrapper.addEventListener('mouseleave', hide); | |
| button.addEventListener('focus', show); | |
| button.addEventListener('blur', hide); | |
| } | |
| function attachDeepSeekButtons(root) { | |
| attachDeepSeekTurnButtons(root); | |
| attachDeepSeekThreadButton(root); | |
| } | |
| function isDeepSeekActionBar(container) { | |
| const group = container.querySelector(DEEPSEEK_GROUP_SELECTOR); | |
| if (!group) { | |
| return false; | |
| } | |
| const roleButtons = group.querySelectorAll(DEEPSEEK_ROLE_BUTTON_SELECTOR); | |
| if (roleButtons.length < 3) { | |
| return false; | |
| } | |
| const spacer = container.querySelector('div[style*="flex: 1 1 0%"]'); | |
| if (!spacer) { | |
| return false; | |
| } | |
| return true; | |
| } | |
| function attachDeepSeekTurnButtons(root) { | |
| const containers = []; | |
| if (root.matches && root.matches(DEEPSEEK_ACTIONS_SELECTOR)) { | |
| containers.push(root); | |
| } | |
| containers.push(...root.querySelectorAll(DEEPSEEK_ACTIONS_SELECTOR)); | |
| containers.forEach((container) => { | |
| if (container.querySelector(`[${DEEPSEEK_EXPORT_ATTR}]`)) { | |
| return; | |
| } | |
| if (!isDeepSeekActionBar(container)) { | |
| return; | |
| } | |
| const group = container.querySelector(DEEPSEEK_GROUP_SELECTOR) || container; | |
| const button = buildExportButton('turn', { | |
| overrideClassName: DEEPSEEK_TURN_BUTTON_CLASSNAME, | |
| useDeepSeekMarkup: true, | |
| tagName: 'div' | |
| }); | |
| button.setAttribute(DEEPSEEK_EXPORT_ATTR, 'true'); | |
| group.appendChild(button); | |
| }); | |
| } | |
| function attachDeepSeekThreadButton(root) { | |
| if (document.querySelector(`[${DEEPSEEK_THREAD_EXPORT_ATTR}]`)) { | |
| return; | |
| } | |
| const candidates = []; | |
| if (root.matches && root.matches(DEEPSEEK_THREAD_BUTTON_SELECTOR)) { | |
| candidates.push(root); | |
| } | |
| candidates.push(...root.querySelectorAll(DEEPSEEK_THREAD_BUTTON_SELECTOR)); | |
| for (const targetButton of candidates) { | |
| const parent = targetButton.parentElement; | |
| if (!parent || parent.querySelector(`[${DEEPSEEK_THREAD_EXPORT_ATTR}]`)) { | |
| continue; | |
| } | |
| const button = buildExportButton('thread', { | |
| overrideClassName: DEEPSEEK_THREAD_BUTTON_CLASSNAME, | |
| useDeepSeekMarkup: true, | |
| tagName: 'div' | |
| }); | |
| button.setAttribute(DEEPSEEK_THREAD_EXPORT_ATTR, 'true'); | |
| button.style.marginRight = '50px'; | |
| targetButton.insertAdjacentElement('beforebegin', button); | |
| break; | |
| } | |
| } | |
| function attachHeaderButton(root) { | |
| const scope = root || document; | |
| const headerActions = scope.matches && scope.matches(HEADER_ACTIONS_SELECTOR) | |
| ? scope | |
| : scope.querySelector(HEADER_ACTIONS_SELECTOR); | |
| if (!headerActions || headerActions.querySelector(`[${HEADER_EXPORT_ATTR}]`)) { | |
| return; | |
| } | |
| const shareButton = headerActions.querySelector('[data-testid="share-chat-button"]'); | |
| const button = buildExportButton('thread'); | |
| button.setAttribute(HEADER_EXPORT_ATTR, 'true'); | |
| if (platform === 'chatgpt') { | |
| button.className = | |
| 'text-token-text-primary no-draggable hover:bg-token-surface-hover keyboard-focused:bg-token-surface-hover ' + | |
| 'touch:h-10 touch:w-10 flex h-9 w-9 items-center justify-center rounded-lg ' + | |
| 'focus:outline-none disabled:opacity-50'; | |
| } | |
| if (platform === 'chatgpt') { | |
| button.setAttribute('data-state', 'closed'); | |
| button.setAttribute('data-radix-tooltip-trigger', ''); | |
| } | |
| if (shareButton) { | |
| shareButton.insertAdjacentElement('beforebegin', button); | |
| } else { | |
| headerActions.insertAdjacentElement('afterbegin', button); | |
| } | |
| } | |
| function buildExportButton(scope, options) { | |
| const tagName = (options && options.tagName) || 'button'; | |
| const button = document.createElement(tagName); | |
| if (tagName === 'button') { | |
| button.type = 'button'; | |
| button.setAttribute('aria-label', 'Exporter ce chat'); | |
| } else { | |
| button.setAttribute('role', 'button'); | |
| button.setAttribute('tabindex', '0'); | |
| button.setAttribute('aria-label', 'Exporter ce chat'); | |
| } | |
| button.setAttribute(EXPORT_SCOPE_ATTR, scope); | |
| if (platform === 'chatgpt' && tagName === 'button') { | |
| button.setAttribute('data-state', 'closed'); | |
| button.setAttribute('data-radix-tooltip-trigger', ''); | |
| } | |
| const extraClasses = options && options.extraClasses ? ` ${options.extraClasses}` : ''; | |
| const overrideClassName = options && options.overrideClassName ? options.overrideClassName : ''; | |
| button.className = overrideClassName || `${EXPORT_BUTTON_CLASS}${extraClasses}`; | |
| if (platform === 'chatgpt' && scope === 'turn' && !overrideClassName) { | |
| button.className = `${EXPORT_BUTTON_CLASS} text-token-text-secondary hover:bg-token-bg-secondary rounded-lg`; | |
| button.innerHTML = ` | |
| <span class="flex items-center justify-center touch:w-10 h-8 w-8"> | |
| ${buildExportIcon()} | |
| </span> | |
| `; | |
| } else if (options && options.useDeepSeekMarkup) { | |
| button.innerHTML = ` | |
| <div class="ds-icon-button__hover-bg"></div> | |
| <div class="ds-icon">${platform === 'gemini' ? '' : buildExportIcon()}</div> | |
| <div class="ds-focus-ring"></div>`; | |
| if (platform === 'gemini') { | |
| const iconDiv = button.querySelector('.ds-icon'); | |
| if (iconDiv) { | |
| iconDiv.appendChild(buildExportIconElement()); | |
| } | |
| } | |
| } else { | |
| if (platform === 'gemini') { | |
| button.appendChild(buildExportIconElement()); | |
| } else if (platform === 'grok' && scope === 'turn') { | |
| button.innerHTML = `<span style="opacity: 1; transform: none;">${buildExportIcon()}</span>`; | |
| } else { | |
| button.innerHTML = buildExportIcon(); | |
| } | |
| } | |
| button.addEventListener('click', (event) => { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| toggleMenu(button); | |
| }); | |
| if (tagName !== 'button') { | |
| button.addEventListener('keydown', (event) => { | |
| if (event.key === 'Enter' || event.key === ' ') { | |
| event.preventDefault(); | |
| button.click(); | |
| } | |
| }); | |
| } | |
| return button; | |
| } | |
| function syncButtonSize(button, reference) { | |
| if (!reference) { | |
| return; | |
| } | |
| const rect = reference.getBoundingClientRect(); | |
| if (rect.width) { | |
| button.style.width = `${rect.width}px`; | |
| } | |
| if (rect.height) { | |
| button.style.height = `${rect.height}px`; | |
| } | |
| } | |
| function toggleMenu(button) { | |
| if (activeMenu && activeMenuButton === button) { | |
| closeMenu(); | |
| return; | |
| } | |
| closeMenu(); | |
| openMenu(button); | |
| } | |
| function openMenu(button) { | |
| const menu = document.createElement('div'); | |
| menu.className = MENU_CLASS; | |
| menu.setAttribute('role', 'menu'); | |
| if (platform === 'gemini') { | |
| appendMenuItemsDOM(menu); | |
| } else { | |
| menu.innerHTML = buildMenuItems(); | |
| } | |
| document.body.appendChild(menu); | |
| positionMenu(menu, button); | |
| requestAnimationFrame(() => { | |
| menu.classList.add(MENU_OPEN_CLASS); | |
| }); | |
| menu.addEventListener('click', (event) => { | |
| const item = event.target.closest(`.${MENU_ITEM_CLASS}`); | |
| if (!item) { | |
| return; | |
| } | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| const format = item.getAttribute('data-format'); | |
| closeMenu(); | |
| handleExportFormat(format, button); | |
| }); | |
| const onPointerDown = (event) => { | |
| if (menu.contains(event.target) || event.target === button) { | |
| return; | |
| } | |
| closeMenu(); | |
| }; | |
| const onKeyDown = (event) => { | |
| if (event.key === 'Escape') { | |
| closeMenu(); | |
| } | |
| }; | |
| const onReposition = () => { | |
| if (activeMenu && activeMenuButton) { | |
| positionMenu(activeMenu, activeMenuButton); | |
| } | |
| }; | |
| document.addEventListener('mousedown', onPointerDown, true); | |
| document.addEventListener('keydown', onKeyDown, true); | |
| window.addEventListener('resize', onReposition, true); | |
| window.addEventListener('scroll', onReposition, true); | |
| menuCleanup = () => { | |
| document.removeEventListener('mousedown', onPointerDown, true); | |
| document.removeEventListener('keydown', onKeyDown, true); | |
| window.removeEventListener('resize', onReposition, true); | |
| window.removeEventListener('scroll', onReposition, true); | |
| }; | |
| button.setAttribute('aria-expanded', 'true'); | |
| activeMenu = menu; | |
| activeMenuButton = button; | |
| } | |
| function closeMenu() { | |
| if (menuCleanup) { | |
| menuCleanup(); | |
| menuCleanup = null; | |
| } | |
| if (activeMenu) { | |
| activeMenu.remove(); | |
| activeMenu = null; | |
| } | |
| if (activeMenuButton) { | |
| activeMenuButton.setAttribute('aria-expanded', 'false'); | |
| activeMenuButton = null; | |
| } | |
| } | |
| function appendMenuItemsDOM(menu) { | |
| const formats = [ | |
| { value: 'txt', label: 'TXT' }, | |
| { value: 'pdf', label: 'PDF' }, | |
| { value: 'json', label: 'JSON' }, | |
| { value: 'md', label: 'Markdown (MD)' } | |
| ]; | |
| formats.forEach(format => { | |
| const button = document.createElement('button'); | |
| button.type = 'button'; | |
| button.className = MENU_ITEM_CLASS; | |
| button.setAttribute('data-format', format.value); | |
| button.setAttribute('role', 'menuitem'); | |
| button.textContent = format.label; | |
| menu.appendChild(button); | |
| }); | |
| } | |
| function buildMenuItems() { | |
| return [ | |
| '<button type="button" class="' + MENU_ITEM_CLASS + '" data-format="txt" role="menuitem">TXT</button>', | |
| '<button type="button" class="' + MENU_ITEM_CLASS + '" data-format="pdf" role="menuitem">PDF</button>', | |
| '<button type="button" class="' + MENU_ITEM_CLASS + '" data-format="json" role="menuitem">JSON</button>', | |
| '<button type="button" class="' + MENU_ITEM_CLASS + '" data-format="md" role="menuitem">Markdown (MD)</button>' | |
| ].join(''); | |
| } | |
| function positionMenu(menu, button) { | |
| const rect = button.getBoundingClientRect(); | |
| const padding = 8; | |
| const menuWidth = menu.offsetWidth || 160; | |
| const menuHeight = menu.offsetHeight || 180; | |
| let left = rect.left + window.scrollX; | |
| let top = rect.bottom + window.scrollY + padding; | |
| const minLeft = window.scrollX + padding; | |
| const maxLeft = window.scrollX + window.innerWidth - menuWidth - padding; | |
| if (left > maxLeft) { | |
| left = maxLeft; | |
| } | |
| if (left < minLeft) { | |
| left = minLeft; | |
| } | |
| const maxTop = window.scrollY + window.innerHeight - menuHeight - padding; | |
| if (top > maxTop) { | |
| top = rect.top + window.scrollY - menuHeight - padding; | |
| } | |
| const minTop = window.scrollY + padding; | |
| menu.style.left = `${Math.max(left, minLeft)}px`; | |
| menu.style.top = `${Math.max(top, minTop)}px`; | |
| } | |
| async function handleExportFormat(format, button) { | |
| const isPdfExport = format === 'pdf'; | |
| if (isPdfExport) { | |
| showPdfExportLoader({ | |
| stage: 'Scanning chat content...', | |
| detail: 'Collecting messages before PDF generation.', | |
| progress: 0.06, | |
| progressText: 'Step 1 of 4', | |
| indeterminate: false | |
| }); | |
| await waitForNextPaint(); | |
| } | |
| try { | |
| const scope = button.getAttribute(EXPORT_SCOPE_ATTR) || 'turn'; | |
| const anchorTurn = findAnchorTurn(button); | |
| if (scope !== 'thread' && !anchorTurn) { | |
| flashButton(button, 'Err: No message', 'error'); | |
| return; | |
| } | |
| const turns = scope === 'thread' ? getAllTurns() : getRelatedTurns(anchorTurn); | |
| let messages = collectMessagesFromTurns(turns); | |
| if (platform === 'chatgpt' && scope === 'thread') { | |
| const apiMessages = await getChatGptConversationMessages(); | |
| if (apiMessages && apiMessages.length) { | |
| messages = apiMessages; | |
| } | |
| } | |
| if (!messages.length) { | |
| flashButton(button, 'Err: 0 messages found', 'error'); | |
| console.warn('OmniChat: No messages found with selectors', turns); | |
| return; | |
| } | |
| if (format === 'pdf') { | |
| const exported = await exportPdf(messages); | |
| if (!exported) { | |
| flashButton(button, 'Export unavailable', 'error'); | |
| return; | |
| } | |
| flashButton(button, 'Export ok', 'success'); | |
| return; | |
| } | |
| if (format === 'json') { | |
| const content = buildExportJson(messages); | |
| const filename = buildExportFilename('json', scope === 'thread' ? null : anchorTurn); | |
| downloadText(content, filename, 'application/json'); | |
| flashButton(button, 'Export ok', 'success'); | |
| return; | |
| } | |
| if (format === 'txt') { | |
| const content = buildExportText(messages); | |
| const filename = buildExportFilename('txt', scope === 'thread' ? null : anchorTurn); | |
| downloadText(content, filename, 'text/plain'); | |
| flashButton(button, 'Export ok', 'success'); | |
| return; | |
| } | |
| const content = buildExportMarkdown(messages); | |
| const filename = buildExportFilename('md', scope === 'thread' ? null : anchorTurn); | |
| downloadText(content, filename, 'text/markdown'); | |
| flashButton(button, 'Export ok', 'success'); | |
| } catch (err) { | |
| console.error('OmniChat export error:', err); | |
| flashButton(button, 'Export failed', 'error'); | |
| } finally { | |
| if (isPdfExport) { | |
| hidePdfExportLoader(); | |
| } | |
| } | |
| } | |
| function findAnchorTurn(button) { | |
| if (platform === 'chatgpt') { | |
| return button.closest(TURN_SELECTOR); | |
| } | |
| if (platform === 'grok') { | |
| return findGrokAnchor(button); | |
| } | |
| if (platform === 'gemini') { | |
| return findGeminiAnchor(button); | |
| } | |
| if (platform === 'claude') { | |
| return findClaudeAnchor(button); | |
| } | |
| if (platform === 'deepseek') { | |
| return findDeepSeekAnchor(button); | |
| } | |
| return null; | |
| } | |
| function getAllTurns() { | |
| if (platform === 'chatgpt') { | |
| return getConversationTurns(); | |
| } | |
| if (platform === 'grok') { | |
| return getGrokMessageRoots(); | |
| } | |
| if (platform === 'gemini') { | |
| return getGeminiMessageRoots(); | |
| } | |
| if (platform === 'claude') { | |
| return getClaudeMessageRoots(); | |
| } | |
| if (platform === 'deepseek') { | |
| return getDeepSeekMessageRoots(); | |
| } | |
| return []; | |
| } | |
| function getConversationTurns() { | |
| return Array.from(document.querySelectorAll(TURN_SELECTOR)); | |
| } | |
| function getTurnRole(turn) { | |
| const declaredRole = turn.getAttribute('data-turn'); | |
| if (declaredRole) { | |
| return declaredRole; | |
| } | |
| const roleNode = turn.querySelector('[data-message-author-role]'); | |
| if (roleNode) { | |
| return roleNode.getAttribute('data-message-author-role'); | |
| } | |
| return inferRoleFromRoot(turn); | |
| } | |
| function findAdjacentTurn(turns, startIndex, direction, role) { | |
| const step = direction === 'prev' ? -1 : 1; | |
| for (let index = startIndex + step; index >= 0 && index < turns.length; index += step) { | |
| if (getTurnRole(turns[index]) === role) { | |
| return turns[index]; | |
| } | |
| } | |
| return null; | |
| } | |
| function getRelatedTurns(anchorTurn) { | |
| if (platform === 'gemini') { | |
| const geminiConversation = resolveGeminiConversation(anchorTurn); | |
| if (geminiConversation) { | |
| const geminiTurns = getGeminiRootsFromConversation(geminiConversation); | |
| if (geminiTurns.length) { | |
| return geminiTurns; | |
| } | |
| return [geminiConversation]; | |
| } | |
| } | |
| const turns = getAllTurns(); | |
| let resolvedAnchor = anchorTurn; | |
| let index = turns.indexOf(anchorTurn); | |
| if (index === -1 && platform === 'deepseek') { | |
| const resolved = resolveDeepSeekTurn(anchorTurn); | |
| if (resolved) { | |
| resolvedAnchor = resolved; | |
| index = turns.indexOf(resolved); | |
| } | |
| } | |
| if (index === -1) { | |
| return [resolvedAnchor]; | |
| } | |
| const role = getTurnRole(resolvedAnchor); | |
| const related = []; | |
| if (role === 'assistant') { | |
| const previousUser = findAdjacentTurn(turns, index, 'prev', 'user'); | |
| if (previousUser) { | |
| related.push(previousUser); | |
| } | |
| related.push(resolvedAnchor); | |
| } else if (role === 'user') { | |
| related.push(resolvedAnchor); | |
| const nextAssistant = findAdjacentTurn(turns, index, 'next', 'assistant'); | |
| if (nextAssistant) { | |
| related.push(nextAssistant); | |
| } | |
| } else { | |
| if (platform === 'gemini' && resolvedAnchor.matches && resolvedAnchor.matches(GEMINI_CONVERSATION_SELECTOR)) { | |
| const geminiTurns = getGeminiRootsFromConversation(resolvedAnchor); | |
| if (geminiTurns.length) { | |
| return geminiTurns; | |
| } | |
| } | |
| if (platform === 'grok' || platform === 'deepseek') { | |
| const previousTurn = turns[index - 1]; | |
| if (previousTurn) { | |
| related.push(previousTurn, resolvedAnchor); | |
| return related; | |
| } | |
| } | |
| related.push(resolvedAnchor); | |
| } | |
| return related; | |
| } | |
| function resolveGeminiConversation(anchorTurn) { | |
| if (!anchorTurn) { | |
| return null; | |
| } | |
| if (anchorTurn.matches && anchorTurn.matches(GEMINI_CONVERSATION_SELECTOR)) { | |
| return anchorTurn; | |
| } | |
| const directConversation = anchorTurn.closest && anchorTurn.closest(GEMINI_CONVERSATION_SELECTOR); | |
| if (directConversation) { | |
| return directConversation; | |
| } | |
| const byId = anchorTurn.id ? document.getElementById(anchorTurn.id) : null; | |
| if (byId && byId.matches && byId.matches(GEMINI_CONVERSATION_SELECTOR)) { | |
| return byId; | |
| } | |
| const siblingConversation = anchorTurn.previousElementSibling && | |
| anchorTurn.previousElementSibling.matches && | |
| anchorTurn.previousElementSibling.matches(GEMINI_CONVERSATION_SELECTOR) | |
| ? anchorTurn.previousElementSibling | |
| : null; | |
| if (siblingConversation) { | |
| return siblingConversation; | |
| } | |
| return null; | |
| } | |
| function resolveDeepSeekTurn(turn) { | |
| if (!turn) { | |
| return null; | |
| } | |
| if (turn.classList && turn.classList.contains('ds-message')) { | |
| return turn; | |
| } | |
| return turn.querySelector('.ds-message') || turn.closest('.ds-message'); | |
| } | |
| function collectMessagesFromTurns(turns) { | |
| if (platform === 'chatgpt') { | |
| return collectChatGptMessages(turns); | |
| } | |
| const messages = []; | |
| turns.forEach((turn) => { | |
| if (!turn || !turn.querySelectorAll) { | |
| return; | |
| } | |
| const isClaudeMessage = turn.matches && | |
| (turn.matches('[data-testid="assistant-message"]') || | |
| turn.matches('[data-testid="user-message"]') || | |
| turn.matches('.font-claude-response')); | |
| const nodes = isClaudeMessage | |
| ? [turn] | |
| : turn.querySelectorAll('[data-message-author-role], [data-testid="assistant-message"], [data-testid="user-message"]'); | |
| if (nodes.length) { | |
| nodes.forEach((node) => { | |
| const role = node.getAttribute('data-message-author-role') || | |
| (node.getAttribute('data-testid') === 'user-message' ? 'user' : | |
| node.getAttribute('data-testid') === 'assistant-message' ? 'assistant' : | |
| (node.matches && node.matches('.font-claude-response') ? 'assistant' : 'message')); | |
| const content = extractMessageContent(node); | |
| if (content && content.text) { | |
| messages.push(buildCollectedMessage(role, content)); | |
| } | |
| }); | |
| return; | |
| } | |
| const role = inferRoleFromRoot(turn) || 'message'; | |
| const content = extractMessageContentFromRoot(turn); | |
| if (content && content.text) { | |
| messages.push(buildCollectedMessage(role, content)); | |
| } | |
| }); | |
| return messages; | |
| } | |
| function collectChatGptMessages(turns) { | |
| const messages = []; | |
| const collectFromScope = (scope) => { | |
| if (!scope || !scope.querySelectorAll) { | |
| return; | |
| } | |
| const roleNodes = filterTopLevelNodes( | |
| Array.from(scope.querySelectorAll('[data-message-author-role]')) | |
| ); | |
| if (roleNodes.length) { | |
| roleNodes.forEach((node) => { | |
| const role = node.getAttribute('data-message-author-role') || | |
| inferRoleFromRoot(node) || 'message'; | |
| const content = extractMessageContent(node); | |
| if (content && content.text) { | |
| messages.push(buildCollectedMessage(role, content)); | |
| } | |
| }); | |
| return; | |
| } | |
| const contentNodes = filterTopLevelNodes( | |
| Array.from(scope.querySelectorAll('.markdown, [data-message-content], .prose, .whitespace-pre-wrap')) | |
| ); | |
| contentNodes.forEach((node) => { | |
| const roleNode = node.closest('[data-message-author-role]'); | |
| const role = roleNode ? roleNode.getAttribute('data-message-author-role') : | |
| inferRoleFromRoot(scope) || 'message'; | |
| const content = extractMessageContent(node); | |
| if (content && content.text) { | |
| messages.push(buildCollectedMessage(role, content)); | |
| } | |
| }); | |
| }; | |
| if (Array.isArray(turns)) { | |
| turns.forEach(collectFromScope); | |
| } | |
| if (messages.length && !isAssistantSparse(messages)) { | |
| return messages; | |
| } | |
| const fallback = collectChatGptMessagesFromDocument(); | |
| if (fallback.length) { | |
| return fallback; | |
| } | |
| return messages; | |
| } | |
| async function getChatGptConversationMessages() { | |
| const conversationId = getChatGptConversationId(); | |
| if (!conversationId) { | |
| return []; | |
| } | |
| const url = `${location.origin}/backend-api/conversation/${conversationId}`; | |
| let response; | |
| try { | |
| response = await fetch(url, { credentials: 'include' }); | |
| } catch (err) { | |
| console.warn('OmniChat: fetch conversation failed', err); | |
| return []; | |
| } | |
| if (!response || !response.ok) { | |
| console.warn('OmniChat: fetch conversation non-ok', response && response.status); | |
| return []; | |
| } | |
| let data; | |
| try { | |
| data = await response.json(); | |
| } catch (err) { | |
| console.warn('OmniChat: conversation JSON parse failed', err); | |
| return []; | |
| } | |
| if (!data || !data.mapping) { | |
| return []; | |
| } | |
| const mapping = data.mapping; | |
| const currentNode = data.current_node || data.currentNode || data.current_node_id; | |
| if (!currentNode || !mapping[currentNode]) { | |
| return []; | |
| } | |
| const orderedNodes = []; | |
| const visited = new Set(); | |
| let nodeId = currentNode; | |
| while (nodeId && mapping[nodeId] && !visited.has(nodeId)) { | |
| visited.add(nodeId); | |
| orderedNodes.push(mapping[nodeId]); | |
| nodeId = mapping[nodeId].parent; | |
| } | |
| orderedNodes.reverse(); | |
| const messages = []; | |
| orderedNodes.forEach((node) => { | |
| if (!node || !node.message) { | |
| return; | |
| } | |
| const author = node.message.author || {}; | |
| const role = author.role || author.name || 'message'; | |
| if (role === 'system' || role === 'tool') { | |
| return; | |
| } | |
| const content = extractChatGptMessageContent(node.message); | |
| if (content && content.text) { | |
| messages.push({ role, text: content.text, html: content.html }); | |
| } | |
| }); | |
| return messages; | |
| } | |
| function getChatGptConversationId() { | |
| const parts = location.pathname.split('/').filter(Boolean); | |
| if (!parts.length) { | |
| return null; | |
| } | |
| const last = parts[parts.length - 1]; | |
| const prev = parts.length > 1 ? parts[parts.length - 2] : ''; | |
| if (prev === 'c' && last) { | |
| return last; | |
| } | |
| if (last && last.length >= 8 && last !== 'c' && last !== 'chat') { | |
| return last; | |
| } | |
| return null; | |
| } | |
| function extractChatGptMessageContent(message) { | |
| if (!message) { | |
| return { text: '', html: '' }; | |
| } | |
| const content = message.content || message.content_parts || {}; | |
| let raw = ''; | |
| if (Array.isArray(content.parts)) { | |
| raw = content.parts.filter(Boolean).join('\n'); | |
| } else if (typeof content.text === 'string') { | |
| raw = content.text; | |
| } else if (typeof content === 'string') { | |
| raw = content; | |
| } else if (Array.isArray(message.parts)) { | |
| raw = message.parts.filter(Boolean).join('\n'); | |
| } | |
| return { text: normalizeText(raw), html: raw }; | |
| } | |
| function collectChatGptMessagesFromDocument() { | |
| const container = document.querySelector('main') || document.body; | |
| if (!container || !container.querySelectorAll) { | |
| return []; | |
| } | |
| const messages = []; | |
| const roleNodes = filterTopLevelNodes( | |
| Array.from(container.querySelectorAll('[data-message-author-role]')) | |
| ); | |
| if (!roleNodes.length) { | |
| return []; | |
| } | |
| roleNodes.forEach((node) => { | |
| const role = node.getAttribute('data-message-author-role') || | |
| inferRoleFromRoot(node) || 'message'; | |
| const content = extractMessageContent(node); | |
| if (content && content.text) { | |
| messages.push(buildCollectedMessage(role, content)); | |
| } | |
| }); | |
| return messages; | |
| } | |
| function buildCollectedMessage(role, content) { | |
| const message = { | |
| role: role, | |
| text: ensureString(content && content.text), | |
| html: ensureString(content && content.html) | |
| }; | |
| if (content && content.sourceNode && content.sourceNode.nodeType === Node.ELEMENT_NODE) { | |
| message.sourceNode = content.sourceNode; | |
| } | |
| return message; | |
| } | |
| function isAssistantSparse(messages) { | |
| let users = 0; | |
| let assistants = 0; | |
| messages.forEach((message) => { | |
| const role = String(message.role || '').toLowerCase(); | |
| if (role === 'user') { | |
| users += 1; | |
| } else if (role === 'assistant') { | |
| assistants += 1; | |
| } | |
| }); | |
| if (users === 0) { | |
| return false; | |
| } | |
| return assistants === 0 || assistants < Math.ceil(users * 0.5); | |
| } | |
| function findClaudeAnchor(button) { | |
| const direct = button.closest('[data-testid="assistant-message"], [data-testid="user-message"], .font-claude-response, article, section'); | |
| if (direct) { | |
| return direct; | |
| } | |
| const actions = getClaudeActionContainer(button); | |
| if (!actions) { | |
| return null; | |
| } | |
| return findClaudeMessageForActions(actions); | |
| } | |
| function getClaudeActionContainer(element) { | |
| if (!element || typeof element.closest !== 'function') { | |
| return null; | |
| } | |
| const labeled = element.closest(CLAUDE_ACTIONS_SELECTOR); | |
| if (labeled) { | |
| return labeled; | |
| } | |
| const copyButton = (element.matches && element.matches(CLAUDE_COPY_SELECTOR)) | |
| ? element | |
| : element.closest(CLAUDE_COPY_SELECTOR); | |
| if (!copyButton) { | |
| return null; | |
| } | |
| return copyButton.closest('[role="group"]') || copyButton.parentElement || null; | |
| } | |
| function collectClaudeActionContainers(scope) { | |
| const containers = []; | |
| const seen = new Set(); | |
| const pushContainer = (candidate) => { | |
| if (!candidate || seen.has(candidate)) { | |
| return; | |
| } | |
| seen.add(candidate); | |
| containers.push(candidate); | |
| }; | |
| if (scope && scope.nodeType === Node.ELEMENT_NODE) { | |
| pushContainer(getClaudeActionContainer(scope)); | |
| if (scope.matches && scope.matches(CLAUDE_ACTIONS_SELECTOR)) { | |
| pushContainer(scope); | |
| } | |
| } | |
| if (scope && typeof scope.querySelectorAll === 'function') { | |
| scope.querySelectorAll(CLAUDE_ACTIONS_SELECTOR).forEach(pushContainer); | |
| scope.querySelectorAll(CLAUDE_COPY_SELECTOR).forEach((button) => { | |
| pushContainer(getClaudeActionContainer(button)); | |
| }); | |
| } | |
| return containers; | |
| } | |
| function getClaudeMessageRoots() { | |
| const container = document.querySelector('main') || document.body; | |
| const selectors = [ | |
| '[data-testid="assistant-message"]', | |
| '[data-testid="user-message"]', | |
| '.font-claude-response' | |
| ]; | |
| let roots = Array.from(container.querySelectorAll(selectors.join(','))); | |
| if (!roots.length) { | |
| roots = Array.from(container.querySelectorAll('.font-claude-response, article, section')); | |
| } | |
| roots = roots.filter((node, index, self) => { | |
| const isNested = self.some((other, otherIndex) => otherIndex !== index && other.contains(node)); | |
| return !isNested; | |
| }); | |
| roots.sort((a, b) => { | |
| if (a === b) { | |
| return 0; | |
| } | |
| const position = a.compareDocumentPosition(b); | |
| if (position & Node.DOCUMENT_POSITION_FOLLOWING) { | |
| return -1; | |
| } | |
| if (position & Node.DOCUMENT_POSITION_PRECEDING) { | |
| return 1; | |
| } | |
| return 0; | |
| }); | |
| return roots; | |
| } | |
| function findClaudeMessageForActions(actions) { | |
| let sibling = actions.previousElementSibling; | |
| while (sibling) { | |
| if (sibling.matches('[data-testid="assistant-message"], [data-testid="user-message"], .font-claude-response, article, section')) { | |
| return sibling; | |
| } | |
| const nested = sibling.querySelector('[data-testid="assistant-message"], [data-testid="user-message"], .font-claude-response, article, section'); | |
| if (nested) { | |
| return nested; | |
| } | |
| sibling = sibling.previousElementSibling; | |
| } | |
| const group = actions.closest('div.group'); | |
| if (group) { | |
| const user = group.querySelector('[data-testid="user-message"]'); | |
| if (user) { | |
| return user; | |
| } | |
| const assistant = group.querySelector('.font-claude-response'); | |
| if (assistant) { | |
| return assistant; | |
| } | |
| } | |
| return null; | |
| } | |
| function findGrokAnchor(button) { | |
| const roots = getGrokMessageRoots(); | |
| const direct = roots.find((root) => root.contains(button)); | |
| if (direct) { | |
| return direct; | |
| } | |
| return button.closest('[data-message-id], [data-message-role], [data-role], article, section, .group'); | |
| } | |
| function findGeminiAnchor(button) { | |
| const conversation = button.closest(GEMINI_CONVERSATION_SELECTOR); | |
| if (conversation) { | |
| return conversation; | |
| } | |
| const direct = button.closest('article, section, [data-test-render-count]'); | |
| if (direct) { | |
| return direct; | |
| } | |
| const actions = button.closest(GEMINI_ACTIONS_SELECTOR); | |
| if (!actions) { | |
| return null; | |
| } | |
| const actionsConversation = actions.closest(GEMINI_CONVERSATION_SELECTOR); | |
| if (actionsConversation) { | |
| return actionsConversation; | |
| } | |
| let sibling = actions.previousElementSibling; | |
| while (sibling) { | |
| if (sibling.matches(GEMINI_CONVERSATION_SELECTOR) || | |
| sibling.matches('article, section') || | |
| sibling.querySelector('article, section, p')) { | |
| return sibling; | |
| } | |
| sibling = sibling.previousElementSibling; | |
| } | |
| return actions.parentElement || null; | |
| } | |
| function getGeminiMessageRoots() { | |
| const container = document.querySelector('main') || document.body; | |
| const conversationRoots = Array.from(container.querySelectorAll(GEMINI_CONVERSATION_SELECTOR)); | |
| if (conversationRoots.length) { | |
| const roots = []; | |
| conversationRoots.forEach((conversation) => { | |
| roots.push(...getGeminiRootsFromConversation(conversation)); | |
| }); | |
| if (roots.length) { | |
| return roots; | |
| } | |
| } | |
| const selectors = [ | |
| '[data-test-render-count]', | |
| 'article', | |
| 'section' | |
| ]; | |
| const roots = Array.from(container.querySelectorAll(selectors.join(','))); | |
| return roots.filter((node, index, self) => { | |
| const isNested = self.some((other, otherIndex) => otherIndex !== index && other.contains(node)); | |
| return !isNested; | |
| }); | |
| } | |
| function getGeminiRootsFromConversation(conversation) { | |
| if (!conversation || !conversation.querySelectorAll) { | |
| return []; | |
| } | |
| const roots = []; | |
| const userRoot = conversation.querySelector( | |
| 'user-query-content .query-content .query-text, user-query-content .query-content, user-query .query-text' | |
| ); | |
| if (userRoot && normalizeText(userRoot.innerText || '')) { | |
| roots.push(userRoot); | |
| } | |
| const assistantRoots = Array.from(conversation.querySelectorAll( | |
| 'model-response message-content .markdown, model-response message-content' | |
| )).filter((node, index, self) => !self.some((other, otherIndex) => otherIndex !== index && other.contains(node))); | |
| assistantRoots.forEach((assistantRoot) => { | |
| if (normalizeText(assistantRoot.innerText || '')) { | |
| roots.push(assistantRoot); | |
| } | |
| }); | |
| return roots; | |
| } | |
| function getGrokMessageRoots() { | |
| const container = document.querySelector('main') || document.body; | |
| const primarySelectors = [ | |
| 'div[id^="response-"]', | |
| '.message-bubble', | |
| '.message-row' | |
| ]; | |
| let roots = Array.from(container.querySelectorAll(primarySelectors.join(','))); | |
| if (roots.length === 0) { | |
| const contentSelectors = [ | |
| '.prose', | |
| '.markdown', | |
| '.whitespace-pre-wrap' | |
| ]; | |
| roots = Array.from(container.querySelectorAll(contentSelectors.join(','))); | |
| } | |
| const uniqueRoots = roots.filter((node, index, self) => { | |
| const isNested = self.some((other) => other !== node && other.contains(node)); | |
| return !isNested; | |
| }); | |
| return uniqueRoots; | |
| } | |
| function findDeepSeekAnchor(button) { | |
| const roots = getDeepSeekMessageRoots(); | |
| const direct = roots.find((root) => root.contains(button)); | |
| if (direct) { | |
| return direct; | |
| } | |
| const actionBar = button.closest(DEEPSEEK_ACTIONS_SELECTOR); | |
| if (actionBar && actionBar.parentElement) { | |
| const messageRoot = actionBar.parentElement.querySelector('.ds-message'); | |
| if (messageRoot) { | |
| return messageRoot; | |
| } | |
| return actionBar.parentElement; | |
| } | |
| return button.closest('.ds-message, [data-message-id], [data-message-role], [data-role], article, section, .ds-chat-message'); | |
| } | |
| function getDeepSeekMessageRoots() { | |
| const container = document.querySelector('main') || document.body; | |
| const messageSelectors = [ | |
| 'article', | |
| 'section', | |
| '.ds-message', | |
| '[data-message-author-role]', | |
| '[data-message-id]', | |
| '[data-message-role]', | |
| '[data-role]', | |
| '[data-testid*="message"]', | |
| '.ds-chat-message' | |
| ]; | |
| const userSelectors = [ | |
| '[data-message-author-role="user"]', | |
| '[data-message-role="user"]', | |
| '[data-role="user"]', | |
| '[data-testid*="user"]' | |
| ]; | |
| const contentSelectors = [ | |
| '.markdown', | |
| '.prose', | |
| '.whitespace-pre-wrap', | |
| '.ds-markdown', | |
| '[data-message-content]', | |
| '[data-testid*="message-content"]' | |
| ]; | |
| const roots = []; | |
| const collectRoots = (nodes) => { | |
| nodes.forEach((node) => { | |
| let root = node.closest('[data-message-id], [data-message-role], [data-role], article, section, .ds-chat-message'); | |
| if (!root) { | |
| const fallback = node.closest('div'); | |
| if (fallback && fallback !== container && fallback !== document.body && fallback !== document.documentElement) { | |
| root = fallback; | |
| } | |
| } | |
| if (root && !roots.includes(root)) { | |
| roots.push(root); | |
| } | |
| }); | |
| }; | |
| collectRoots(Array.from(container.querySelectorAll(messageSelectors.join(',')))); | |
| collectRoots(Array.from(container.querySelectorAll(userSelectors.join(',')))); | |
| collectRoots(Array.from(container.querySelectorAll(contentSelectors.join(',')))); | |
| let uniqueRoots = roots.filter((node, index, self) => { | |
| const isContained = self.some((other, otherIndex) => otherIndex !== index && other.contains(node)); | |
| return !isContained; | |
| }); | |
| const addIfMissing = (node) => { | |
| if (node && !uniqueRoots.includes(node)) { | |
| uniqueRoots.push(node); | |
| } | |
| }; | |
| const findSiblingMessage = (start, direction) => { | |
| let sibling = start; | |
| while (sibling) { | |
| sibling = direction < 0 ? sibling.previousElementSibling : sibling.nextElementSibling; | |
| if (!sibling) { | |
| return null; | |
| } | |
| const content = extractMessageContentFromRoot(sibling); | |
| if (content && content.text) { | |
| return sibling; | |
| } | |
| } | |
| return null; | |
| }; | |
| uniqueRoots.forEach((root) => { | |
| const role = inferRoleFromRoot(root); | |
| if (role === 'assistant') { | |
| addIfMissing(findSiblingMessage(root, -1)); | |
| } else if (role === 'user') { | |
| addIfMissing(findSiblingMessage(root, 1)); | |
| } | |
| }); | |
| uniqueRoots = uniqueRoots.filter((node, index, self) => { | |
| const isContained = self.some((other, otherIndex) => otherIndex !== index && other.contains(node)); | |
| return !isContained; | |
| }); | |
| uniqueRoots.sort((a, b) => { | |
| if (a === b) { | |
| return 0; | |
| } | |
| const position = a.compareDocumentPosition(b); | |
| if (position & Node.DOCUMENT_POSITION_FOLLOWING) { | |
| return -1; | |
| } | |
| if (position & Node.DOCUMENT_POSITION_PRECEDING) { | |
| return 1; | |
| } | |
| return 0; | |
| }); | |
| return uniqueRoots; | |
| } | |
| function inferRoleFromRoot(root) { | |
| const directRole = root.getAttribute('data-message-author-role') || | |
| root.getAttribute('data-message-role') || | |
| root.getAttribute('data-role'); | |
| if (directRole) { | |
| return directRole; | |
| } | |
| if (platform === 'claude') { | |
| const testId = root.getAttribute('data-testid'); | |
| if (testId === 'user-message') { | |
| return 'user'; | |
| } | |
| if (testId === 'assistant-message') { | |
| return 'assistant'; | |
| } | |
| if (root.matches && root.matches('.font-claude-response')) { | |
| return 'assistant'; | |
| } | |
| if (root.querySelector && root.querySelector('[data-testid="user-message"]')) { | |
| return 'user'; | |
| } | |
| if (root.querySelector && root.querySelector('.font-claude-response')) { | |
| return 'assistant'; | |
| } | |
| } | |
| if (platform === 'deepseek') { | |
| if (root.querySelector('.ds-markdown')) { | |
| return 'assistant'; | |
| } | |
| if (root.querySelector('.fbb737a4, ._72b6158')) { | |
| return 'user'; | |
| } | |
| } | |
| if (platform === 'gemini') { | |
| if ( | |
| root.matches && ( | |
| root.matches('user-query, user-query-content, .query-content, .query-text') || | |
| root.closest('user-query') | |
| ) | |
| ) { | |
| return 'user'; | |
| } | |
| if ( | |
| root.matches && ( | |
| root.matches('model-response, message-content, .model-response-text, .markdown') || | |
| root.closest('model-response') | |
| ) | |
| ) { | |
| return 'assistant'; | |
| } | |
| } | |
| if (platform === 'grok') { | |
| if (root.matches && root.matches('.items-end')) { | |
| return 'user'; | |
| } | |
| if (root.matches && root.matches('.items-start')) { | |
| return 'assistant'; | |
| } | |
| if (root.querySelector && root.querySelector('.message-bubble.bg-surface-l1')) { | |
| return 'user'; | |
| } | |
| if (root.querySelector && root.querySelector('.response-content-markdown')) { | |
| return 'assistant'; | |
| } | |
| } | |
| const roleNode = root.querySelector('[data-message-author-role], [data-message-role], [data-role]'); | |
| if (roleNode) { | |
| return roleNode.getAttribute('data-message-author-role') || | |
| roleNode.getAttribute('data-message-role') || | |
| roleNode.getAttribute('data-role'); | |
| } | |
| const className = root.className || ''; | |
| if (/\bassistant\b/i.test(className)) { | |
| return 'assistant'; | |
| } | |
| if (/\buser\b/i.test(className)) { | |
| return 'user'; | |
| } | |
| return null; | |
| } | |
| function cleanHtml(node) { | |
| if (!node) return ''; | |
| const clone = node.cloneNode(true); | |
| stripNonExportableNodes(clone); | |
| return clone.innerHTML; | |
| } | |
| function prepareNodeForExport(node) { | |
| if (!node || !node.cloneNode) { | |
| return node; | |
| } | |
| const clone = node.cloneNode(true); | |
| stripNonExportableNodes(clone); | |
| if (platform === 'grok') { | |
| normalizeGrokStrokeWidthInlineSpan(clone); | |
| } | |
| return clone; | |
| } | |
| function stripNonExportableNodes(root) { | |
| if (!root || !root.querySelectorAll) { | |
| return; | |
| } | |
| const unwanted = root.querySelectorAll(NON_EXPORTABLE_NODE_SELECTOR); | |
| unwanted.forEach((el) => el.remove()); | |
| } | |
| function normalizeGrokStrokeWidthInlineSpan(root) { | |
| if (!root || !root.querySelectorAll) { | |
| return; | |
| } | |
| const spans = Array.from(root.querySelectorAll('span')); | |
| spans.forEach((span) => { | |
| if (!isTargetGrokStrokeWidthSpan(span)) { | |
| return; | |
| } | |
| span.textContent = ensureString(span.textContent) | |
| .replace(/\s*\n+\s*/g, ' ') | |
| .replace(/[ \t]{2,}/g, ' ') | |
| .trim(); | |
| enforceSingleLineBreakAroundNode(span); | |
| }); | |
| } | |
| function isTargetGrokStrokeWidthSpan(span) { | |
| if (!span) { | |
| return false; | |
| } | |
| const className = ensureString(span.className); | |
| const requiredClasses = [ | |
| 'text-sm', | |
| 'px-1', | |
| 'rounded-sm', | |
| '!font-mono', | |
| 'bg-orange-400/10', | |
| 'text-orange-500', | |
| 'dark:bg-orange-300/10', | |
| 'dark:text-orange-300' | |
| ]; | |
| const hasAllClasses = requiredClasses.every((token) => className.includes(token)); | |
| if (!hasAllClasses) { | |
| return false; | |
| } | |
| const compactText = ensureString(span.textContent).replace(/\s+/g, ' ').trim(); | |
| return /stroke-width\s*=\s*["']?1\.5["']?/i.test(compactText); | |
| } | |
| function enforceSingleLineBreakAroundNode(node) { | |
| if (!node || !node.parentNode) { | |
| return; | |
| } | |
| trimSiblingBoundary(node, 'before'); | |
| trimSiblingBoundary(node, 'after'); | |
| const doc = node.ownerDocument || document; | |
| node.parentNode.insertBefore(doc.createTextNode('\n'), node); | |
| if (node.nextSibling) { | |
| node.parentNode.insertBefore(doc.createTextNode('\n'), node.nextSibling); | |
| } else { | |
| node.parentNode.appendChild(doc.createTextNode('\n')); | |
| } | |
| } | |
| function trimSiblingBoundary(node, direction) { | |
| const parent = node.parentNode; | |
| if (!parent) { | |
| return; | |
| } | |
| let sibling = direction === 'before' ? node.previousSibling : node.nextSibling; | |
| while (sibling && sibling.nodeType === Node.TEXT_NODE && /^\s*$/.test(sibling.textContent || '')) { | |
| const toRemove = sibling; | |
| sibling = direction === 'before' ? sibling.previousSibling : sibling.nextSibling; | |
| parent.removeChild(toRemove); | |
| } | |
| if (sibling && sibling.nodeType === Node.TEXT_NODE) { | |
| if (direction === 'before') { | |
| sibling.textContent = ensureString(sibling.textContent) | |
| .replace(/[ \t]*\n+[ \t]*$/g, '') | |
| .replace(/[ \t]+$/g, ''); | |
| } else { | |
| sibling.textContent = ensureString(sibling.textContent) | |
| .replace(/^[ \t]*\n+[ \t]*/g, '') | |
| .replace(/^[ \t]+/g, ''); | |
| } | |
| if (!sibling.textContent) { | |
| parent.removeChild(sibling); | |
| } | |
| } | |
| } | |
| function extractCleanTextForPdf(node) { | |
| const clone = node.cloneNode(true); | |
| clone.querySelectorAll('[class*="whitespace-pre-wrap"]').forEach(el => { | |
| el.style.whiteSpace = 'normal'; | |
| }); | |
| return clone.innerText | |
| .replace(/\s*\n+\s*/g, ' ') | |
| .replace(/[ \t]{2,}/g, ' ') | |
| .trim(); | |
| } | |
| function extractMessageContentFromRoot(root) { | |
| if (!root) { | |
| return { text: '', html: '' }; | |
| } | |
| let selectors; | |
| if (platform === 'deepseek') { | |
| selectors = [ | |
| '.ds-markdown', | |
| '.fbb737a4', | |
| '._72b6158', | |
| '.markdown', | |
| '.prose', | |
| '.whitespace-pre-wrap', | |
| '[data-message-content]', | |
| '[data-testid*="message-content"]' | |
| ]; | |
| } else if (platform === 'claude') { | |
| selectors = [ | |
| '[data-testid="assistant-message"]', | |
| '[data-testid="user-message"]', | |
| '.font-claude-response-body', | |
| '.font-claude-response', | |
| '.standard-markdown', | |
| '.progressive-markdown', | |
| '.markdown', | |
| '.prose', | |
| '.whitespace-pre-wrap' | |
| ]; | |
| } else if (platform === 'gemini') { | |
| const GEMINI_LEAF_SELECTOR = | |
| 'message-content, .markdown, ' + | |
| '.query-text, .query-content, .user-query-bubble-with-background'; | |
| if (root.matches && root.matches(GEMINI_LEAF_SELECTOR)) { | |
| const preferredLeaf = | |
| (root.matches && root.matches('.markdown, .query-text, .user-query-bubble-with-background') ? root : null) || | |
| (root.querySelector && root.querySelector('.markdown, .query-text, .user-query-bubble-with-background')) || | |
| root; | |
| const exportNode = prepareNodeForExport(preferredLeaf); | |
| const text = normalizeText(exportNode.innerText || ''); | |
| if (text) { | |
| return { | |
| text, | |
| html: cleanHtml(exportNode), | |
| sourceNode: preferredLeaf | |
| }; | |
| } | |
| } | |
| selectors = [ | |
| 'message-content .markdown', | |
| 'model-response message-content .markdown', | |
| 'user-query-content .query-content .query-text', | |
| 'user-query .query-text', | |
| '.query-content .query-text', | |
| '.query-text', | |
| '.user-query-bubble-with-background', | |
| 'model-response message-content', | |
| 'user-query-content .query-content' | |
| ]; | |
| } else if (platform === 'grok') { | |
| const content = root.querySelector('.message-content, .message-row'); | |
| if (content) { | |
| const exportContent = prepareNodeForExport(content); | |
| return { | |
| text: normalizeText(exportContent.innerText || ''), | |
| html: cleanHtml(exportContent) | |
| }; | |
| } | |
| selectors = [ | |
| '.response-content-markdown', | |
| '.message-bubble', | |
| '.markdown', | |
| '.prose', | |
| '.whitespace-pre-wrap', | |
| '[data-message-content]', | |
| '[data-testid*="message-content"]' | |
| ]; | |
| } else { | |
| selectors = [ | |
| '.markdown', | |
| '.prose', | |
| '.whitespace-pre-wrap', | |
| '[data-message-content]', | |
| '[data-testid*="message-content"]' | |
| ]; | |
| } | |
| const allNodes = Array.from(root.querySelectorAll(selectors.join(','))); | |
| const nodes = allNodes.filter((node, index, self) => { | |
| if (platform === 'gemini') { | |
| const containsOther = self.some((other) => other !== node && node.contains(other)); | |
| return !containsOther; | |
| } | |
| const isContained = self.some((other) => other !== node && other.contains(node)); | |
| return !isContained; | |
| }); | |
| const parts = []; | |
| const htmlParts = []; | |
| const sourceNodes = []; | |
| nodes.forEach((node) => { | |
| if (node.closest('button, nav, header, footer, svg')) { | |
| return; | |
| } | |
| const exportNode = prepareNodeForExport(node); | |
| const text = normalizeText(exportNode.innerText || ''); | |
| const html = cleanHtml(exportNode); | |
| if (text) { | |
| parts.push(text); | |
| htmlParts.push(html); | |
| sourceNodes.push(node); | |
| } | |
| }); | |
| if (parts.length) { | |
| return { | |
| text: parts.join('\n\n').trim(), | |
| html: htmlParts.join('<br><br>').trim(), | |
| sourceNode: sourceNodes.length === 1 ? sourceNodes[0] : null | |
| }; | |
| } | |
| const fallbackNode = prepareNodeForExport(root); | |
| const fallbackText = normalizeText(fallbackNode.innerText || ''); | |
| const fallbackHtml = cleanHtml(fallbackNode); | |
| return { | |
| text: stripActionLines(fallbackText, root), | |
| html: fallbackHtml, | |
| sourceNode: root | |
| }; | |
| } | |
| function stripActionLines(text, root) { | |
| if (!text) { | |
| return ''; | |
| } | |
| const blocked = collectActionLabels(root); | |
| if (!blocked.size) { | |
| return text.trim(); | |
| } | |
| return text | |
| .split('\n') | |
| .map((line) => line.trim()) | |
| .filter((line) => line && !blocked.has(line)) | |
| .join('\n') | |
| .trim(); | |
| } | |
| function collectActionLabels(root) { | |
| const blocked = new Set(); | |
| if (!root || !root.querySelectorAll) { | |
| return blocked; | |
| } | |
| const actionNodes = root.querySelectorAll('button, [role="button"], [role="menuitem"], [aria-label], [mattooltip], [title]'); | |
| actionNodes.forEach((node) => { | |
| const candidates = [ | |
| node.getAttribute('aria-label'), | |
| node.getAttribute('mattooltip'), | |
| node.getAttribute('title'), | |
| node.getAttribute('data-tooltip') | |
| ]; | |
| const text = normalizeText(extractCleanTextForPdf(node)); | |
| if (text && text.length <= 80 && text.split('\n').length <= 2) { | |
| candidates.push(text); | |
| } | |
| candidates.forEach((candidate) => { | |
| if (!candidate) { | |
| return; | |
| } | |
| const normalized = normalizeText(candidate); | |
| normalized.split('\n').forEach((line) => { | |
| const clean = line.trim(); | |
| if (clean && clean.length <= 80) { | |
| blocked.add(clean); | |
| } | |
| }); | |
| }); | |
| }); | |
| return blocked; | |
| } | |
| function extractMessageContent(node) { | |
| const contentRoot = | |
| node.querySelector('.markdown') || | |
| node.querySelector('[data-message-content]') || | |
| node; | |
| if (!contentRoot) { | |
| return { text: '', html: '' }; | |
| } | |
| const exportNode = prepareNodeForExport(contentRoot); | |
| const rawText = exportNode.innerText || ''; | |
| const html = cleanHtml(exportNode); | |
| return { | |
| text: normalizeText(rawText), | |
| html: html, | |
| sourceNode: contentRoot | |
| }; | |
| } | |
| function normalizeText(text) { | |
| return text | |
| .replace(/\r\n/g, '\n') | |
| .replace(/[ \t]+\n/g, '\n') | |
| .replace(/\n{3,}/g, '\n\n') | |
| .trim(); | |
| } | |
| function normalizePdfPipelineText(text) { | |
| const raw = ensureString(text); | |
| if (!raw) { | |
| return ''; | |
| } | |
| if (platform === 'grok') { | |
| return normalizeGrokPdfText(raw); | |
| } | |
| return raw; | |
| } | |
| function normalizeGrokPdfText(text) { | |
| const PARAGRAPH_TOKEN = '__OMNI_GROK_PDF_PARAGRAPH__'; | |
| return ensureString(text) | |
| .replace(/\r\n/g, '\n') | |
| .replace(/\n{2,}/g, PARAGRAPH_TOKEN) | |
| .replace(/\s*\n+\s*/g, ' ') | |
| .replace(/[ \t]{2,}/g, ' ') | |
| .replace(new RegExp(PARAGRAPH_TOKEN, 'g'), '\n\n') | |
| .replace(/[ \t]*\n\n[ \t]*/g, '\n\n'); | |
| } | |
| function filterTopLevelNodes(nodes) { | |
| return nodes.filter((node, index, self) => { | |
| const isContained = self.some((other, otherIndex) => | |
| otherIndex !== index && other.contains(node) | |
| ); | |
| return !isContained; | |
| }); | |
| } | |
| function buildExportMarkdown(messages) { | |
| const title = `${getPlatformLabel()} Export`; | |
| const conversationTitle = getExportConversationTitle(); | |
| const lines = []; | |
| lines.push(`# ${title}`); | |
| if (conversationTitle) { | |
| lines.push(`Conversation: ${conversationTitle}`); | |
| } | |
| lines.push(`URL: ${location.href}`); | |
| lines.push(`Exported: ${new Date().toISOString()}`); | |
| lines.push(''); | |
| messages.forEach((message) => { | |
| const roleLabel = formatRoleLabel(message.role); | |
| lines.push(`## ${roleLabel}`); | |
| lines.push(''); | |
| const markdownBody = | |
| platform === 'gemini' && | |
| message && | |
| message.sourceNode && | |
| message.sourceNode.nodeType === Node.ELEMENT_NODE && | |
| message.sourceNode.isConnected | |
| ? convertMessageNodeToMarkdown(message.sourceNode, message.text) | |
| : convertMessageHtmlToMarkdown(message.html, message.text); | |
| lines.push(markdownBody || ensureString(message.text)); | |
| lines.push(''); | |
| }); | |
| return `${lines.join('\n').trim()}\n`; | |
| } | |
| function convertMessageNodeToMarkdown(sourceNode, fallbackText) { | |
| if (!sourceNode || sourceNode.nodeType !== Node.ELEMENT_NODE) { | |
| return normalizePlainMarkdownText(fallbackText); | |
| } | |
| const exportNode = prepareNodeForExport(sourceNode); | |
| const markdown = renderMarkdownChildren(exportNode, { listDepth: 0, inPre: false, inTable: false }); | |
| return finalizeMarkdownOutput(markdown) || normalizePlainMarkdownText(fallbackText || exportNode.innerText || ''); | |
| } | |
| function convertMessageHtmlToMarkdown(html, fallbackText) { | |
| const rawHtml = ensureString(html); | |
| if (!rawHtml || !/<[^>]+>/.test(rawHtml)) { | |
| return normalizePlainMarkdownText(fallbackText || rawHtml); | |
| } | |
| const container = parseHtmlContainer(rawHtml); | |
| if (!container) { | |
| return normalizePlainMarkdownText(stripHtmlToText(rawHtml) || fallbackText); | |
| } | |
| stripNonExportableNodes(container); | |
| const markdown = renderMarkdownChildren(container, { listDepth: 0, inPre: false, inTable: false }); | |
| return finalizeMarkdownOutput(markdown) || normalizePlainMarkdownText(fallbackText); | |
| } | |
| function renderMarkdownChildren(parentNode, ctx) { | |
| return Array.from(parentNode.childNodes || []) | |
| .map((child) => renderMarkdownNode(child, ctx)) | |
| .join(''); | |
| } | |
| function renderMarkdownNode(node, ctx) { | |
| if (!node) { | |
| return ''; | |
| } | |
| if (node.nodeType === Node.TEXT_NODE) { | |
| return renderMarkdownTextNode(node, ctx); | |
| } | |
| if (node.nodeType !== Node.ELEMENT_NODE) { | |
| return ''; | |
| } | |
| if (node.matches && node.matches(NON_EXPORTABLE_NODE_SELECTOR)) { | |
| return ''; | |
| } | |
| const tag = ensureString(node.tagName).toLowerCase(); | |
| const katexMode = detectKatexMode(node); | |
| if (katexMode === 'display') { | |
| const tex = extractLatexFromNode(node); | |
| return tex ? `\n\n$$\n${tex}\n$$\n\n` : ''; | |
| } | |
| if (katexMode === 'inline') { | |
| const tex = extractLatexFromNode(node); | |
| return tex ? `$${tex}$` : ''; | |
| } | |
| if (tag === 'annotation' && ensureString(node.getAttribute('encoding')).toLowerCase() === 'application/x-tex') { | |
| return ''; | |
| } | |
| if (tag === 'br') { | |
| return '\n'; | |
| } | |
| if (tag === 'hr') { | |
| return '\n\n---\n\n'; | |
| } | |
| if (tag === 'pre') { | |
| return renderMarkdownCodeBlock(node); | |
| } | |
| if (tag === 'code') { | |
| if (node.closest('pre')) { | |
| return ''; | |
| } | |
| return wrapMarkdownInlineCode(node.textContent || ''); | |
| } | |
| if (tag === 'table') { | |
| return renderMarkdownTable(node, ctx); | |
| } | |
| if (tag === 'blockquote') { | |
| const quoteBody = finalizeMarkdownOutput(renderMarkdownChildren(node, ctx)); | |
| if (!quoteBody) { | |
| return ''; | |
| } | |
| const quotedLines = quoteBody.split('\n').map((line) => line ? `> ${line}` : '>'); | |
| return `\n\n${quotedLines.join('\n')}\n\n`; | |
| } | |
| if (tag === 'ul' || tag === 'ol') { | |
| return renderMarkdownList(node, tag === 'ol', ctx); | |
| } | |
| if (tag === 'li') { | |
| return renderMarkdownListItem(node, ctx, '-'); | |
| } | |
| if (tag === 'h1' || tag === 'h2' || tag === 'h3' || tag === 'h4' || tag === 'h5' || tag === 'h6') { | |
| const level = Number.parseInt(tag.slice(1), 10) || 1; | |
| const heading = normalizeInlineMarkdownChunk(renderMarkdownChildren(node, ctx)); | |
| if (!heading) { | |
| return ''; | |
| } | |
| return `\n\n${'#'.repeat(Math.max(1, Math.min(6, level)))} ${heading}\n\n`; | |
| } | |
| if (tag === 'p') { | |
| const paragraph = normalizeInlineMarkdownChunk(renderMarkdownChildren(node, ctx)); | |
| return paragraph ? `\n\n${paragraph}\n\n` : ''; | |
| } | |
| if (tag === 'strong' || tag === 'b') { | |
| const content = normalizeInlineMarkdownChunk(renderMarkdownChildren(node, ctx)); | |
| return content ? `**${content}**` : ''; | |
| } | |
| if (tag === 'em' || tag === 'i') { | |
| const content = normalizeInlineMarkdownChunk(renderMarkdownChildren(node, ctx)); | |
| return content ? `*${content}*` : ''; | |
| } | |
| if (tag === 'del' || tag === 's' || tag === 'strike') { | |
| const content = normalizeInlineMarkdownChunk(renderMarkdownChildren(node, ctx)); | |
| return content ? `~~${content}~~` : ''; | |
| } | |
| if (tag === 'a') { | |
| const href = ensureString(node.getAttribute('href')).trim(); | |
| const label = normalizeInlineMarkdownChunk(renderMarkdownChildren(node, ctx)) || href; | |
| if (!href) { | |
| return label; | |
| } | |
| return `[${label}](${href})`; | |
| } | |
| if (tag === 'img') { | |
| const alt = escapeMarkdownText(ensureString(node.getAttribute('alt')).trim()); | |
| const src = ensureString(node.getAttribute('src')).trim(); | |
| if (!src) { | |
| return alt; | |
| } | |
| return ``; | |
| } | |
| const inner = renderMarkdownChildren(node, ctx); | |
| if (isMarkdownBlockTag(tag)) { | |
| const block = finalizeMarkdownOutput(inner); | |
| return block ? `\n\n${block}\n\n` : ''; | |
| } | |
| return inner; | |
| } | |
| function renderMarkdownTextNode(node, ctx) { | |
| const raw = ensureString(node.textContent).replace(/\u00a0/g, ' '); | |
| if (!raw) { | |
| return ''; | |
| } | |
| if (ctx && ctx.inPre) { | |
| return raw; | |
| } | |
| return escapeMarkdownText(raw.replace(/[ \t\r\f\v]+/g, ' ').replace(/\n+/g, ' ')); | |
| } | |
| function renderMarkdownCodeBlock(preNode) { | |
| const codeNode = preNode.querySelector('code') || preNode; | |
| const rawCode = ensureString(codeNode.textContent) | |
| .replace(/\r\n/g, '\n') | |
| .replace(/\u00a0/g, ' ') | |
| .replace(/\s+$/, ''); | |
| const language = extractMarkdownCodeLanguage(preNode, codeNode); | |
| const fenceSize = Math.max(3, longestBacktickRun(rawCode) + 1); | |
| const fence = '`'.repeat(fenceSize); | |
| return `\n\n${fence}${language}\n${rawCode}\n${fence}\n\n`; | |
| } | |
| function wrapMarkdownInlineCode(text) { | |
| const value = ensureString(text).replace(/\r\n/g, ' ').replace(/\n/g, ' '); | |
| if (!value) { | |
| return '``'; | |
| } | |
| const fenceSize = Math.max(1, longestBacktickRun(value) + 1); | |
| const fence = '`'.repeat(fenceSize); | |
| if (/^\s|\s$/.test(value) || value.includes(fence)) { | |
| return `${fence} ${value} ${fence}`; | |
| } | |
| return `${fence}${value}${fence}`; | |
| } | |
| function longestBacktickRun(text) { | |
| const runs = ensureString(text).match(/`+/g); | |
| if (!runs || !runs.length) { | |
| return 0; | |
| } | |
| return runs.reduce((max, entry) => Math.max(max, entry.length), 0); | |
| } | |
| function renderMarkdownList(listNode, isOrdered, ctx) { | |
| const depth = Number(ctx && ctx.listDepth) || 0; | |
| const items = Array.from(listNode.children || []).filter((child) => { | |
| return child && ensureString(child.tagName).toLowerCase() === 'li'; | |
| }); | |
| if (!items.length) { | |
| return ''; | |
| } | |
| const start = isOrdered ? parseListStartValue(listNode) : 1; | |
| const nextCtx = Object.assign({}, ctx, { listDepth: depth }); | |
| const rendered = items.map((item, index) => { | |
| const marker = isOrdered ? `${start + index}.` : '-'; | |
| return renderMarkdownListItem(item, nextCtx, marker); | |
| }).filter(Boolean).join('\n'); | |
| if (!rendered) { | |
| return ''; | |
| } | |
| return depth > 0 ? `\n${rendered}\n` : `\n\n${rendered}\n\n`; | |
| } | |
| function renderMarkdownListItem(listItemNode, ctx, marker) { | |
| const depth = Number(ctx && ctx.listDepth) || 0; | |
| const indent = ' '.repeat(depth); | |
| const continuationIndent = `${indent}${' '.repeat(marker.length + 1)}`; | |
| const nestedCtx = Object.assign({}, ctx, { listDepth: depth + 1 }); | |
| let inlineBuffer = ''; | |
| const trailingBlocks = []; | |
| Array.from(listItemNode.childNodes || []).forEach((child) => { | |
| if (child.nodeType === Node.ELEMENT_NODE) { | |
| const tag = ensureString(child.tagName).toLowerCase(); | |
| if (tag === 'ul' || tag === 'ol') { | |
| const nested = renderMarkdownList(child, tag === 'ol', nestedCtx).trimEnd(); | |
| if (nested) { | |
| trailingBlocks.push({ kind: 'nested', value: nested }); | |
| } | |
| return; | |
| } | |
| if (isMarkdownListItemBlockTag(tag)) { | |
| const block = finalizeMarkdownOutput(renderMarkdownNode(child, Object.assign({}, ctx, { listDepth: depth }))); | |
| if (block) { | |
| trailingBlocks.push({ kind: 'block', value: block }); | |
| } | |
| return; | |
| } | |
| } | |
| inlineBuffer += renderMarkdownNode(child, Object.assign({}, ctx, { listDepth: depth })); | |
| }); | |
| const inlineText = normalizeInlineMarkdownChunk(inlineBuffer); | |
| let result = `${indent}${marker} ${inlineText}`.replace(/[ \t]+$/g, ''); | |
| trailingBlocks.forEach((entry) => { | |
| if (!entry || !entry.value) { | |
| return; | |
| } | |
| if (entry.kind === 'nested') { | |
| result += `\n${entry.value}`; | |
| return; | |
| } | |
| const padded = entry.value | |
| .split('\n') | |
| .map((line) => line ? `${continuationIndent}${line}` : continuationIndent) | |
| .join('\n'); | |
| result += `\n${padded}`; | |
| }); | |
| return result.trimEnd(); | |
| } | |
| function parseListStartValue(listNode) { | |
| const raw = ensureString(listNode && listNode.getAttribute && listNode.getAttribute('start')).trim(); | |
| const value = Number.parseInt(raw, 10); | |
| return Number.isFinite(value) ? value : 1; | |
| } | |
| function renderMarkdownTable(tableNode, ctx) { | |
| const rows = Array.from(tableNode.querySelectorAll('tr')); | |
| const parsedRows = rows.map((row) => { | |
| return Array.from(row.children || []) | |
| .filter((cell) => { | |
| const tag = ensureString(cell.tagName).toLowerCase(); | |
| return tag === 'th' || tag === 'td'; | |
| }) | |
| .map((cell) => { | |
| const cellText = normalizeInlineMarkdownChunk( | |
| renderMarkdownChildren(cell, Object.assign({}, ctx, { inTable: true })) | |
| ).replace(/\n+/g, ' <br> '); | |
| return escapeMarkdownTableCell(cellText); | |
| }); | |
| }).filter((row) => row.length > 0); | |
| if (!parsedRows.length) { | |
| return ''; | |
| } | |
| const columnCount = parsedRows.reduce((max, row) => Math.max(max, row.length), 0); | |
| parsedRows.forEach((row) => { | |
| while (row.length < columnCount) { | |
| row.push(''); | |
| } | |
| }); | |
| const hasHeaderRow = rows.length > 0 && Array.from(rows[0].children || []).some((cell) => { | |
| return ensureString(cell.tagName).toLowerCase() === 'th'; | |
| }); | |
| const header = hasHeaderRow ? parsedRows[0] : parsedRows[0].map((_, index) => `Col ${index + 1}`); | |
| const bodyRows = hasHeaderRow ? parsedRows.slice(1) : parsedRows; | |
| const separator = new Array(columnCount).fill('---'); | |
| const lines = []; | |
| lines.push(`| ${header.join(' | ')} |`); | |
| lines.push(`| ${separator.join(' | ')} |`); | |
| bodyRows.forEach((row) => { | |
| lines.push(`| ${row.join(' | ')} |`); | |
| }); | |
| return `\n\n${lines.join('\n')}\n\n`; | |
| } | |
| function escapeMarkdownTableCell(value) { | |
| return ensureString(value) | |
| .replace(/\|/g, '\\|') | |
| .replace(/\r?\n/g, ' ') | |
| .trim(); | |
| } | |
| function extractMarkdownCodeLanguage(preNode, codeNode) { | |
| const candidates = [ | |
| codeNode, | |
| preNode, | |
| preNode && preNode.parentElement, | |
| preNode && preNode.closest && preNode.closest('[data-testid="code-block"], .md-code-block, code-block, .code-block') | |
| ].filter(Boolean); | |
| for (const candidate of candidates) { | |
| const className = ensureString(candidate.className); | |
| const classMatch = className.match(/(?:^|\s)language-([a-z0-9_+.-]+)/i); | |
| if (classMatch && classMatch[1]) { | |
| return sanitizeMarkdownLanguage(classMatch[1]); | |
| } | |
| const attr = ensureString( | |
| candidate.getAttribute && ( | |
| candidate.getAttribute('data-language') || | |
| candidate.getAttribute('lang') | |
| ) | |
| ).trim(); | |
| if (attr) { | |
| return sanitizeMarkdownLanguage(attr); | |
| } | |
| } | |
| const labelNode = | |
| (preNode && preNode.closest && preNode.closest('.md-code-block') && | |
| preNode.closest('.md-code-block').querySelector('.md-code-block-banner .d813de27')) || | |
| (preNode && preNode.closest && preNode.closest('.code-block') && | |
| preNode.closest('.code-block').querySelector('.code-block-decoration span')) || | |
| (preNode && preNode.closest && preNode.closest('[data-testid="code-block"]') && | |
| preNode.closest('[data-testid="code-block"]').querySelector('.text-xs')) || | |
| null; | |
| if (labelNode) { | |
| const label = sanitizeMarkdownLanguage(labelNode.textContent || ''); | |
| if (label) { | |
| return label; | |
| } | |
| } | |
| return ''; | |
| } | |
| function sanitizeMarkdownLanguage(value) { | |
| return ensureString(value).trim().replace(/[^a-z0-9_+.-]/gi, ''); | |
| } | |
| function detectKatexMode(node) { | |
| if (!node || !node.classList) { | |
| return ''; | |
| } | |
| if (node.classList.contains('katex-display')) { | |
| return 'display'; | |
| } | |
| if (node.classList.contains('katex') && !node.closest('.katex-display')) { | |
| return 'inline'; | |
| } | |
| return ''; | |
| } | |
| function extractLatexFromNode(node) { | |
| if (!node || !node.querySelector) { | |
| return ''; | |
| } | |
| const annotation = | |
| (node.matches && | |
| node.matches('annotation[encoding="application/x-tex"]') && | |
| node) || | |
| node.querySelector('annotation[encoding="application/x-tex"]'); | |
| if (!annotation) { | |
| return ''; | |
| } | |
| return ensureString(annotation.textContent).replace(/\r\n/g, '\n').trim(); | |
| } | |
| function escapeMarkdownText(text) { | |
| return ensureString(text) | |
| .replace(/\\/g, '\\\\') | |
| .replace(/([`*_{}[\]()#+!>|])/g, '\\$1'); | |
| } | |
| function normalizeInlineMarkdownChunk(value) { | |
| return ensureString(value) | |
| .replace(/[ \t]+\n/g, '\n') | |
| .replace(/\n[ \t]+/g, '\n') | |
| .replace(/[ \t]{2,}/g, ' ') | |
| .replace(/\n{3,}/g, '\n\n') | |
| .trim(); | |
| } | |
| function normalizePlainMarkdownText(value) { | |
| return ensureString(value) | |
| .replace(/\r\n/g, '\n') | |
| .replace(/\u00a0/g, ' ') | |
| .replace(/[ \t]+\n/g, '\n') | |
| .replace(/\n{3,}/g, '\n\n') | |
| .trim(); | |
| } | |
| function finalizeMarkdownOutput(value) { | |
| return ensureString(value) | |
| .replace(/\r\n/g, '\n') | |
| .replace(/[ \t]+\n/g, '\n') | |
| .replace(/\n[ \t]+/g, '\n') | |
| .replace(/\n{3,}/g, '\n\n') | |
| .trim(); | |
| } | |
| function isMarkdownBlockTag(tag) { | |
| return tag === 'div' || | |
| tag === 'section' || | |
| tag === 'article' || | |
| tag === 'main' || | |
| tag === 'header' || | |
| tag === 'footer' || | |
| tag === 'aside'; | |
| } | |
| function isMarkdownListItemBlockTag(tag) { | |
| return tag === 'p' || | |
| tag === 'div' || | |
| tag === 'pre' || | |
| tag === 'blockquote' || | |
| tag === 'table' || | |
| tag === 'h1' || | |
| tag === 'h2' || | |
| tag === 'h3' || | |
| tag === 'h4' || | |
| tag === 'h5' || | |
| tag === 'h6'; | |
| } | |
| function buildExportText(messages) { | |
| const title = `${getPlatformLabel()} Export`; | |
| const conversationTitle = getExportConversationTitle(); | |
| const lines = []; | |
| lines.push(title); | |
| if (conversationTitle) { | |
| lines.push(`Conversation: ${conversationTitle}`); | |
| } | |
| lines.push(`URL: ${location.href}`); | |
| lines.push(`Exported: ${new Date().toISOString()}`); | |
| lines.push(''); | |
| messages.forEach((message) => { | |
| const roleLabel = formatRoleLabel(message.role); | |
| lines.push(`${roleLabel}:`); | |
| lines.push(ensureString(message.text)); | |
| lines.push(''); | |
| }); | |
| return `${lines.join('\n').trim()}\n`; | |
| } | |
| function buildExportJson(messages) { | |
| const conversationTitle = getExportConversationTitle(); | |
| const payload = { | |
| url: location.href, | |
| exportedAt: new Date().toISOString(), | |
| messages: messages.map((message) => ({ | |
| role: ensureString(message.role), | |
| text: ensureString(message.text), | |
| html: ensureString(message.html) | |
| })) | |
| }; | |
| if (conversationTitle) { | |
| payload.conversationTitle = conversationTitle; | |
| } | |
| return JSON.stringify(payload, null, 2); | |
| } | |
| function buildExportHtml(messages) { | |
| const title = `${getPlatformLabel()} Export`; | |
| const conversationTitle = getExportConversationTitle(); | |
| const rows = messages.map((message) => { | |
| const roleLabel = | |
| message.role.charAt(0).toUpperCase() + message.role.slice(1); | |
| return ` | |
| <section class="message"> | |
| <h3>${escapeHtml(roleLabel)}</h3> | |
| <pre>${escapeHtml(message.text)}</pre> | |
| </section> | |
| `; | |
| }).join(''); | |
| return `<!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>${escapeHtml(title)}</title> | |
| <style> | |
| :root { color-scheme: light; } | |
| body { font-family: "Segoe UI", system-ui, sans-serif; margin: 32px; color: #0f172a; } | |
| h1 { font-size: 20px; margin-bottom: 6px; } | |
| p.meta { color: #475569; font-size: 12px; margin-top: 0; } | |
| section.message { margin: 18px 0 22px; padding: 12px 14px; border: 1px solid #e2e8f0; border-radius: 12px; background: #f8fafc; } | |
| section.message h3 { margin: 0 0 8px; font-size: 13px; text-transform: capitalize; color: #1e293b; } | |
| section.message pre { margin: 0; white-space: pre-wrap; font-family: "Consolas", "SFMono-Regular", ui-monospace, monospace; font-size: 12px; line-height: 1.45; } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>${escapeHtml(title)}</h1> | |
| <p class="meta">${conversationTitle ? `Conversation: ${escapeHtml(conversationTitle)}<br>` : ''}URL: ${escapeHtml(location.href)}<br>Exported: ${escapeHtml(new Date().toISOString())}</p> | |
| ${rows} | |
| </body> | |
| </html>`; | |
| } | |
| function convertHtmlToPdfMake(htmlOrText) { | |
| if (!htmlOrText || typeof htmlOrText !== 'string') { | |
| return { text: '' }; | |
| } | |
| if (!/<[^>]+>/.test(htmlOrText)) { | |
| return { text: formatPdfTextWithEmoji(htmlOrText), preserveLeadingSpaces: true }; | |
| } | |
| if (document && document.body) { | |
| const mount = document.createElement('div'); | |
| mount.setAttribute('data-omni-pdf-parse', 'true'); | |
| mount.style.position = 'fixed'; | |
| mount.style.left = '-100000px'; | |
| mount.style.top = '-100000px'; | |
| mount.style.width = '1px'; | |
| mount.style.height = '1px'; | |
| mount.style.opacity = '0'; | |
| mount.style.pointerEvents = 'none'; | |
| mount.style.overflow = 'hidden'; | |
| try { | |
| mount.innerHTML = htmlOrText; | |
| stripNonExportableNodes(mount); | |
| document.body.appendChild(mount); | |
| const liveResult = parseNodeToPdfMake(mount); | |
| if (Array.isArray(liveResult) && liveResult.length === 1) { | |
| return liveResult[0]; | |
| } | |
| return liveResult; | |
| } catch (err) { | |
| } finally { | |
| if (mount.parentNode) { | |
| mount.parentNode.removeChild(mount); | |
| } | |
| } | |
| } | |
| const temp = parseHtmlContainer(htmlOrText); | |
| if (!temp) { | |
| return { | |
| text: formatPdfTextWithEmoji(normalizeText(stripHtmlToText(htmlOrText))), | |
| preserveLeadingSpaces: true | |
| }; | |
| } | |
| const result = parseNodeToPdfMake(temp); | |
| if (Array.isArray(result) && result.length === 1) { | |
| return result[0]; | |
| } | |
| return result; | |
| } | |
| function parseHtmlContainer(html) { | |
| const raw = ensureString(html); | |
| if (!raw) { | |
| return null; | |
| } | |
| if (typeof DOMParser !== 'undefined') { | |
| try { | |
| const parser = new DOMParser(); | |
| const doc = parser.parseFromString(raw, 'text/html'); | |
| if (doc && doc.body) { | |
| return doc.body; | |
| } | |
| } catch (err) { | |
| } | |
| } | |
| const temp = document.createElement('div'); | |
| try { | |
| temp.innerHTML = raw; | |
| return temp; | |
| } catch (err) { | |
| return null; | |
| } | |
| } | |
| function stripHtmlToText(html) { | |
| if (!html) { | |
| return ''; | |
| } | |
| const withLineBreaks = String(html) | |
| .replace(/<\s*br\b[^>]*>/gi, '\n') | |
| .replace(/<\/(p|div|h1|h2|h3|h4|h5|h6|li|blockquote|pre|tr)>/gi, '\n') | |
| .replace(/<li[^>]*>/gi, '- '); | |
| const withoutTags = withLineBreaks.replace(/<[^>]+>/g, ''); | |
| return decodeHtmlEntities(withoutTags); | |
| } | |
| function decodeHtmlEntities(text) { | |
| if (!text) { | |
| return ''; | |
| } | |
| return String(text) | |
| .replace(/ /gi, ' ') | |
| .replace(/&/gi, '&') | |
| .replace(/</gi, '<') | |
| .replace(/>/gi, '>') | |
| .replace(/"/gi, '"') | |
| .replace(/'/gi, '\'') | |
| .replace(/&#(\d+);/g, (_, code) => { | |
| const value = Number(code); | |
| return Number.isFinite(value) ? String.fromCharCode(value) : ''; | |
| }); | |
| } | |
| function parseNodeToPdfMake(node) { | |
| const children = Array.from(node.childNodes); | |
| const content = []; | |
| children.forEach(child => { | |
| const parsed = parseNodeRecursive(child); | |
| if (parsed) { | |
| if (Array.isArray(parsed)) { | |
| content.push(...parsed); | |
| } else { | |
| content.push(parsed); | |
| } | |
| } | |
| }); | |
| return content.length === 1 ? content[0] : content; | |
| } | |
| function parseNodeRecursive(node) { | |
| if (node.nodeType === Node.TEXT_NODE) { | |
| const text = node.textContent; | |
| if (!text) { | |
| return null; | |
| } | |
| const normalized = text.replace(/\s*\n+\s*/g, ' '); | |
| if (normalized.trim() === '' && normalized.length > 0) { | |
| return { text: normalized }; | |
| } | |
| if (!normalized.trim()) { | |
| return null; | |
| } | |
| return { text: formatPdfTextWithEmoji(normalized) }; | |
| } | |
| if (node.nodeType === Node.ELEMENT_NODE) { | |
| if (node.matches && node.matches(NON_EXPORTABLE_NODE_SELECTOR)) { | |
| return null; | |
| } | |
| const specialCodeBlock = buildSpecialPdfCodeBlock(node); | |
| if (specialCodeBlock) { | |
| return specialCodeBlock; | |
| } | |
| if (isGrokInlineCodeLikeElement(node)) { | |
| return buildInlineCodeTextStyle(node.textContent || '', { noWrap: false }); | |
| } | |
| const tagName = node.tagName.toLowerCase(); | |
| const children = Array.from(node.childNodes); | |
| const childContent = []; | |
| children.forEach(child => { | |
| const parsed = parseNodeRecursive(child); | |
| if (parsed) { | |
| if (Array.isArray(parsed)) { | |
| childContent.push(...parsed); | |
| } else { | |
| childContent.push(parsed); | |
| } | |
| } | |
| }); | |
| switch (tagName) { | |
| case 'strong': | |
| case 'b': | |
| return childContent.map(c => ({ ...c, bold: true })); | |
| case 'em': | |
| case 'i': | |
| return childContent.map(c => ({ ...c, italics: true })); | |
| case 'u': | |
| return childContent.map(c => ({ ...c, decoration: 'underline' })); | |
| case 'a': { | |
| const href = node.getAttribute('href') || ''; | |
| return childContent.map(c => ({ | |
| ...c, | |
| link: href, | |
| color: '#2563eb', | |
| decoration: 'underline' | |
| })); | |
| } | |
| case 'code': { | |
| return buildInlineCodeTextStyle(node.textContent || ''); | |
| } | |
| case 'pre': { | |
| const codeNode = node.querySelector('code'); | |
| const text = (codeNode || node).textContent || ''; | |
| return { | |
| text: formatPdfTextWithEmoji(text), | |
| font: 'monospace', | |
| fontSize: 9, | |
| background: '#f6f8fa', | |
| margin: [0, 6, 0, 6], | |
| preserveLeadingSpaces: true | |
| }; | |
| } | |
| case 'h1': | |
| return [{ text: formatPdfTextWithEmoji(node.textContent || ''), fontSize: 18, bold: true, margin: [0, 12, 0, 6] }]; | |
| case 'h2': | |
| return [{ text: formatPdfTextWithEmoji(node.textContent || ''), fontSize: 16, bold: true, margin: [0, 10, 0, 5] }]; | |
| case 'h3': | |
| return [{ text: formatPdfTextWithEmoji(node.textContent || ''), fontSize: 14, bold: true, margin: [0, 8, 0, 4] }]; | |
| case 'h4': | |
| return [{ text: formatPdfTextWithEmoji(node.textContent || ''), fontSize: 12, bold: true, margin: [0, 6, 0, 3] }]; | |
| case 'h5': | |
| case 'h6': | |
| return [{ text: formatPdfTextWithEmoji(node.textContent || ''), fontSize: 11, bold: true, margin: [0, 4, 0, 2] }]; | |
| case 'hr': | |
| return [{ | |
| canvas: [ | |
| { type: 'line', x1: 0, y1: 0, x2: 505, y2: 0, lineWidth: 0.5, lineColor: '#cbd5e1' } | |
| ], | |
| margin: [0, 6, 0, 8] | |
| }]; | |
| case 'table': { | |
| const table = buildPdfTableFromHtmlTable(node); | |
| return table || null; | |
| } | |
| case 'ul': { | |
| return buildStructuredPdfList(node, false); | |
| } | |
| case 'ol': { | |
| return buildStructuredPdfList(node, true); | |
| } | |
| case 'li': | |
| return buildListItemPdfContent(childContent, node.textContent || ''); | |
| case 'p': | |
| return withParagraphBreak(childContent, node.textContent || ''); | |
| case 'blockquote': { | |
| const unwrappedForQuote = childContent.flatMap((part) => { | |
| if ( | |
| part && | |
| typeof part === 'object' && | |
| Array.isArray(part.stack) && | |
| !Object.prototype.hasOwnProperty.call(part, 'ul') && | |
| !Object.prototype.hasOwnProperty.call(part, 'ol') && | |
| !Object.prototype.hasOwnProperty.call(part, 'image') && | |
| !Object.prototype.hasOwnProperty.call(part, 'table') | |
| ) { | |
| return part.stack; | |
| } | |
| return [part]; | |
| }); | |
| const quoteContent = composeMixedPdfStack(unwrappedForQuote, node.textContent || ''); | |
| return { | |
| table: { | |
| widths: [0.01, '*'], | |
| body: [[ | |
| { text: '', fillColor: '#e5e7eb', border: [false, false, false, false] }, | |
| { | |
| stack: quoteContent, | |
| fillColor: '#f9fafb', | |
| color: '#475569', | |
| border: [false, false, false, false] | |
| } | |
| ]] | |
| }, | |
| layout: { | |
| hLineWidth: () => 0, | |
| vLineWidth: () => 0, | |
| paddingLeft: (i) => (i === 0 ? 0 : 8), | |
| paddingRight: () => 8, | |
| paddingTop: () => 4, | |
| paddingBottom: () => 4 | |
| }, | |
| margin: [6, 2, 0, 6] | |
| }; | |
| } | |
| case 'br': | |
| return { text: '\n', preserveLeadingSpaces: true }; | |
| case 'div': | |
| case 'span': | |
| return childContent; | |
| default: | |
| return childContent; | |
| } | |
| } | |
| return null; | |
| } | |
| function isGrokInlineCodeLikeElement(node) { | |
| if (!node || !node.className || platform !== 'grok') { | |
| return false; | |
| } | |
| const className = ensureString(node.className); | |
| return ( | |
| className.includes('!font-mono') && | |
| className.includes('rounded-sm') && | |
| (className.includes('bg-orange-400/10') || className.includes('dark:bg-orange-300/10')) && | |
| (className.includes('text-orange-500') || className.includes('dark:text-orange-300')) | |
| ); | |
| } | |
| function buildInlineCodeTextStyle(text, options) { | |
| const opts = options || {}; | |
| const raw = ensureString(text).replace(/\r\n/g, '\n'); | |
| const styled = { | |
| text: formatPdfTextWithEmoji(raw), | |
| font: 'monospace', | |
| fontSize: 9, | |
| color: '#1f2937', | |
| background: '#eef2ff' | |
| }; | |
| if (opts.noWrap !== false) { | |
| styled.noWrap = true; | |
| } | |
| if (opts.preserveLeadingSpaces || raw.includes('\n')) { | |
| styled.preserveLeadingSpaces = true; | |
| } | |
| return styled; | |
| } | |
| function withParagraphBreak(parts, fallbackText) { | |
| const inline = forceInlinePdfText(parts, fallbackText); | |
| return { | |
| stack: [inline], | |
| margin: [0, 0, 0, 6] | |
| }; | |
| } | |
| function getDirectListItems(listNode) { | |
| if (!listNode) { | |
| return []; | |
| } | |
| return Array.from(listNode.children || []).filter((child) => { | |
| return child && child.tagName && child.tagName.toLowerCase() === 'li'; | |
| }); | |
| } | |
| function buildStructuredPdfList(listNode, isOrdered) { | |
| const items = getDirectListItems(listNode); | |
| if (!items.length) { | |
| return null; | |
| } | |
| const start = getOrderedListStart(listNode); | |
| const body = items.map((li, index) => { | |
| const marker = isOrdered ? `${start + index}.` : '•'; | |
| const stack = buildListItemStackFromNode(li); | |
| return [ | |
| { | |
| text: marker, | |
| bold: isOrdered, | |
| noWrap: true, | |
| color: '#334155', | |
| alignment: 'right', | |
| margin: [0, 0, 3, 0], | |
| border: [false, false, false, false] | |
| }, | |
| { | |
| stack: stack.length ? stack : [{ text: '' }], | |
| border: [false, false, false, false] | |
| } | |
| ]; | |
| }); | |
| return { | |
| table: { | |
| widths: [14, '*'], | |
| body: body | |
| }, | |
| layout: { | |
| hLineWidth: () => 0, | |
| vLineWidth: () => 0, | |
| paddingLeft: () => 0, | |
| paddingRight: (i) => (i === 0 ? 2 : 0), | |
| paddingTop: () => 0, | |
| paddingBottom: () => 0 | |
| }, | |
| margin: [0, 2, 0, 2] | |
| }; | |
| } | |
| function getOrderedListStart(listNode) { | |
| if (!listNode || !listNode.getAttribute) { | |
| return 1; | |
| } | |
| const raw = listNode.getAttribute('start'); | |
| if (!raw) { | |
| return 1; | |
| } | |
| const value = Number.parseInt(raw, 10); | |
| return Number.isFinite(value) ? value : 1; | |
| } | |
| function buildListItemStackFromNode(liNode) { | |
| const parts = []; | |
| Array.from(liNode.childNodes || []).forEach((child) => { | |
| const isParagraphNode = | |
| child && | |
| child.nodeType === Node.ELEMENT_NODE && | |
| child.tagName && | |
| child.tagName.toLowerCase() === 'p'; | |
| if (isParagraphNode) { | |
| Array.from(child.childNodes || []).forEach((paragraphChild) => { | |
| const parsed = parseNodeRecursive(paragraphChild); | |
| if (!parsed) { | |
| return; | |
| } | |
| if (Array.isArray(parsed)) { | |
| parts.push(...parsed); | |
| } else { | |
| parts.push(parsed); | |
| } | |
| }); | |
| return; | |
| } | |
| const parsed = parseNodeRecursive(child); | |
| if (!parsed) { | |
| return; | |
| } | |
| if (Array.isArray(parsed)) { | |
| parts.push(...parsed); | |
| } else { | |
| parts.push(parsed); | |
| } | |
| }); | |
| const mixed = composeMixedPdfStack(parts, liNode.textContent || ''); | |
| return normalizeListItemStack(mixed); | |
| } | |
| function normalizeListItemStack(stack) { | |
| const items = Array.isArray(stack) ? stack : []; | |
| return items.map((item) => { | |
| if (!isParagraphStyleStack(item)) { | |
| return item; | |
| } | |
| if (item.stack.length === 1) { | |
| return item.stack[0]; | |
| } | |
| return { stack: item.stack }; | |
| }).filter(Boolean); | |
| } | |
| function isParagraphStyleStack(item) { | |
| if (!item || typeof item !== 'object' || !Array.isArray(item.stack)) { | |
| return false; | |
| } | |
| if (!Array.isArray(item.margin) || item.margin.length !== 4) { | |
| return false; | |
| } | |
| return item.margin[0] === 0 && item.margin[1] === 0 && item.margin[2] === 0 && item.margin[3] === 6; | |
| } | |
| function buildSpecialPdfCodeBlock(node) { | |
| if (!isSpecialCodeBlockElement(node)) { | |
| return null; | |
| } | |
| const codeText = extractCodeBlockText(node); | |
| if (!codeText) { | |
| return null; | |
| } | |
| const richCodeText = | |
| extractChatGptCodeRichInlines(node) || | |
| extractGeminiCodeRichInlines(node) || | |
| extractClaudeCodeRichInlines(node) || | |
| extractGrokCodeRichInlines(node) || | |
| extractDeepSeekCodeRichInlines(node); | |
| const language = extractCodeBlockLanguage(node) || 'Code'; | |
| const headerLabel = language; | |
| return { | |
| table: { | |
| widths: [5, '*'], | |
| body: [ | |
| [ | |
| { text: '', fillColor: '#0ea5e9', border: [false, false, false, false] }, | |
| { | |
| text: headerLabel, | |
| style: 'codeBlockHeader', | |
| fillColor: '#0f172a', | |
| border: [false, false, false, false] | |
| } | |
| ], | |
| [ | |
| { text: '', fillColor: '#334155', border: [false, false, false, false] }, | |
| { | |
| text: richCodeText || formatPdfTextWithEmoji(codeText), | |
| style: 'codeBlockBody', | |
| preserveLeadingSpaces: true, | |
| fillColor: '#1f2937', | |
| border: [false, false, false, false] | |
| } | |
| ] | |
| ] | |
| }, | |
| layout: { | |
| hLineWidth: () => 0, | |
| vLineWidth: () => 0, | |
| paddingLeft: (i) => (i === 0 ? 0 : 12), | |
| paddingRight: () => 12, | |
| paddingTop: (i) => (i === 0 ? 8 : 10), | |
| paddingBottom: (i) => (i === 0 ? 7 : 10) | |
| }, | |
| margin: [0, 8, 0, 12] | |
| }; | |
| } | |
| function extractChatGptCodeRichInlines(node) { | |
| if (platform !== 'chatgpt' || !node || !node.querySelector) { | |
| return null; | |
| } | |
| const cmContent = node.querySelector('.cm-content'); | |
| if (!cmContent) { | |
| return null; | |
| } | |
| const defaultColor = normalizePdfColorValue( | |
| ensureString(window.getComputedStyle(cmContent).color) | |
| ) || PDF_CODE_DEFAULT_TEXT_COLOR; | |
| const parts = []; | |
| appendChatGptCodeInlinesFromNode(cmContent, defaultColor, parts); | |
| const merged = mergeCodeRichInlines(parts); | |
| return merged.length ? merged : null; | |
| } | |
| function appendChatGptCodeInlinesFromNode(node, inheritedColor, out) { | |
| if (!node) { | |
| return; | |
| } | |
| if (node.nodeType === Node.TEXT_NODE) { | |
| const text = node.textContent || ''; | |
| if (!text) { | |
| return; | |
| } | |
| out.push({ | |
| text: text, | |
| color: inheritedColor || PDF_CODE_DEFAULT_TEXT_COLOR | |
| }); | |
| return; | |
| } | |
| if (node.nodeType !== Node.ELEMENT_NODE) { | |
| return; | |
| } | |
| const tagName = (node.tagName || '').toLowerCase(); | |
| let nextColor = inheritedColor || PDF_CODE_DEFAULT_TEXT_COLOR; | |
| if (tagName === 'span') { | |
| const computedColor = normalizePdfColorValue( | |
| ensureString(window.getComputedStyle(node).color) | |
| ); | |
| if (computedColor) { | |
| nextColor = computedColor; | |
| } | |
| } | |
| if (tagName === 'br') { | |
| out.push({ | |
| text: '\n', | |
| color: nextColor | |
| }); | |
| return; | |
| } | |
| if (tagName === 'div' && node.classList && node.classList.contains('cm-content')) { | |
| const cmLines = Array.from(node.children || []).filter((child) => { | |
| return child && child.classList && child.classList.contains('cm-line'); | |
| }); | |
| if (cmLines.length) { | |
| cmLines.forEach((lineNode, index) => { | |
| appendChatGptCodeInlinesFromNode(lineNode, nextColor, out); | |
| if (index < cmLines.length - 1) { | |
| out.push({ text: '\n', color: nextColor }); | |
| } | |
| }); | |
| return; | |
| } | |
| } | |
| Array.from(node.childNodes || []).forEach((child) => { | |
| appendChatGptCodeInlinesFromNode(child, nextColor, out); | |
| }); | |
| } | |
| function extractClaudeCodeRichInlines(node) { | |
| if (platform !== 'claude' || !node || !node.querySelector) { | |
| return null; | |
| } | |
| const codeRoot = | |
| querySelectorScoped(node, ':scope > .overflow-x-auto > pre > code') || | |
| querySelectorScoped(node, ':scope > pre > code') || | |
| node.querySelector('pre.code-block__code > code') || | |
| node.querySelector('pre code'); | |
| if (!codeRoot) { | |
| return null; | |
| } | |
| const defaultColor = resolveInlineColorFromStyleAttr(codeRoot) || PDF_CODE_DEFAULT_TEXT_COLOR; | |
| const parts = []; | |
| appendClaudeCodeInlinesFromNode(codeRoot, defaultColor, parts); | |
| const merged = mergeCodeRichInlines(parts); | |
| return merged.length ? merged : null; | |
| } | |
| function extractGeminiCodeRichInlines(node) { | |
| if (platform !== 'gemini' || !node || !node.querySelector) { | |
| return null; | |
| } | |
| const codeRoot = | |
| querySelectorScoped(node, ':scope > .code-block > .formatted-code-block-internal-container > pre > code[data-test-id="code-content"]') || | |
| querySelectorScoped(node, ':scope > .formatted-code-block-internal-container > pre > code[data-test-id="code-content"]') || | |
| querySelectorScoped(node, ':scope > pre > code[data-test-id="code-content"]') || | |
| node.querySelector('code[data-test-id="code-content"]') || | |
| querySelectorScoped(node, ':scope > pre > code') || | |
| node.querySelector('pre code'); | |
| if (!codeRoot) { | |
| return null; | |
| } | |
| const defaultColor = normalizePdfColorValue( | |
| ensureString(window.getComputedStyle(codeRoot).color) | |
| ) || PDF_CODE_DEFAULT_TEXT_COLOR; | |
| const parts = []; | |
| appendGeminiCodeInlinesFromNode(codeRoot, defaultColor, parts); | |
| const merged = mergeCodeRichInlines(parts); | |
| return merged.length ? merged : null; | |
| } | |
| function extractGrokCodeRichInlines(node) { | |
| if (platform !== 'grok' || !node || !node.querySelector) { | |
| return null; | |
| } | |
| const codeRoot = | |
| querySelectorScoped(node, ':scope > .overflow-x-auto > pre > code') || | |
| querySelectorScoped(node, ':scope > pre > code') || | |
| node.querySelector('pre code'); | |
| if (!codeRoot) { | |
| return null; | |
| } | |
| const defaultColor = resolveInlineColorFromStyleAttr(codeRoot) || PDF_CODE_DEFAULT_TEXT_COLOR; | |
| const parts = []; | |
| const lines = Array.from(codeRoot.querySelectorAll(':scope > span.line')); | |
| if (lines.length) { | |
| lines.forEach((lineNode, index) => { | |
| appendGrokCodeInlinesFromNode(lineNode, defaultColor, parts); | |
| if (index < lines.length - 1) { | |
| parts.push({ text: '\n', color: defaultColor }); | |
| } | |
| }); | |
| } else { | |
| appendGrokCodeInlinesFromNode(codeRoot, defaultColor, parts); | |
| } | |
| const merged = mergeCodeRichInlines(parts); | |
| return merged.length ? merged : null; | |
| } | |
| function extractDeepSeekCodeRichInlines(node) { | |
| if (platform !== 'deepseek' || !node || !node.querySelector) { | |
| return null; | |
| } | |
| const tagName = ensureString(node.tagName).toLowerCase(); | |
| const codeRoot = | |
| (tagName === 'code' || tagName === 'pre' ? node : null) || | |
| querySelectorScoped(node, ':scope > pre > code') || | |
| querySelectorScoped(node, ':scope > pre') || | |
| node.querySelector('pre code') || | |
| node.querySelector('pre') || | |
| node.querySelector('code'); | |
| if (!codeRoot) { | |
| return null; | |
| } | |
| const defaultColor = normalizePdfColorValue( | |
| ensureString(window.getComputedStyle(codeRoot).color) | |
| ) || PDF_CODE_DEFAULT_TEXT_COLOR; | |
| const parts = []; | |
| const directSpanChildren = Array.from(codeRoot.childNodes || []).filter((child) => { | |
| return child && child.nodeType === Node.ELEMENT_NODE && ensureString(child.tagName).toLowerCase() === 'span'; | |
| }); | |
| const allChildrenAreSpans = | |
| directSpanChildren.length > 0 && | |
| directSpanChildren.length === (codeRoot.childNodes || []).length; | |
| if (allChildrenAreSpans) { | |
| directSpanChildren.forEach((lineNode, index) => { | |
| appendDeepSeekCodeInlinesFromNode(lineNode, defaultColor, parts); | |
| if (index < directSpanChildren.length - 1) { | |
| parts.push({ text: '\n', color: defaultColor }); | |
| } | |
| }); | |
| } else { | |
| appendDeepSeekCodeInlinesFromNode(codeRoot, defaultColor, parts); | |
| } | |
| const merged = mergeCodeRichInlines(parts); | |
| return merged.length ? merged : null; | |
| } | |
| function appendClaudeCodeInlinesFromNode(node, inheritedColor, out) { | |
| if (!node) { | |
| return; | |
| } | |
| if (node.nodeType === Node.TEXT_NODE) { | |
| const text = node.textContent || ''; | |
| if (!text) { | |
| return; | |
| } | |
| out.push({ | |
| text: text, | |
| color: inheritedColor || PDF_CODE_DEFAULT_TEXT_COLOR | |
| }); | |
| return; | |
| } | |
| if (node.nodeType !== Node.ELEMENT_NODE) { | |
| return; | |
| } | |
| const tagName = (node.tagName || '').toLowerCase(); | |
| const inlineColor = resolveInlineColorFromStyleAttr(node); | |
| const nextColor = inlineColor || inheritedColor || PDF_CODE_DEFAULT_TEXT_COLOR; | |
| if (tagName === 'br') { | |
| out.push({ | |
| text: '\n', | |
| color: nextColor | |
| }); | |
| return; | |
| } | |
| Array.from(node.childNodes || []).forEach((child) => { | |
| appendClaudeCodeInlinesFromNode(child, nextColor, out); | |
| }); | |
| } | |
| function appendGrokCodeInlinesFromNode(node, inheritedColor, out) { | |
| if (!node) { | |
| return; | |
| } | |
| if (node.nodeType === Node.TEXT_NODE) { | |
| const text = node.textContent || ''; | |
| if (!text) { | |
| return; | |
| } | |
| out.push({ | |
| text: text, | |
| color: inheritedColor || PDF_CODE_DEFAULT_TEXT_COLOR | |
| }); | |
| return; | |
| } | |
| if (node.nodeType !== Node.ELEMENT_NODE) { | |
| return; | |
| } | |
| const tagName = (node.tagName || '').toLowerCase(); | |
| const inlineColor = resolveInlineColorFromStyleAttr(node); | |
| const nextColor = inlineColor || inheritedColor || PDF_CODE_DEFAULT_TEXT_COLOR; | |
| if (tagName === 'br') { | |
| out.push({ | |
| text: '\n', | |
| color: nextColor | |
| }); | |
| return; | |
| } | |
| Array.from(node.childNodes || []).forEach((child) => { | |
| appendGrokCodeInlinesFromNode(child, nextColor, out); | |
| }); | |
| } | |
| function appendGeminiCodeInlinesFromNode(node, inheritedColor, out) { | |
| if (!node) { | |
| return; | |
| } | |
| if (node.nodeType === Node.TEXT_NODE) { | |
| const text = node.textContent || ''; | |
| if (!text) { | |
| return; | |
| } | |
| out.push({ | |
| text: text, | |
| color: inheritedColor || PDF_CODE_DEFAULT_TEXT_COLOR | |
| }); | |
| return; | |
| } | |
| if (node.nodeType !== Node.ELEMENT_NODE) { | |
| return; | |
| } | |
| const tagName = (node.tagName || '').toLowerCase(); | |
| let nextColor = inheritedColor || PDF_CODE_DEFAULT_TEXT_COLOR; | |
| const className = ensureString(node.className); | |
| if (/(?:^|\s)hljs-[\w-]+(?:\s|$)/.test(className)) { | |
| const computedColor = normalizePdfColorValue( | |
| ensureString(window.getComputedStyle(node).color) | |
| ); | |
| if (computedColor) { | |
| nextColor = computedColor; | |
| } | |
| } | |
| if (tagName === 'br') { | |
| out.push({ | |
| text: '\n', | |
| color: nextColor | |
| }); | |
| return; | |
| } | |
| Array.from(node.childNodes || []).forEach((child) => { | |
| appendGeminiCodeInlinesFromNode(child, nextColor, out); | |
| }); | |
| } | |
| function appendDeepSeekCodeInlinesFromNode(node, inheritedColor, out) { | |
| if (!node) { | |
| return; | |
| } | |
| if (node.nodeType === Node.TEXT_NODE) { | |
| const text = node.textContent || ''; | |
| if (!text) { | |
| return; | |
| } | |
| out.push({ | |
| text: text, | |
| color: inheritedColor || PDF_CODE_DEFAULT_TEXT_COLOR | |
| }); | |
| return; | |
| } | |
| if (node.nodeType !== Node.ELEMENT_NODE) { | |
| return; | |
| } | |
| const tagName = (node.tagName || '').toLowerCase(); | |
| let nextColor = inheritedColor || PDF_CODE_DEFAULT_TEXT_COLOR; | |
| if (tagName === 'span') { | |
| const className = ensureString(node.className); | |
| const hasPrismTokenClass = /(?:^|\s)token(?:\s|$)/.test(className); | |
| if (hasPrismTokenClass || className) { | |
| const computedColor = normalizePdfColorValue( | |
| ensureString(window.getComputedStyle(node).color) | |
| ); | |
| if (computedColor) { | |
| nextColor = computedColor; | |
| } | |
| } | |
| } | |
| if (tagName === 'br') { | |
| out.push({ | |
| text: '\n', | |
| color: nextColor | |
| }); | |
| return; | |
| } | |
| Array.from(node.childNodes || []).forEach((child) => { | |
| appendDeepSeekCodeInlinesFromNode(child, nextColor, out); | |
| }); | |
| } | |
| function resolveInlineColorFromStyleAttr(node) { | |
| if (!node || !node.getAttribute) { | |
| return ''; | |
| } | |
| const styleAttr = ensureString(node.getAttribute('style')); | |
| if (styleAttr) { | |
| const match = styleAttr.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i); | |
| if (match && match[1]) { | |
| const normalized = normalizePdfColorValue(match[1]); | |
| if (normalized) { | |
| return normalized; | |
| } | |
| } | |
| } | |
| const inlineStyleColor = ensureString(node.style && node.style.color).trim(); | |
| const normalizedInline = normalizePdfColorValue(inlineStyleColor); | |
| if (normalizedInline) { | |
| return normalizedInline; | |
| } | |
| try { | |
| const computedColor = ensureString(window.getComputedStyle(node).color).trim(); | |
| return normalizePdfColorValue(computedColor); | |
| } catch (err) { | |
| return ''; | |
| } | |
| } | |
| function normalizePdfColorValue(colorValue) { | |
| const raw = ensureString(colorValue).trim(); | |
| if (!raw) { | |
| return ''; | |
| } | |
| const hexMatch = raw.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); | |
| if (hexMatch) { | |
| if (hexMatch[1].length === 3) { | |
| const shortHex = hexMatch[1].toLowerCase(); | |
| return `#${shortHex[0]}${shortHex[0]}${shortHex[1]}${shortHex[1]}${shortHex[2]}${shortHex[2]}`; | |
| } | |
| return `#${hexMatch[1].toLowerCase()}`; | |
| } | |
| const rgbMatch = raw.match(/^rgba?\(([^)]+)\)$/i); | |
| if (!rgbMatch) { | |
| return ''; | |
| } | |
| const channels = rgbMatch[1] | |
| .split(/[,\s/]+/) | |
| .map((entry) => entry.trim()) | |
| .filter(Boolean); | |
| if (channels.length < 3) { | |
| return ''; | |
| } | |
| const r = normalizeRgbChannel(channels[0]); | |
| const g = normalizeRgbChannel(channels[1]); | |
| const b = normalizeRgbChannel(channels[2]); | |
| if (r === null || g === null || b === null) { | |
| return ''; | |
| } | |
| return `#${channelToHex(r)}${channelToHex(g)}${channelToHex(b)}`; | |
| } | |
| function normalizeRgbChannel(value) { | |
| const raw = ensureString(value).trim(); | |
| if (!raw) { | |
| return null; | |
| } | |
| if (raw.endsWith('%')) { | |
| const percentage = Number.parseFloat(raw.slice(0, -1)); | |
| if (!Number.isFinite(percentage)) { | |
| return null; | |
| } | |
| const scaled = Math.round((Math.max(0, Math.min(100, percentage)) / 100) * 255); | |
| return scaled; | |
| } | |
| const numeric = Number.parseFloat(raw); | |
| if (!Number.isFinite(numeric)) { | |
| return null; | |
| } | |
| return Math.max(0, Math.min(255, Math.round(numeric))); | |
| } | |
| function channelToHex(value) { | |
| return value.toString(16).padStart(2, '0'); | |
| } | |
| function mergeCodeRichInlines(parts) { | |
| const merged = []; | |
| (parts || []).forEach((part) => { | |
| if (!part || typeof part.text !== 'string' || !part.text) { | |
| return; | |
| } | |
| const previous = merged[merged.length - 1]; | |
| if ( | |
| previous && | |
| previous.color === part.color && | |
| previous.text !== '\n' && | |
| part.text !== '\n' | |
| ) { | |
| previous.text += part.text; | |
| return; | |
| } | |
| merged.push({ | |
| text: part.text, | |
| color: part.color || PDF_CODE_DEFAULT_TEXT_COLOR | |
| }); | |
| }); | |
| return merged; | |
| } | |
| function isSpecialCodeBlockElement(node) { | |
| if (!node || !node.matches) { | |
| return false; | |
| } | |
| const className = ensureString(node.className); | |
| if (node.matches('div[data-testid="code-block"]')) { | |
| return true; // Grok | |
| } | |
| if (node.matches('pre[data-start][data-end]')) { | |
| return true; // ChatGPT | |
| } | |
| if (node.matches('#code-block-viewer')) { | |
| return true; // ChatGPT | |
| } | |
| if ( | |
| platform === 'deepseek' && | |
| node.matches('pre, code') && | |
| ( | |
| node.querySelector('span.token, span[class*="token "]') || | |
| /(?:^|\s)language-[\w+.-]+(?:\s|$)/i.test(ensureString(node.className)) | |
| ) | |
| ) { | |
| return true; // DeepSeek | |
| } | |
| if ( | |
| node.matches('code-block, div.code-block') && | |
| ( | |
| node.querySelector('code[data-test-id="code-content"]') || | |
| node.querySelector('.formatted-code-block-internal-container pre code') | |
| ) | |
| ) { | |
| return true; // Gemini | |
| } | |
| if (node.matches('div.md-code-block, div[class*="md-code-block"]')) { | |
| return true; // DeepSeek | |
| } | |
| if ( | |
| className.includes('group/copy') && | |
| (node.querySelector('pre.code-block__code') || node.querySelector('code.language-javascript, code[class*="language-"]')) | |
| ) { | |
| return true; // Claude | |
| } | |
| return false; | |
| } | |
| function extractCodeBlockLanguage(node) { | |
| if (platform === 'chatgpt') { | |
| const chatGptHeader = | |
| node.querySelector('div.text-token-text-primary') || | |
| node.querySelector('.text-token-text-primary'); | |
| if (chatGptHeader) { | |
| const value = normalizeText(chatGptHeader.textContent || ''); | |
| if (value) { | |
| return value; | |
| } | |
| } | |
| } | |
| if (platform === 'gemini') { | |
| const geminiHeader = | |
| querySelectorScoped(node, ':scope > .code-block > .code-block-decoration span') || | |
| querySelectorScoped(node, ':scope > .code-block-decoration span') || | |
| node.querySelector('.code-block-decoration.header-formatted span') || | |
| node.querySelector('.code-block-decoration span') || | |
| node.querySelector('.code-block-decoration'); | |
| if (geminiHeader) { | |
| const value = normalizeText(geminiHeader.textContent || ''); | |
| if (value) { | |
| return value; | |
| } | |
| } | |
| } | |
| if (platform === 'deepseek') { | |
| const deepSeekContainer = (node.closest && node.closest('.md-code-block')) || node; | |
| const deepSeekHeader = | |
| deepSeekContainer.querySelector('.md-code-block-banner .d813de27') || | |
| deepSeekContainer.querySelector('.md-code-block-banner [class*="d813de27"]') || | |
| deepSeekContainer.querySelector('.code-info-button-text'); | |
| if (deepSeekHeader) { | |
| const value = normalizeText(deepSeekHeader.textContent || ''); | |
| if (value && !/^(copy|download|copier|télécharger)$/i.test(value)) { | |
| return value; | |
| } | |
| } | |
| } | |
| const explicitLanguage = | |
| node.querySelector('.code-block-decoration span') || | |
| node.querySelector('.text-text-500') || | |
| node.querySelector('.md-code-block-banner .d813de27') || | |
| node.querySelector('.md-code-block-banner [class*="d813de27"]') || | |
| node.querySelector('[class*="code-info-language"]'); | |
| if (explicitLanguage) { | |
| const value = normalizeText(explicitLanguage.textContent || ''); | |
| if (value) { | |
| return value; | |
| } | |
| } | |
| const codeClassSource = node.querySelector('pre code, code'); | |
| if (codeClassSource && codeClassSource.className) { | |
| const classMatch = String(codeClassSource.className).match(/(?:^|\s)language-([a-z0-9_+.-]+)/i); | |
| if (classMatch && classMatch[1]) { | |
| return classMatch[1]; | |
| } | |
| } | |
| const labels = Array.from(node.querySelectorAll('span, div')) | |
| .filter((el) => !el.closest('pre, code, .cm-content, .cm-line')) | |
| .map((el) => normalizeText(el.textContent || '')) | |
| .filter(Boolean); | |
| const blocked = new Set(['Copier', 'Copy', 'Envelopper', 'Wrap', 'Exécuter', 'Run', 'Download', 'Télécharger']); | |
| for (const label of labels) { | |
| if (blocked.has(label)) { | |
| continue; | |
| } | |
| if (label.length < 2 || label.length > 24) { | |
| continue; | |
| } | |
| if (!/^[A-Za-z][A-Za-z0-9+#.\- ]*$/.test(label)) { | |
| continue; | |
| } | |
| return label; | |
| } | |
| return ''; | |
| } | |
| function extractCodeBlockText(node) { | |
| const geminiCodeNode = | |
| querySelectorScoped(node, ':scope > .code-block > .formatted-code-block-internal-container > pre > code[data-test-id="code-content"]') || | |
| querySelectorScoped(node, ':scope > .formatted-code-block-internal-container > pre > code[data-test-id="code-content"]') || | |
| querySelectorScoped(node, ':scope > pre > code[data-test-id="code-content"]') || | |
| node.querySelector('code[data-test-id="code-content"]'); | |
| if (geminiCodeNode) { | |
| return normalizeCodeText(geminiCodeNode.innerText || geminiCodeNode.textContent || ''); | |
| } | |
| const scopedCodeNode = | |
| querySelectorScoped(node, ':scope > .overflow-x-auto > pre > code') || | |
| querySelectorScoped(node, ':scope > pre > code') || | |
| node.querySelector('pre.code-block__code > code') || | |
| node.querySelector('pre code'); | |
| if (scopedCodeNode) { | |
| const lines = Array.from(scopedCodeNode.querySelectorAll(':scope > span.line')); | |
| if (lines.length) { | |
| return normalizeCodeText(lines.map((line) => line.textContent || '').join('\n')); | |
| } | |
| return normalizeCodeText(scopedCodeNode.innerText || scopedCodeNode.textContent || ''); | |
| } | |
| const cmContent = node.querySelector('.cm-content'); | |
| if (cmContent) { | |
| return normalizeCodeText(cmContent.innerText || cmContent.textContent || ''); | |
| } | |
| const scopedPre = | |
| querySelectorScoped(node, ':scope > .overflow-x-auto > pre') || | |
| querySelectorScoped(node, ':scope > pre') || | |
| node.querySelector('pre.code-block__code') || | |
| node.querySelector('pre'); | |
| if (scopedPre) { | |
| return normalizeCodeText(scopedPre.innerText || scopedPre.textContent || ''); | |
| } | |
| return normalizeCodeText(node.innerText || node.textContent || ''); | |
| } | |
| function querySelectorScoped(node, selector) { | |
| try { | |
| return node.querySelector(selector); | |
| } catch (err) { | |
| return null; | |
| } | |
| } | |
| function normalizeCodeText(value) { | |
| return ensureString(value) | |
| .replace(/\r\n/g, '\n') | |
| .replace(/\u00a0/g, ' ') | |
| .replace(/\n{3,}/g, '\n\n') | |
| .replace(/\s+$/g, ''); | |
| } | |
| function forceInlinePdfText(parts, fallbackText) { | |
| const inline = buildInlinePdfText(parts); | |
| if (inline) { | |
| return inline; | |
| } | |
| const raw = ensureString(fallbackText) | |
| .replace(/\s*\n+\s*/g, ' ') | |
| .replace(/[ \t]{2,}/g, ' ') | |
| .trim(); | |
| return { text: formatPdfTextWithEmoji(raw) }; | |
| } | |
| function buildListItemPdfContent(parts, fallbackText) { | |
| const flattened = flattenPdfParts(parts).filter(Boolean); | |
| const hasBlockChildren = flattened.some((part) => !isInlinePdfTextPart(part)); | |
| if (!hasBlockChildren) { | |
| return forceInlinePdfText(flattened, fallbackText); | |
| } | |
| const stack = composeMixedPdfStack(flattened, fallbackText); | |
| if (stack.length === 1) { | |
| return stack[0]; | |
| } | |
| return { stack: stack }; | |
| } | |
| function composeMixedPdfStack(parts, fallbackText) { | |
| const flattened = flattenPdfParts(parts).filter(Boolean); | |
| const stack = []; | |
| let inlineBuffer = []; | |
| const flushInline = () => { | |
| if (!inlineBuffer.length) { | |
| return; | |
| } | |
| const inline = buildInlinePdfText(inlineBuffer); | |
| if (inline) { | |
| stack.push(inline); | |
| } else { | |
| const fallbackInlineText = normalizeText(extractPlainTextFromPdfParts(inlineBuffer)); | |
| if (fallbackInlineText) { | |
| stack.push({ text: formatPdfTextWithEmoji(fallbackInlineText) }); | |
| } | |
| } | |
| inlineBuffer = []; | |
| }; | |
| flattened.forEach((part) => { | |
| if (isInlinePdfTextPart(part)) { | |
| inlineBuffer.push(part); | |
| return; | |
| } | |
| flushInline(); | |
| stack.push(part); | |
| }); | |
| flushInline(); | |
| if (!stack.length) { | |
| const raw = normalizeText(ensureString(fallbackText)); | |
| if (raw) { | |
| stack.push({ text: formatPdfTextWithEmoji(raw) }); | |
| } | |
| } | |
| return stack; | |
| } | |
| function extractPlainTextFromPdfParts(parts) { | |
| return (parts || []) | |
| .map((part) => extractPlainTextFromPdfValue(part)) | |
| .join(' '); | |
| } | |
| function extractPlainTextFromPdfValue(value) { | |
| if (value === null || value === undefined) { | |
| return ''; | |
| } | |
| if (typeof value === 'string') { | |
| return value; | |
| } | |
| if (Array.isArray(value)) { | |
| return value.map((entry) => extractPlainTextFromPdfValue(entry)).join(''); | |
| } | |
| if (typeof value === 'object') { | |
| if (Object.prototype.hasOwnProperty.call(value, 'text')) { | |
| return extractPlainTextFromPdfValue(value.text); | |
| } | |
| return ''; | |
| } | |
| return ''; | |
| } | |
| function buildPdfTableFromHtmlTable(tableNode) { | |
| if (!tableNode || !tableNode.querySelectorAll) { | |
| return null; | |
| } | |
| let rows = []; | |
| try { | |
| rows = Array.from(tableNode.querySelectorAll(':scope > thead > tr, :scope > tbody > tr, :scope > tfoot > tr, :scope > tr')); | |
| } catch (err) { | |
| rows = Array.from(tableNode.querySelectorAll('tr')); | |
| } | |
| if (!rows.length) { | |
| return null; | |
| } | |
| const body = []; | |
| let maxCols = 0; | |
| rows.forEach((row, rowIndex) => { | |
| const cells = Array.from(row.children).filter((cell) => { | |
| const tag = (cell.tagName || '').toLowerCase(); | |
| return tag === 'th' || tag === 'td'; | |
| }); | |
| if (!cells.length) { | |
| return; | |
| } | |
| maxCols = Math.max(maxCols, cells.length); | |
| const isHeaderRow = isTableHeaderRow(row, rowIndex); | |
| const mapped = cells.map((cell) => { | |
| const content = parseTableCellContent(cell); | |
| return { | |
| stack: Array.isArray(content) ? content : [content], | |
| fillColor: isHeaderRow ? '#f1f5f9' : '#ffffff', | |
| color: '#0f172a', | |
| bold: isHeaderRow | |
| }; | |
| }); | |
| body.push(mapped); | |
| }); | |
| if (!body.length || !maxCols) { | |
| return null; | |
| } | |
| body.forEach((row) => { | |
| while (row.length < maxCols) { | |
| row.push({ text: '' }); | |
| } | |
| }); | |
| return { | |
| table: { | |
| widths: new Array(maxCols).fill('*'), | |
| body: body | |
| }, | |
| layout: { | |
| hLineWidth: () => 0.5, | |
| vLineWidth: () => 0.5, | |
| hLineColor: () => '#cbd5e1', | |
| vLineColor: () => '#cbd5e1', | |
| paddingLeft: () => 6, | |
| paddingRight: () => 6, | |
| paddingTop: () => 5, | |
| paddingBottom: () => 5 | |
| }, | |
| margin: [0, 8, 0, 12] | |
| }; | |
| } | |
| function isTableHeaderRow(row, rowIndex) { | |
| const parentTag = ((row.parentElement && row.parentElement.tagName) || '').toLowerCase(); | |
| if (parentTag === 'thead') { | |
| return true; | |
| } | |
| const cells = Array.from(row.children).filter((cell) => { | |
| const tag = (cell.tagName || '').toLowerCase(); | |
| return tag === 'th' || tag === 'td'; | |
| }); | |
| if (!cells.length) { | |
| return false; | |
| } | |
| const allTh = cells.every((cell) => (cell.tagName || '').toLowerCase() === 'th'); | |
| if (allTh) { | |
| return true; | |
| } | |
| return rowIndex === 0 && cells.some((cell) => (cell.tagName || '').toLowerCase() === 'th'); | |
| } | |
| function parseTableCellContent(cell) { | |
| const parsed = parseNodeToPdfMake(cell); | |
| if (!parsed) { | |
| return { text: '' }; | |
| } | |
| if (Array.isArray(parsed)) { | |
| const inline = buildInlinePdfText(parsed); | |
| if (inline) { | |
| return inline; | |
| } | |
| return { stack: parsed }; | |
| } | |
| return parsed; | |
| } | |
| function buildInlinePdfText(parts) { | |
| const flattened = flattenPdfParts(parts).filter(Boolean); | |
| if (!flattened.length) { | |
| return null; | |
| } | |
| const inlineParts = []; | |
| for (const part of flattened) { | |
| if (!isInlinePdfTextPart(part)) { | |
| return null; | |
| } | |
| const normalizedPart = normalizeInlinePdfPart(part); | |
| if (!normalizedPart.text && normalizedPart.text !== 0) { | |
| continue; | |
| } | |
| inlineParts.push(normalizedPart); | |
| } | |
| if (!inlineParts.length) { | |
| return null; | |
| } | |
| stabilizeInlineCodeSpacing(inlineParts); | |
| if (inlineParts.length === 1) { | |
| return inlineParts[0]; | |
| } | |
| return { text: inlineParts }; | |
| } | |
| function stabilizeInlineCodeSpacing(parts) { | |
| for (let index = 0; index < parts.length; index += 1) { | |
| const current = parts[index]; | |
| if (!isInlineCodeStyledPart(current)) { | |
| continue; | |
| } | |
| if (index > 0) { | |
| replaceTrailingSpaceWithNbsp(parts[index - 1]); | |
| } | |
| if (index < parts.length - 1) { | |
| replaceLeadingSpaceWithNbsp(parts[index + 1]); | |
| } | |
| } | |
| } | |
| function isInlineCodeStyledPart(part) { | |
| if (!part || typeof part !== 'object') { | |
| return false; | |
| } | |
| return (part.font === 'Courier' || part.font === 'monospace') && typeof part.background === 'string'; | |
| } | |
| function replaceTrailingSpaceWithNbsp(part) { | |
| updateBoundarySpace(part, true); | |
| } | |
| function replaceLeadingSpaceWithNbsp(part) { | |
| updateBoundarySpace(part, false); | |
| } | |
| function updateBoundarySpace(value, fromEnd) { | |
| if (typeof value === 'string') { | |
| return fromEnd | |
| ? value.replace(/ $/, '\u00a0') | |
| : value.replace(/^ /, '\u00a0'); | |
| } | |
| if (Array.isArray(value)) { | |
| if (!value.length) { | |
| return value; | |
| } | |
| if (fromEnd) { | |
| for (let i = value.length - 1; i >= 0; i -= 1) { | |
| const next = updateBoundarySpace(value[i], true); | |
| value[i] = next; | |
| if (valueHasVisibleText(next)) { | |
| break; | |
| } | |
| } | |
| } else { | |
| for (let i = 0; i < value.length; i += 1) { | |
| const next = updateBoundarySpace(value[i], false); | |
| value[i] = next; | |
| if (valueHasVisibleText(next)) { | |
| break; | |
| } | |
| } | |
| } | |
| return value; | |
| } | |
| if (value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, 'text')) { | |
| value.text = updateBoundarySpace(value.text, fromEnd); | |
| return value; | |
| } | |
| return value; | |
| } | |
| function valueHasVisibleText(value) { | |
| if (typeof value === 'string') { | |
| return value.length > 0; | |
| } | |
| if (Array.isArray(value)) { | |
| return value.some((entry) => valueHasVisibleText(entry)); | |
| } | |
| if (value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, 'text')) { | |
| return valueHasVisibleText(value.text); | |
| } | |
| return false; | |
| } | |
| function normalizeInlinePdfPart(part) { | |
| const clone = Object.assign({}, part); | |
| clone.text = normalizeInlinePdfTextValue(clone.text, Boolean(clone.preserveLeadingSpaces)); | |
| return clone; | |
| } | |
| function normalizeInlinePdfTextValue(value, keepWhitespace) { | |
| if (typeof value === 'string') { | |
| if (keepWhitespace) { | |
| return value; | |
| } | |
| return value | |
| .replace(/\s*\n+\s*/g, ' ') | |
| .replace(/[ \t]{2,}/g, ' '); | |
| } | |
| if (Array.isArray(value)) { | |
| return value.map((entry) => normalizeInlinePdfTextValue(entry, keepWhitespace)); | |
| } | |
| if (value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, 'text')) { | |
| const entry = Object.assign({}, value); | |
| entry.text = normalizeInlinePdfTextValue( | |
| entry.text, | |
| keepWhitespace || Boolean(entry.preserveLeadingSpaces) | |
| ); | |
| return entry; | |
| } | |
| return value; | |
| } | |
| function flattenPdfParts(parts) { | |
| const output = []; | |
| (parts || []).forEach((part) => { | |
| if (!part) { | |
| return; | |
| } | |
| if (Array.isArray(part)) { | |
| output.push(...flattenPdfParts(part)); | |
| return; | |
| } | |
| output.push(part); | |
| }); | |
| return output; | |
| } | |
| function isInlinePdfTextPart(part) { | |
| if (!part || typeof part !== 'object') { | |
| return false; | |
| } | |
| if (!Object.prototype.hasOwnProperty.call(part, 'text')) { | |
| return false; | |
| } | |
| if (Object.prototype.hasOwnProperty.call(part, 'canvas')) { | |
| return false; | |
| } | |
| if (Object.prototype.hasOwnProperty.call(part, 'table')) { | |
| return false; | |
| } | |
| if (Object.prototype.hasOwnProperty.call(part, 'ul')) { | |
| return false; | |
| } | |
| if (Object.prototype.hasOwnProperty.call(part, 'ol')) { | |
| return false; | |
| } | |
| if (Object.prototype.hasOwnProperty.call(part, 'stack')) { | |
| return false; | |
| } | |
| if (Object.prototype.hasOwnProperty.call(part, 'columns')) { | |
| return false; | |
| } | |
| if (Object.prototype.hasOwnProperty.call(part, 'image')) { | |
| return false; | |
| } | |
| return true; | |
| } | |
| function normalizePdfContentForPlatform(content) { | |
| if (platform !== 'grok') { | |
| return content; | |
| } | |
| return normalizeGrokPdfContentNode(content, false); | |
| } | |
| function normalizeGrokPdfContentNode(node, inCodeBlock) { | |
| if (node === null || node === undefined) { | |
| return node; | |
| } | |
| if (typeof node === 'string') { | |
| return inCodeBlock ? node : normalizePdfPipelineText(node); | |
| } | |
| if (Array.isArray(node)) { | |
| return node.map((entry) => normalizeGrokPdfContentNode(entry, inCodeBlock)); | |
| } | |
| if (typeof node !== 'object') { | |
| return node; | |
| } | |
| const next = Object.assign({}, node); | |
| const thisIsCodeBlock = inCodeBlock || isPdfCodeBlockNode(next); | |
| if (Object.prototype.hasOwnProperty.call(next, 'text')) { | |
| next.text = normalizePdfTextValue( | |
| next.text, | |
| thisIsCodeBlock, | |
| Boolean(next.preserveLeadingSpaces) | |
| ); | |
| } | |
| if (Array.isArray(next.stack)) { | |
| next.stack = next.stack.map((entry) => normalizeGrokPdfContentNode(entry, thisIsCodeBlock)); | |
| } | |
| if (Array.isArray(next.ul)) { | |
| next.ul = next.ul.map((entry) => normalizeGrokPdfContentNode(entry, thisIsCodeBlock)); | |
| } | |
| if (Array.isArray(next.ol)) { | |
| next.ol = next.ol.map((entry) => normalizeGrokPdfContentNode(entry, thisIsCodeBlock)); | |
| } | |
| if (Array.isArray(next.columns)) { | |
| next.columns = next.columns.map((entry) => normalizeGrokPdfContentNode(entry, thisIsCodeBlock)); | |
| } | |
| if (next.table && Array.isArray(next.table.body)) { | |
| next.table = Object.assign({}, next.table, { | |
| body: next.table.body.map((row) => { | |
| if (!Array.isArray(row)) { | |
| return row; | |
| } | |
| return row.map((cell) => normalizeGrokPdfContentNode(cell, thisIsCodeBlock)); | |
| }) | |
| }); | |
| } | |
| return next; | |
| } | |
| function normalizePdfTextValue(value, inCodeBlock, keepWhitespace) { | |
| if (value === null || value === undefined) { | |
| return value; | |
| } | |
| if (typeof value === 'string') { | |
| if (inCodeBlock || keepWhitespace) { | |
| return value; | |
| } | |
| return normalizePdfPipelineText(value); | |
| } | |
| if (Array.isArray(value)) { | |
| return value.map((entry) => normalizePdfTextValue(entry, inCodeBlock, keepWhitespace)); | |
| } | |
| if (typeof value === 'object') { | |
| const next = Object.assign({}, value); | |
| const nestedCodeBlock = inCodeBlock || isPdfCodeBlockNode(next); | |
| if (Object.prototype.hasOwnProperty.call(next, 'text')) { | |
| const keepTextWhitespace = keepWhitespace || Boolean(next.preserveLeadingSpaces); | |
| next.text = normalizePdfTextValue(next.text, nestedCodeBlock, keepTextWhitespace); | |
| } | |
| return next; | |
| } | |
| return value; | |
| } | |
| function isPdfCodeBlockNode(node) { | |
| if (!node || typeof node !== 'object') { | |
| return false; | |
| } | |
| if (node.style === 'codeBlockBody') { | |
| return true; | |
| } | |
| if ((node.font === 'Courier' || node.font === 'monospace') && node.preserveLeadingSpaces && node.noWrap !== false) { | |
| return true; | |
| } | |
| return false; | |
| } | |
| async function requestRemoteText(url, label) { | |
| if (typeof GM_xmlhttpRequest === 'function') { | |
| return gmXmlHttpRequestPromise({ | |
| method: 'GET', | |
| url: url, | |
| responseType: 'text', | |
| label: label | |
| }); | |
| } | |
| const response = await fetch(url, { | |
| cache: 'force-cache', | |
| credentials: 'omit' | |
| }); | |
| if (!response || !response.ok) { | |
| throw new Error(`${label || 'Remote text'} HTTP ${response && response.status}`); | |
| } | |
| return await response.text(); | |
| } | |
| async function requestRemoteArrayBuffer(url, label, onProgress) { | |
| if (typeof GM_xmlhttpRequest === 'function') { | |
| return gmXmlHttpRequestPromise({ | |
| method: 'GET', | |
| url: url, | |
| responseType: 'arraybuffer', | |
| label: label, | |
| onProgress: onProgress | |
| }); | |
| } | |
| const response = await fetch(url, { | |
| cache: 'force-cache', | |
| credentials: 'omit' | |
| }); | |
| if (!response || !response.ok) { | |
| throw new Error(`${label || 'Remote binary'} HTTP ${response && response.status}`); | |
| } | |
| return await readRemoteFontBuffer(response, onProgress); | |
| } | |
| function gmXmlHttpRequestPromise(options) { | |
| return new Promise((resolve, reject) => { | |
| try { | |
| GM_xmlhttpRequest({ | |
| method: options.method || 'GET', | |
| url: options.url, | |
| responseType: options.responseType, | |
| anonymous: true, | |
| onprogress: (event) => { | |
| if (typeof options.onProgress === 'function') { | |
| const loaded = Number(event && event.loaded) || 0; | |
| const total = Number(event && event.total) || 0; | |
| options.onProgress(loaded, total, !(event && event.lengthComputable && total > 0)); | |
| } | |
| }, | |
| onload: (response) => { | |
| const status = Number(response && response.status) || 0; | |
| if (status < 200 || status >= 300) { | |
| reject(new Error(`${options.label || 'Remote request'} HTTP ${status || 'error'}`)); | |
| return; | |
| } | |
| if (options.responseType === 'arraybuffer') { | |
| resolve(response.response); | |
| return; | |
| } | |
| resolve(response.responseText != null ? response.responseText : response.response); | |
| }, | |
| onerror: (error) => { | |
| reject(new Error(`${options.label || 'Remote request'} failed: ${String((error && error.error) || error || '')}`)); | |
| }, | |
| ontimeout: () => { | |
| reject(new Error(`${options.label || 'Remote request'} timed out`)); | |
| } | |
| }); | |
| } catch (err) { | |
| reject(err); | |
| } | |
| }); | |
| } | |
| function encodeBase64Utf8(value) { | |
| const input = ensureString(value); | |
| if (!input) { | |
| return ''; | |
| } | |
| if (typeof TextEncoder === 'function') { | |
| const bytes = new TextEncoder().encode(input); | |
| const chunkSize = 0x8000; | |
| let binary = ''; | |
| for (let index = 0; index < bytes.length; index += chunkSize) { | |
| const chunk = bytes.subarray(index, index + chunkSize); | |
| binary += String.fromCharCode.apply(null, chunk); | |
| } | |
| return btoa(binary); | |
| } | |
| return btoa(unescape(encodeURIComponent(input))); | |
| } | |
| async function importFrancLanguageDetectorFromSource(source) { | |
| const raw = ensureString(source) | |
| .replace(/\/\/# sourceMappingURL=.*$/gm, '') | |
| .trim(); | |
| if (!raw) { | |
| throw new Error('Language detector source is empty'); | |
| } | |
| const dataUrl = `data:text/javascript;base64,${encodeBase64Utf8(raw)}`; | |
| const blobUrl = URL.createObjectURL(new Blob([raw], { type: 'text/javascript' })); | |
| try { | |
| try { | |
| return await import(blobUrl); | |
| } catch (blobErr) { | |
| return await import(dataUrl); | |
| } | |
| } finally { | |
| window.setTimeout(() => URL.revokeObjectURL(blobUrl), 0); | |
| } | |
| } | |
| async function loadPdfLanguageDetector() { | |
| if (!languageDetectorModulePromise) { | |
| languageDetectorModulePromise = (async () => { | |
| try { | |
| const source = await requestRemoteText(PDF_LANGUAGE_DETECTOR_URL, 'Language detector'); | |
| const imported = await importFrancLanguageDetectorFromSource(source); | |
| if (imported && typeof imported.francAll === 'function') { | |
| return imported; | |
| } | |
| throw new Error('Language detector module is missing francAll'); | |
| } catch (err) { | |
| console.warn('OmniChat: language detector unavailable for PDF export', err); | |
| return null; | |
| } | |
| })(); | |
| } | |
| return languageDetectorModulePromise; | |
| } | |
| function buildPdfLanguageProfile(messages, detectorModule, extraTexts) { | |
| const detectedScripts = new Set(); | |
| const sampleCandidates = []; | |
| let containsEmoji = false; | |
| const scanText = (rawText) => { | |
| const value = ensureString(rawText); | |
| if (!value) { | |
| return; | |
| } | |
| detectPdfScriptsInText(value, detectedScripts); | |
| if (!containsEmoji && containsEmojiForPdf(value)) { | |
| containsEmoji = true; | |
| } | |
| collectPdfLanguageSamples(value, sampleCandidates); | |
| }; | |
| messages.forEach((message) => { | |
| scanText(message && message.text); | |
| }); | |
| (extraTexts || []).forEach((entry) => { | |
| scanText(entry); | |
| }); | |
| if (!detectedScripts.size) { | |
| detectedScripts.add('latin'); | |
| } | |
| const languageScores = Object.create(null); | |
| const detector = detectorModule && typeof detectorModule.francAll === 'function' | |
| ? detectorModule.francAll | |
| : null; | |
| if (detector) { | |
| const selectedSamples = selectPdfLanguageSamples(sampleCandidates, PDF_LANGUAGE_SAMPLE_LIMIT); | |
| selectedSamples.forEach((sample) => { | |
| const results = detector(sample.text, { minLength: PDF_LANGUAGE_MIN_LENGTH }); | |
| if (!Array.isArray(results) || !results.length) { | |
| return; | |
| } | |
| results.slice(0, 3).forEach((entry, index) => { | |
| if (!Array.isArray(entry) || entry.length < 2) { | |
| return; | |
| } | |
| const lang = ensureString(entry[0]).trim(); | |
| const score = Number(entry[1]); | |
| if (!lang || lang === 'und' || !Number.isFinite(score) || score < 0.15) { | |
| return; | |
| } | |
| const weight = sample.weight * score * (index === 0 ? 1 : 0.35); | |
| languageScores[lang] = (languageScores[lang] || 0) + weight; | |
| }); | |
| }); | |
| } | |
| const detectedLanguages = Object.entries(languageScores) | |
| .sort((a, b) => b[1] - a[1]) | |
| .map((entry) => mapFrancLanguageCode(entry[0])) | |
| .filter((value, index, self) => value && self.indexOf(value) === index) | |
| .slice(0, 8); | |
| addFallbackLanguagesFromScripts(detectedLanguages, detectedScripts); | |
| const mainLanguage = detectedLanguages[0] || fallbackLanguageFromScripts(detectedScripts) || 'und'; | |
| const profile = { | |
| mainLanguage: mainLanguage, | |
| detectedLanguages: detectedLanguages, | |
| detectedScripts: Array.from(detectedScripts), | |
| containsEmoji: containsEmoji | |
| }; | |
| console.info('OmniChat PDF language detection:', { | |
| mainLanguage: profile.mainLanguage, | |
| detectedLanguages: profile.detectedLanguages, | |
| detectedScripts: profile.detectedScripts | |
| }); | |
| return profile; | |
| } | |
| function detectPdfScriptsInText(text, detectedScripts) { | |
| const value = ensureString(text); | |
| if (!value) { | |
| return; | |
| } | |
| const segments = value.split(/\n+/); | |
| segments.forEach((segment) => { | |
| if (!segment) { | |
| return; | |
| } | |
| const hasJapanese = PDF_SCRIPT_DETECTION_PATTERNS.japanese.test(segment); | |
| const hasKorean = PDF_SCRIPT_DETECTION_PATTERNS.korean.test(segment); | |
| const hasHan = PDF_HAN_PATTERN.test(segment); | |
| const hasCjkSymbols = PDF_CJK_SYMBOL_PATTERN.test(segment); | |
| if (PDF_SCRIPT_DETECTION_PATTERNS.latin.test(segment) || PDF_SCRIPT_DETECTION_PATTERNS.latinExtended.test(segment)) { | |
| detectedScripts.add('latin'); | |
| } | |
| if (containsPdfSymbolTextForRouting(segment)) { | |
| detectedScripts.add('symbolsText'); | |
| } | |
| PDF_DIRECT_SCRIPT_SCAN_ORDER.forEach((script) => { | |
| const pattern = PDF_SCRIPT_DETECTION_PATTERNS[script]; | |
| if (pattern && pattern.test(segment)) { | |
| detectedScripts.add(script); | |
| } | |
| }); | |
| if (hasJapanese) { | |
| detectedScripts.add('japanese'); | |
| } | |
| if (hasKorean) { | |
| detectedScripts.add('korean'); | |
| } | |
| if (hasHan && !hasJapanese && !hasKorean) { | |
| detectedScripts.add('chinese'); | |
| } else if (hasCjkSymbols && !hasJapanese && !hasKorean) { | |
| detectedScripts.add('chinese'); | |
| } | |
| }); | |
| } | |
| function collectPdfLanguageSamples(text, target) { | |
| const normalized = normalizePdfLanguageSample(text); | |
| if (!normalized) { | |
| return; | |
| } | |
| const maxSegmentsPerMessage = normalized.length > PDF_LANGUAGE_SAMPLE_LENGTH * 2 ? 2 : 1; | |
| let added = 0; | |
| for (let start = 0; start < normalized.length && added < maxSegmentsPerMessage; start += PDF_LANGUAGE_SAMPLE_LENGTH) { | |
| const segment = normalized.slice(start, start + PDF_LANGUAGE_SAMPLE_LENGTH).trim(); | |
| if (segment.length < PDF_LANGUAGE_MIN_LENGTH) { | |
| continue; | |
| } | |
| target.push({ | |
| text: segment, | |
| weight: Math.min(segment.length, PDF_LANGUAGE_SAMPLE_LENGTH) | |
| }); | |
| added += 1; | |
| } | |
| } | |
| function normalizePdfLanguageSample(text) { | |
| return ensureString(text) | |
| .replace(/```[\s\S]*?```/g, ' ') | |
| .replace(/`[^`]*`/g, ' ') | |
| .replace(/https?:\/\/\S+/gi, ' ') | |
| .replace(/\s+/g, ' ') | |
| .trim(); | |
| } | |
| function selectPdfLanguageSamples(candidates, limit) { | |
| if (candidates.length <= limit) { | |
| return candidates; | |
| } | |
| const selected = []; | |
| const lastIndex = candidates.length - 1; | |
| const step = lastIndex / Math.max(1, limit - 1); | |
| const seen = new Set(); | |
| for (let index = 0; index < limit; index += 1) { | |
| const candidateIndex = Math.min(lastIndex, Math.round(index * step)); | |
| if (seen.has(candidateIndex)) { | |
| continue; | |
| } | |
| seen.add(candidateIndex); | |
| selected.push(candidates[candidateIndex]); | |
| } | |
| return selected; | |
| } | |
| function mapFrancLanguageCode(code) { | |
| const normalized = ensureString(code).trim().toLowerCase(); | |
| return PDF_LANGUAGE_CODE_MAP[normalized] || normalized || 'und'; | |
| } | |
| function addFallbackLanguagesFromScripts(detectedLanguages, detectedScripts) { | |
| Object.keys(PDF_SCRIPT_FALLBACK_LANGUAGE_MAP).forEach((script) => { | |
| if (!detectedScripts.has(script)) { | |
| return; | |
| } | |
| const code = PDF_SCRIPT_FALLBACK_LANGUAGE_MAP[script]; | |
| if (detectedLanguages.indexOf(code) === -1) { | |
| detectedLanguages.push(code); | |
| } | |
| }); | |
| } | |
| function fallbackLanguageFromScripts(detectedScripts) { | |
| for (const script of PDF_SCRIPT_FALLBACK_PRIORITY) { | |
| if (!detectedScripts.has(script)) { | |
| continue; | |
| } | |
| if (script === 'latin') { | |
| return 'en'; | |
| } | |
| if (PDF_SCRIPT_FALLBACK_LANGUAGE_MAP[script]) { | |
| return PDF_SCRIPT_FALLBACK_LANGUAGE_MAP[script]; | |
| } | |
| } | |
| if (detectedScripts.has('latin')) { | |
| return 'en'; | |
| } | |
| return 'und'; | |
| } | |
| function formatPdfDetectionSummary(languageProfile) { | |
| if (!languageProfile || typeof languageProfile !== 'object') { | |
| return 'Using local language and script detection.'; | |
| } | |
| const languages = Array.isArray(languageProfile.detectedLanguages) | |
| ? languageProfile.detectedLanguages.filter(Boolean) | |
| : []; | |
| const scripts = Array.isArray(languageProfile.detectedScripts) | |
| ? languageProfile.detectedScripts.filter(Boolean) | |
| : []; | |
| const languageText = languages.length ? languages.join(', ') : ensureString(languageProfile.mainLanguage || 'und'); | |
| const scriptText = scripts.length ? scripts.join(', ') : 'latin'; | |
| return `Main language: ${ensureString(languageProfile.mainLanguage || 'und')} | Languages: ${languageText} | Scripts: ${scriptText}`; | |
| } | |
| function formatPdfResourceLabel(resourceKey) { | |
| if (PDF_SCRIPT_RESOURCE_LABELS[resourceKey]) { | |
| return PDF_SCRIPT_RESOURCE_LABELS[resourceKey]; | |
| } | |
| return `${ensureString(resourceKey || 'PDF')} font`; | |
| } | |
| function clonePdfFontContext(context) { | |
| if (!context || typeof context !== 'object') { | |
| return null; | |
| } | |
| const next = { | |
| baseFont: ensureString(context.baseFont), | |
| mainLanguage: ensureString(context.mainLanguage || 'und'), | |
| detectedLanguages: Array.isArray(context.detectedLanguages) ? context.detectedLanguages.slice() : [], | |
| detectedScripts: Array.isArray(context.detectedScripts) ? context.detectedScripts.slice() : [], | |
| safeSegmentationScripts: Array.isArray(context.safeSegmentationScripts) ? context.safeSegmentationScripts.slice() : [], | |
| scriptFonts: Object.create(null), | |
| emojiFontFamily: ensureString(context.emojiFontFamily) | |
| }; | |
| Object.keys(context.scriptFonts || {}).forEach((script) => { | |
| if (context.scriptFonts[script]) { | |
| next.scriptFonts[script] = context.scriptFonts[script]; | |
| } | |
| }); | |
| return next; | |
| } | |
| function isRecoverablePdfFontError(error) { | |
| const details = [ | |
| ensureString(error && error.message), | |
| ensureString(error && error.stack) | |
| ].filter(Boolean).join('\n'); | |
| if (!details) { | |
| return false; | |
| } | |
| return /advanceWidth|xCoordinate|EmbeddedFont|GPOSProcessor|getAnchor|TTFFont\.layout|FontProvider\.provideFont/i.test(details); | |
| } | |
| function buildPdfFontFallbackPlans(fontContext) { | |
| if (!fontContext) { | |
| return []; | |
| } | |
| const hasEmojiFont = Boolean(fontContext.emojiFontFamily); | |
| const availableScripts = Object.keys(fontContext.scriptFonts).filter((script) => Boolean(fontContext.scriptFonts[script])); | |
| if (!availableScripts.length && !hasEmojiFont) { | |
| return []; | |
| } | |
| const existingSafeScripts = Array.isArray(fontContext.safeSegmentationScripts) | |
| ? fontContext.safeSegmentationScripts | |
| : []; | |
| const recommendedSafeScripts = availableScripts.filter((script) => { | |
| return PDF_SAFE_SEGMENTATION_SCRIPTS.indexOf(script) !== -1 && existingSafeScripts.indexOf(script) === -1; | |
| }); | |
| const plans = []; | |
| if (hasEmojiFont) { | |
| plans.push({ | |
| disabledScripts: [], | |
| disableEmojiFont: true, | |
| detail: 'Retrying PDF generation without the emoji font.' | |
| }); | |
| } | |
| if (recommendedSafeScripts.length) { | |
| plans.push({ | |
| disabledScripts: [], | |
| safeSegmentationScripts: existingSafeScripts.concat(recommendedSafeScripts), | |
| detail: 'Retrying PDF generation with safer complex-script layout.' | |
| }); | |
| } | |
| const orderedScripts = PDF_SCRIPT_FONT_RETRY_ORDER | |
| .filter((script) => availableScripts.indexOf(script) !== -1) | |
| .concat(availableScripts.filter((script) => PDF_SCRIPT_FONT_RETRY_ORDER.indexOf(script) === -1)); | |
| plans.push(...orderedScripts.map((script) => ({ | |
| disabledScripts: [script], | |
| detail: `Retrying PDF generation without ${formatPdfResourceLabel(script).toLowerCase()}.` | |
| }))); | |
| if (availableScripts.length > 1) { | |
| plans.push({ | |
| disabledScripts: availableScripts.slice(), | |
| disableEmojiFont: hasEmojiFont, | |
| detail: 'Retrying PDF generation without extra language fonts.' | |
| }); | |
| } | |
| return plans; | |
| } | |
| function buildPdfFontContextVariant(fontContext, disabledScripts, safeSegmentationScripts, disableEmojiFont) { | |
| const baseContext = clonePdfFontContext(fontContext); | |
| if (!baseContext) { | |
| return null; | |
| } | |
| const disabled = new Set(Array.isArray(disabledScripts) ? disabledScripts : []); | |
| const scriptFonts = Object.create(null); | |
| Object.keys(baseContext.scriptFonts || {}).forEach((script) => { | |
| if (!disabled.has(script) && baseContext.scriptFonts[script]) { | |
| scriptFonts[script] = baseContext.scriptFonts[script]; | |
| } | |
| }); | |
| baseContext.scriptFonts = scriptFonts; | |
| baseContext.detectedScripts = baseContext.detectedScripts.filter((script) => !disabled.has(script)); | |
| baseContext.safeSegmentationScripts = ( | |
| Array.isArray(safeSegmentationScripts) ? safeSegmentationScripts : baseContext.safeSegmentationScripts | |
| ).filter((script, index, list) => { | |
| return !disabled.has(script) && list.indexOf(script) === index; | |
| }); | |
| if (disableEmojiFont) { | |
| baseContext.emojiFontFamily = ''; | |
| } | |
| return baseContext; | |
| } | |
| async function exportPdf(messages) { | |
| updatePdfExportLoader({ | |
| stage: 'Detecting languages and scripts...', | |
| detail: 'Analyzing the full chat locally before preparing the PDF.', | |
| progress: 0.18, | |
| progressText: 'Step 2 of 4', | |
| indeterminate: false | |
| }); | |
| await waitForNextPaint(); | |
| const title = `${getPlatformLabel()} Export`; | |
| const conversationTitle = getExportConversationTitle(); | |
| const filename = buildExportFilename('pdf', null); | |
| const metaDate = new Date().toLocaleString('fr-FR'); | |
| const metaUrl = location.href; | |
| const metaLines = []; | |
| if (conversationTitle) { | |
| metaLines.push(`Conversation: ${conversationTitle}`); | |
| } | |
| metaLines.push(`URL: ${metaUrl}`); | |
| metaLines.push(`Exported: ${metaDate}`); | |
| if (!pdfMakeRef) { | |
| pdfMakeRef = resolvePdfMake(); | |
| } | |
| const pdfMakeInstance = pdfMakeRef || resolvePdfMake(); | |
| if (!pdfMakeInstance || typeof pdfMakeInstance.createPdf !== 'function') { | |
| return false; | |
| } | |
| const detectorModule = await loadPdfLanguageDetector(); | |
| const languageProfile = buildPdfLanguageProfile(messages, detectorModule, [title, conversationTitle, metaLines.join('\n')]); | |
| updatePdfExportLoader({ | |
| stage: 'Detecting languages and scripts...', | |
| detail: formatPdfDetectionSummary(languageProfile), | |
| progress: 0.28, | |
| progressText: 'Step 2 of 4', | |
| indeterminate: false | |
| }); | |
| await waitForNextPaint(); | |
| updatePdfExportLoader({ | |
| stage: 'Loading PDF fonts...', | |
| detail: formatPdfDetectionSummary(languageProfile), | |
| progress: 0.34, | |
| progressText: 'Step 3 of 4', | |
| indeterminate: false | |
| }); | |
| await waitForNextPaint(); | |
| const fontName = await ensurePdfMakeFonts(pdfMakeInstance, languageProfile); | |
| if (!fontName) { | |
| return false; | |
| } | |
| const pageMargins = [42, 38, 42, 50]; | |
| const pageWidthPt = 595.28; | |
| const dividerWidth = pageWidthPt - pageMargins[0] - pageMargins[2]; | |
| const wrapRoleLabel = (role) => { | |
| if (!role) return 'MESSAGE'; | |
| const lowered = String(role).toLowerCase(); | |
| if (lowered === 'user') return 'UTILISATEUR'; | |
| if (lowered === 'assistant') return 'ASSISTANT'; | |
| return String(role).toUpperCase(); | |
| }; | |
| const roleTheme = (role) => { | |
| const lowered = String(role || '').toLowerCase(); | |
| if (lowered === 'user') { | |
| return { fill: '#f1f5f9', border: '#e2e8f0', text: '#0f766e', accent: '#14b8a6' }; | |
| } | |
| if (lowered === 'assistant') { | |
| return { fill: '#f8fafc', border: '#e2e8f0', text: '#1d4ed8', accent: '#60a5fa' }; | |
| } | |
| return { fill: '#f8fafc', border: '#e2e8f0', text: '#334155', accent: '#94a3b8' }; | |
| }; | |
| const buildDocDefinition = () => { | |
| const content = [ | |
| { text: title, style: 'title' }, | |
| { text: formatPdfTextWithEmoji(metaLines.join('\n')), style: 'meta' }, | |
| { | |
| canvas: [ | |
| { type: 'line', x1: 0, y1: 0, x2: dividerWidth, y2: 0, lineWidth: 1, lineColor: '#e2e8f0' } | |
| ], | |
| margin: [0, 2, 0, 14] | |
| } | |
| ]; | |
| messages.forEach((message) => { | |
| const theme = roleTheme(message.role); | |
| const roleLabel = wrapRoleLabel(message.role); | |
| const messageText = ensureString(message.text); | |
| const htmlContent = message.html || messageText; | |
| const liveGeminiNode = | |
| platform === 'gemini' && | |
| message && | |
| message.sourceNode && | |
| message.sourceNode.nodeType === Node.ELEMENT_NODE && | |
| message.sourceNode.isConnected | |
| ? message.sourceNode | |
| : null; | |
| const richContent = liveGeminiNode | |
| ? parseNodeToPdfMake(liveGeminiNode) | |
| : convertHtmlToPdfMake(htmlContent); | |
| const normalizedRichContent = normalizePdfContentForPlatform(richContent); | |
| const emojiRichContent = applyEmojiFontToTree(normalizedRichContent); | |
| const richContentStack = Array.isArray(emojiRichContent) ? emojiRichContent : [emojiRichContent]; | |
| content.push({ | |
| table: { | |
| widths: [3, '*'], | |
| body: [[ | |
| { | |
| stack: [ | |
| { text: '' } | |
| ], | |
| fillColor: theme.accent | |
| }, | |
| { | |
| stack: [ | |
| { text: formatPdfTextWithEmoji(roleLabel), style: 'role', color: theme.text }, | |
| ...richContentStack | |
| ], | |
| fillColor: theme.fill | |
| } | |
| ]] | |
| }, | |
| layout: { | |
| hLineWidth: () => 0, | |
| vLineWidth: () => 0, | |
| paddingLeft: (i) => (i === 0 ? 0 : 12), | |
| paddingRight: () => 12, | |
| paddingTop: () => 10, | |
| paddingBottom: () => 10 | |
| }, | |
| margin: [0, 0, 0, 14] | |
| }); | |
| }); | |
| return { | |
| info: { title: title }, | |
| pageSize: 'A4', | |
| pageMargins: pageMargins, | |
| content: content, | |
| defaultStyle: { | |
| font: fontName, | |
| fontSize: 10, | |
| color: '#0f172a' | |
| }, | |
| footer: function (currentPage, pageCount) { | |
| return { | |
| columns: [ | |
| { | |
| text: 'Generated with OmniChat Exporter', | |
| alignment: 'left' | |
| }, | |
| { | |
| text: `${currentPage} / ${pageCount}`, | |
| alignment: 'right' | |
| } | |
| ], | |
| margin: [40, 6, 40, 10], | |
| relativePosition: { x: 0, y: 6 }, | |
| fontSize: 8, | |
| color: '#94a3b8' | |
| }; | |
| }, | |
| styles: { | |
| title: { fontSize: 18, bold: true, margin: [0, 0, 0, 6], color: '#0f172a' }, | |
| meta: { fontSize: 9, color: '#64748b', margin: [0, 0, 0, 12] }, | |
| role: { fontSize: 9, bold: true, margin: [0, 0, 0, 11] }, | |
| message: { fontSize: 11, lineHeight: 1.45 }, | |
| codeBlockHeader: { fontSize: 9, bold: true, color: '#f8fafc' }, | |
| codeBlockBody: { fontSize: 9, color: '#f8fafc', font: 'monospace', lineHeight: 1.35 } | |
| } | |
| }; | |
| }; | |
| const originalFontContext = clonePdfFontContext(activePdfFontContext); | |
| const attemptPdfDownload = async function (fontContextOverride) { | |
| activePdfFontContext = clonePdfFontContext(fontContextOverride); | |
| activePdfEmojiFontFamily = activePdfFontContext && activePdfFontContext.emojiFontFamily | |
| ? activePdfFontContext.emojiFontFamily | |
| : ''; | |
| await downloadPdfDocument(pdfMakeInstance, buildDocDefinition(), filename); | |
| }; | |
| try { | |
| updatePdfExportLoader({ | |
| stage: 'Generating PDF...', | |
| detail: 'Finalizing layout and preparing the download.', | |
| progress: 0.92, | |
| progressText: 'Step 4 of 4', | |
| indeterminate: false | |
| }); | |
| await waitForNextPaint(); | |
| await attemptPdfDownload(originalFontContext); | |
| updatePdfExportLoader({ | |
| stage: 'PDF export ready.', | |
| detail: 'The document has been generated and the download has been triggered.', | |
| progress: 1, | |
| progressText: 'Completed', | |
| indeterminate: false | |
| }); | |
| await waitForNextPaint(); | |
| return true; | |
| } catch (err) { | |
| const fallbackPlans = isRecoverablePdfFontError(err) ? buildPdfFontFallbackPlans(originalFontContext) : []; | |
| if (fallbackPlans.length) { | |
| console.warn('PDF export hit a recoverable font error, retrying with safer font fallbacks', err); | |
| for (let index = 0; index < fallbackPlans.length; index += 1) { | |
| const plan = fallbackPlans[index]; | |
| updatePdfExportLoader({ | |
| stage: 'Generating PDF...', | |
| detail: plan.detail, | |
| progress: Math.min(0.97, 0.93 + ((index + 1) / (fallbackPlans.length + 1)) * 0.04), | |
| progressText: 'Retrying with fallback fonts', | |
| indeterminate: false | |
| }); | |
| await waitForNextPaint(); | |
| try { | |
| const retryContext = buildPdfFontContextVariant( | |
| originalFontContext, | |
| plan.disabledScripts, | |
| plan.safeSegmentationScripts, | |
| plan.disableEmojiFont | |
| ); | |
| await attemptPdfDownload(retryContext); | |
| updatePdfExportLoader({ | |
| stage: 'PDF export ready.', | |
| detail: 'The PDF was generated after applying a safer font fallback.', | |
| progress: 1, | |
| progressText: 'Completed', | |
| indeterminate: false | |
| }); | |
| await waitForNextPaint(); | |
| return true; | |
| } catch (retryErr) { | |
| console.warn('PDF export fallback retry failed', plan.disabledScripts, retryErr); | |
| } | |
| } | |
| } | |
| console.error('PDF export error:', err); | |
| return false; | |
| } finally { | |
| activePdfEmojiFontFamily = ''; | |
| activePdfFontContext = null; | |
| } | |
| } | |
| async function downloadPdfDocument(pdfMakeInstance, docDefinition, filename) { | |
| const instance = pdfMakeInstance.createPdf(docDefinition); | |
| const result = instance.download(filename); | |
| if (result && typeof result.then === 'function') { | |
| await result; | |
| } | |
| } | |
| function escapeHtml(value) { | |
| return String(value) | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| } | |
| function buildExportFilename(extension, anchorTurn) { | |
| const slug = sanitizeFilename(getConversationSlug()); | |
| const turnId = anchorTurn ? anchorTurn.getAttribute('data-turn-id') : ''; | |
| const turnSlug = turnId ? sanitizeFilename(turnId).slice(0, 24) : ''; | |
| const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); | |
| const suffix = turnSlug ? `-${turnSlug}` : ''; | |
| const prefix = platform || 'chat'; | |
| return `${prefix}-${slug || 'chat'}${suffix}-${timestamp}.${extension}`; | |
| } | |
| function getConversationSlug() { | |
| const conversationTitle = getExportConversationTitle(); | |
| if (conversationTitle) { | |
| return conversationTitle; | |
| } | |
| const parts = location.pathname.split('/').filter(Boolean); | |
| return parts[parts.length - 1] || 'chat'; | |
| } | |
| function getExportConversationTitle() { | |
| const rawTitle = ensureString(document.title).trim(); | |
| if (!rawTitle) { | |
| return ''; | |
| } | |
| if (platform === 'chatgpt') { | |
| return rawTitle | |
| .replace(' – ChatGPT', '') | |
| .replace(/\s+[–-]\s+ChatGPT$/i, '') | |
| .trim(); | |
| } | |
| if (platform === 'claude') { | |
| return rawTitle.replace(/\s*[-–]\s*Claude/gi, '').trim(); | |
| } | |
| if (platform === 'grok') { | |
| return rawTitle | |
| .replace(/\s*[-–]\s*Grok.*$/i, '') | |
| .trim(); | |
| } | |
| if (platform === 'deepseek') { | |
| return rawTitle | |
| .replace(/\s*[-–]\s*DeepSeek.*$/i, '') | |
| .trim(); | |
| } | |
| if (platform === 'gemini') { | |
| const candidate = | |
| ensureString(document.querySelector('[aria-current="true"]')?.innerText).trim() || | |
| ensureString(document.querySelector('h1')?.innerText).trim(); | |
| const geminiTitle = candidate && candidate !== 'Google Gemini' ? candidate : rawTitle; | |
| return geminiTitle.trim(); | |
| } | |
| return ''; | |
| } | |
| function sanitizeFilename(value) { | |
| return ensureString(value) | |
| .normalize('NFD') | |
| .replace(/[\u0300-\u036f]/g, '') | |
| .toLowerCase() | |
| .replace(/[^a-z0-9-_]+/g, '-') | |
| .replace(/^-+|-+$/g, '') | |
| .slice(0, 80); | |
| } | |
| function downloadText(text, filename, mime) { | |
| const blob = new Blob([text], { type: `${mime};charset=utf-8` }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.download = filename; | |
| document.body.appendChild(link); | |
| link.click(); | |
| link.remove(); | |
| window.setTimeout(() => URL.revokeObjectURL(url), 0); | |
| } | |
| function formatRoleLabel(role) { | |
| if (!role) { | |
| return 'Message'; | |
| } | |
| const value = String(role); | |
| return value.charAt(0).toUpperCase() + value.slice(1); | |
| } | |
| function ensureString(value) { | |
| if (value === null || value === undefined) { | |
| return ''; | |
| } | |
| return String(value); | |
| } | |
| function flashButton(button, label, status) { | |
| const previousLabel = button.getAttribute('aria-label') || 'Exporter ce chat'; | |
| button.disabled = true; | |
| button.setAttribute('data-omni-status', status); | |
| button.setAttribute('aria-label', label); | |
| window.setTimeout(() => { | |
| button.setAttribute('aria-label', previousLabel); | |
| button.removeAttribute('data-omni-status'); | |
| button.disabled = false; | |
| }, STATUS_DURATION_MS); | |
| } | |
| function detectPlatform(hostname) { | |
| if (hostname === 'chat.openai.com' || hostname === 'chatgpt.com') { | |
| return 'chatgpt'; | |
| } | |
| if (hostname === 'gemini.google.com') { | |
| return 'gemini'; | |
| } | |
| if (hostname === 'grok.com' || hostname === 'grok.x.ai') { | |
| return 'grok'; | |
| } | |
| if (hostname === 'claude.ai') { | |
| return 'claude'; | |
| } | |
| if (hostname === 'chat.deepseek.com') { | |
| return 'deepseek'; | |
| } | |
| return null; | |
| } | |
| function getPlatformLabel() { | |
| if (platform === 'chatgpt') { | |
| return 'ChatGPT'; | |
| } | |
| if (platform === 'gemini') { | |
| return 'Gemini'; | |
| } | |
| if (platform === 'grok') { | |
| return 'Grok'; | |
| } | |
| if (platform === 'claude') { | |
| return 'Claude'; | |
| } | |
| if (platform === 'deepseek') { | |
| return 'DeepSeek'; | |
| } | |
| return 'Chat'; | |
| } | |
| function resolvePdfMake() { | |
| const localPdfMake = getLocalPdfMake(); | |
| const candidates = [ | |
| localPdfMake, | |
| window.pdfMake, | |
| window.pdfmake, | |
| window.pdfMake && window.pdfMake.default, | |
| window.pdfmake && window.pdfmake.default, | |
| window.pdfMake && window.pdfMake.pdfMake, | |
| window.pdfmake && window.pdfmake.pdfMake | |
| ]; | |
| for (const candidate of candidates) { | |
| if (candidate && typeof candidate.createPdf === 'function') { | |
| return candidate; | |
| } | |
| } | |
| return null; | |
| } | |
| async function ensurePdfMakeFonts(pdfMakeInstance, languageProfile) { | |
| const localPdfMake = getLocalPdfMake(); | |
| const vfsCandidates = [ | |
| pdfMakeInstance.vfs, | |
| localPdfMake && localPdfMake.vfs, | |
| window.pdfMake && window.pdfMake.vfs, | |
| window.pdfmake && window.pdfmake.vfs, | |
| window.pdfFonts && window.pdfFonts.pdfMake && window.pdfFonts.pdfMake.vfs, | |
| window.pdfFonts && window.pdfFonts.vfs | |
| ]; | |
| let vfs = null; | |
| for (const candidate of vfsCandidates) { | |
| if (candidate && typeof candidate === 'object') { | |
| vfs = candidate; | |
| break; | |
| } | |
| } | |
| if (vfs && Object.keys(vfs).length) { | |
| mergePdfVfs(pdfMakeInstance, vfs); | |
| } | |
| const baseFont = ensureBaseFont(pdfMakeInstance); | |
| if (!baseFont) { | |
| return null; | |
| } | |
| activePdfFontContext = { | |
| baseFont: baseFont, | |
| mainLanguage: languageProfile && languageProfile.mainLanguage ? languageProfile.mainLanguage : 'und', | |
| detectedLanguages: languageProfile && Array.isArray(languageProfile.detectedLanguages) | |
| ? languageProfile.detectedLanguages.slice() | |
| : [], | |
| detectedScripts: languageProfile && Array.isArray(languageProfile.detectedScripts) | |
| ? languageProfile.detectedScripts.slice() | |
| : ['latin'], | |
| safeSegmentationScripts: [], | |
| scriptFonts: Object.create(null), | |
| emojiFontFamily: '' | |
| }; | |
| activePdfEmojiFontFamily = ''; | |
| const scriptLoadList = activePdfFontContext.detectedScripts.filter((script) => { | |
| return Boolean(PDF_SCRIPT_FONT_SPECS[script]); | |
| }); | |
| const existingVfs = pdfMakeInstance.vfs || {}; | |
| const pendingResources = []; | |
| scriptLoadList.forEach((script) => { | |
| const spec = PDF_SCRIPT_FONT_SPECS[script]; | |
| if (spec && !existingVfs[spec.file]) { | |
| pendingResources.push({ key: script, kind: 'script' }); | |
| } | |
| }); | |
| if (platform === 'gemini') { | |
| activePdfFontContext.safeSegmentationScripts = scriptLoadList.filter((script, index, list) => { | |
| return PDF_SAFE_SEGMENTATION_SCRIPTS.indexOf(script) !== -1 && list.indexOf(script) === index; | |
| }); | |
| } | |
| if (PDF_ENABLE_EMOJI_FONT && languageProfile && languageProfile.containsEmoji && !existingVfs[PDF_EMOJI_FONT_FILE]) { | |
| pendingResources.push({ key: 'emoji', kind: 'emoji' }); | |
| } | |
| if (!pendingResources.length) { | |
| updatePdfExportLoader({ | |
| stage: 'Loading PDF fonts...', | |
| detail: 'All required fonts are already cached locally.', | |
| progress: 0.84, | |
| progressText: 'Step 3 of 4', | |
| indeterminate: false | |
| }); | |
| await waitForNextPaint(); | |
| } | |
| for (let index = 0; index < scriptLoadList.length; index += 1) { | |
| const script = scriptLoadList[index]; | |
| const family = await ensureScriptFont(pdfMakeInstance, script, { | |
| resourceIndex: pendingResources.findIndex((entry) => entry.key === script), | |
| totalResources: pendingResources.length, | |
| pendingResources: pendingResources | |
| }); | |
| if (script && family) { | |
| activePdfFontContext.scriptFonts[script] = family; | |
| } | |
| } | |
| const emojiFont = PDF_ENABLE_EMOJI_FONT && languageProfile && languageProfile.containsEmoji | |
| ? await ensureEmojiFont(pdfMakeInstance, { | |
| resourceIndex: pendingResources.findIndex((entry) => entry.key === 'emoji'), | |
| totalResources: pendingResources.length, | |
| pendingResources: pendingResources | |
| }) | |
| : ''; | |
| if (emojiFont) { | |
| activePdfEmojiFontFamily = emojiFont; | |
| activePdfFontContext.emojiFontFamily = emojiFont; | |
| } | |
| updatePdfExportLoader({ | |
| stage: 'Loading PDF fonts...', | |
| detail: pendingResources.length | |
| ? 'Required language fonts are ready for the PDF renderer.' | |
| : 'No extra font download was needed.', | |
| progress: 0.84, | |
| progressText: 'Step 3 of 4', | |
| indeterminate: false | |
| }); | |
| await waitForNextPaint(); | |
| return baseFont; | |
| } | |
| function mergePdfVfs(pdfMakeInstance, vfs) { | |
| if (!vfs || typeof vfs !== 'object') { | |
| return; | |
| } | |
| if (typeof pdfMakeInstance.addVirtualFileSystem === 'function') { | |
| pdfMakeInstance.addVirtualFileSystem(vfs); | |
| } else { | |
| pdfMakeInstance.vfs = Object.assign({}, pdfMakeInstance.vfs || {}, vfs); | |
| } | |
| if (window.pdfMake && window.pdfMake !== pdfMakeInstance) { | |
| window.pdfMake.vfs = Object.assign({}, window.pdfMake.vfs || {}, vfs); | |
| } | |
| } | |
| async function ensureScriptFont(pdfMakeInstance, script, progressState) { | |
| const spec = PDF_SCRIPT_FONT_SPECS[script]; | |
| if (!spec) { | |
| return ''; | |
| } | |
| try { | |
| const vfs = pdfMakeInstance.vfs || {}; | |
| if (vfs[spec.file]) { | |
| registerPdfFont(pdfMakeInstance, spec.family, spec.file); | |
| return spec.family; | |
| } | |
| const base64 = await loadRemoteFontBase64(spec.file, spec.urls, script, progressState); | |
| if (!base64) { | |
| return ''; | |
| } | |
| const nextVfs = {}; | |
| nextVfs[spec.file] = base64; | |
| mergePdfVfs(pdfMakeInstance, nextVfs); | |
| registerPdfFont(pdfMakeInstance, spec.family, spec.file); | |
| return spec.family; | |
| } catch (err) { | |
| console.warn(`OmniChat: ${script} PDF font unavailable`, err); | |
| return ''; | |
| } | |
| } | |
| async function ensureEmojiFont(pdfMakeInstance, progressState) { | |
| try { | |
| const vfs = pdfMakeInstance.vfs || {}; | |
| if (vfs[PDF_EMOJI_FONT_FILE]) { | |
| registerPdfFont(pdfMakeInstance, PDF_EMOJI_FONT_FAMILY, PDF_EMOJI_FONT_FILE); | |
| return PDF_EMOJI_FONT_FAMILY; | |
| } | |
| const base64 = await loadRemoteFontBase64(PDF_EMOJI_FONT_FILE, PDF_EMOJI_FONT_URLS, 'emoji', progressState); | |
| if (!base64) { | |
| return ''; | |
| } | |
| const nextVfs = {}; | |
| nextVfs[PDF_EMOJI_FONT_FILE] = base64; | |
| mergePdfVfs(pdfMakeInstance, nextVfs); | |
| registerPdfFont(pdfMakeInstance, PDF_EMOJI_FONT_FAMILY, PDF_EMOJI_FONT_FILE); | |
| return PDF_EMOJI_FONT_FAMILY; | |
| } catch (err) { | |
| console.warn('OmniChat: emoji font unavailable for PDF export', err); | |
| return ''; | |
| } | |
| } | |
| function registerPdfFont(pdfMakeInstance, family, filename) { | |
| const existing = pdfMakeInstance.fonts || {}; | |
| pdfMakeInstance.fonts = Object.assign({}, existing, { | |
| [family]: { | |
| normal: filename, | |
| bold: filename, | |
| italics: filename, | |
| bolditalics: filename | |
| } | |
| }); | |
| } | |
| async function loadRemoteFontBase64(cacheKey, urls, label, progressState) { | |
| if (!pdfFontBase64Promises[cacheKey]) { | |
| pdfFontBase64Promises[cacheKey] = (async function () { | |
| for (const url of urls) { | |
| try { | |
| updatePdfFontDownloadProgress(progressState, label, 0, 0, true); | |
| const buffer = await requestRemoteArrayBuffer(url, `${label || 'PDF'} font`, (loaded, total, indeterminate) => { | |
| updatePdfFontDownloadProgress(progressState, label, loaded, total, indeterminate); | |
| }); | |
| if (!buffer || !buffer.byteLength) { | |
| continue; | |
| } | |
| updatePdfFontDownloadProgress(progressState, label, buffer.byteLength, buffer.byteLength, false); | |
| return await arrayBufferToBase64(buffer); | |
| } catch (err) { | |
| const details = String((err && err.message) || err || ''); | |
| const isCspBlocked = /content security policy|csp|failed to fetch/i.test(details); | |
| if (isCspBlocked) { | |
| return ''; | |
| } | |
| console.warn(`OmniChat: failed loading ${label || 'pdf'} font URL`, url, err); | |
| } | |
| } | |
| return ''; | |
| })(); | |
| } | |
| return pdfFontBase64Promises[cacheKey]; | |
| } | |
| function updatePdfFontDownloadProgress(progressState, label, loaded, total, indeterminate) { | |
| const totalResources = Math.max(0, Number(progressState && progressState.totalResources) || 0); | |
| const resourceIndex = Math.max(0, Number(progressState && progressState.resourceIndex) || 0); | |
| const resourceLabel = formatPdfResourceLabel(label); | |
| let withinResource = 0; | |
| let meta = ''; | |
| if (Number.isFinite(total) && total > 0 && Number.isFinite(loaded)) { | |
| withinResource = Math.max(0, Math.min(1, loaded / total)); | |
| meta = `${resourceLabel} ${Math.round(withinResource * 100)}%`; | |
| } else if (Number.isFinite(loaded) && loaded > 0) { | |
| meta = `${resourceLabel} ${formatPdfByteSize(loaded)} downloaded`; | |
| } else { | |
| meta = `${resourceLabel}...`; | |
| } | |
| const overallBase = 0.34; | |
| const overallSpan = 0.5; | |
| const progress = totalResources > 0 | |
| ? overallBase + (((resourceIndex + withinResource) / totalResources) * overallSpan) | |
| : overallBase; | |
| const pendingResources = progressState && Array.isArray(progressState.pendingResources) | |
| ? progressState.pendingResources | |
| : []; | |
| const remainingLabels = pendingResources | |
| .slice(Math.min(resourceIndex, pendingResources.length)) | |
| .map((entry) => formatPdfResourceLabel(entry.key)); | |
| const resourceCountText = totalResources > 0 | |
| ? `${Math.min(resourceIndex + 1, totalResources)} / ${totalResources} resources` | |
| : 'Step 3 of 4'; | |
| const detail = remainingLabels.length | |
| ? `Loading ${resourceLabel}. Remaining resources: ${remainingLabels.join(', ')}.` | |
| : `Loading ${resourceLabel}.`; | |
| updatePdfExportLoader({ | |
| stage: 'Loading PDF fonts...', | |
| detail: detail, | |
| progress: progress, | |
| progressText: meta ? `${resourceCountText} | ${meta}` : resourceCountText, | |
| indeterminate: Boolean(indeterminate && !(Number.isFinite(total) && total > 0)) | |
| }); | |
| } | |
| async function readRemoteFontBuffer(response, onProgress) { | |
| if (!response || !response.body || typeof response.body.getReader !== 'function') { | |
| const directBuffer = await response.arrayBuffer(); | |
| if (typeof onProgress === 'function') { | |
| onProgress(directBuffer.byteLength, directBuffer.byteLength, false); | |
| } | |
| return directBuffer; | |
| } | |
| const contentLength = Number.parseInt(response.headers.get('content-length') || '', 10); | |
| const total = Number.isFinite(contentLength) && contentLength > 0 ? contentLength : 0; | |
| const reader = response.body.getReader(); | |
| const chunks = []; | |
| let loaded = 0; | |
| let nextYieldAt = 256 * 1024; | |
| while (true) { | |
| const result = await reader.read(); | |
| if (!result || result.done) { | |
| break; | |
| } | |
| const value = result.value; | |
| if (!value || !value.byteLength) { | |
| continue; | |
| } | |
| chunks.push(value); | |
| loaded += value.byteLength; | |
| if (typeof onProgress === 'function') { | |
| onProgress(loaded, total, !total); | |
| } | |
| if (loaded >= nextYieldAt) { | |
| nextYieldAt += 256 * 1024; | |
| await waitForNextPaint(); | |
| } | |
| } | |
| const merged = mergeUint8ArrayChunks(chunks, loaded); | |
| if (typeof onProgress === 'function') { | |
| onProgress(loaded, total || loaded, false); | |
| } | |
| return merged.buffer; | |
| } | |
| function mergeUint8ArrayChunks(chunks, totalLength) { | |
| const output = new Uint8Array(totalLength); | |
| let offset = 0; | |
| chunks.forEach((chunk) => { | |
| output.set(chunk, offset); | |
| offset += chunk.byteLength; | |
| }); | |
| return output; | |
| } | |
| async function arrayBufferToBase64(buffer) { | |
| if (typeof FileReader === 'function') { | |
| return await new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onload = () => { | |
| const result = ensureString(reader.result); | |
| const commaIndex = result.indexOf(','); | |
| resolve(commaIndex >= 0 ? result.slice(commaIndex + 1) : result); | |
| }; | |
| reader.onerror = () => reject(reader.error || new Error('FileReader failed')); | |
| reader.readAsDataURL(new Blob([buffer])); | |
| }); | |
| } | |
| const bytes = new Uint8Array(buffer); | |
| const chunkSize = 0x8000; | |
| let binary = ''; | |
| for (let index = 0; index < bytes.length; index += chunkSize) { | |
| const chunk = bytes.subarray(index, index + chunkSize); | |
| binary += String.fromCharCode.apply(null, chunk); | |
| } | |
| return btoa(binary); | |
| } | |
| function formatPdfByteSize(bytes) { | |
| const value = Number(bytes); | |
| if (!Number.isFinite(value) || value <= 0) { | |
| return '0 B'; | |
| } | |
| if (value < 1024) { | |
| return `${Math.round(value)} B`; | |
| } | |
| if (value < 1024 * 1024) { | |
| return `${(value / 1024).toFixed(1)} KB`; | |
| } | |
| return `${(value / (1024 * 1024)).toFixed(1)} MB`; | |
| } | |
| function applyEmojiFontToTree(node) { | |
| if (!activePdfEmojiFontFamily && !activePdfFontContext) { | |
| return node; | |
| } | |
| if (typeof node === 'string') { | |
| return formatPdfTextWithEmoji(node); | |
| } | |
| if (Array.isArray(node)) { | |
| return node.map(applyEmojiFontToTree); | |
| } | |
| if (!node || typeof node !== 'object') { | |
| return node; | |
| } | |
| const next = Object.assign({}, node); | |
| if (Object.prototype.hasOwnProperty.call(next, 'text')) { | |
| next.text = applyPdfFontsToTextValue(next.text); | |
| } | |
| if (Array.isArray(next.stack)) { | |
| next.stack = next.stack.map(applyEmojiFontToTree); | |
| } | |
| if (Array.isArray(next.ul)) { | |
| next.ul = next.ul.map(applyEmojiFontToTree); | |
| } | |
| if (Array.isArray(next.ol)) { | |
| next.ol = next.ol.map(applyEmojiFontToTree); | |
| } | |
| if (Array.isArray(next.columns)) { | |
| next.columns = next.columns.map(applyEmojiFontToTree); | |
| } | |
| if (next.table && Array.isArray(next.table.body)) { | |
| next.table = Object.assign({}, next.table, { | |
| body: next.table.body.map((row) => | |
| Array.isArray(row) ? row.map(applyEmojiFontToTree) : row | |
| ) | |
| }); | |
| } | |
| return next; | |
| } | |
| function applyPdfFontsToTextValue(value) { | |
| if (typeof value === 'string') { | |
| return formatPdfTextWithEmoji(value); | |
| } | |
| if (Array.isArray(value)) { | |
| const output = []; | |
| value.forEach((entry) => { | |
| appendPdfTextValue(output, entry); | |
| }); | |
| return output.length === 1 && typeof output[0] === 'string' ? output[0] : output; | |
| } | |
| if (value && typeof value === 'object') { | |
| return applyEmojiFontToTree(value); | |
| } | |
| return value; | |
| } | |
| function appendPdfTextValue(target, value) { | |
| if (value === null || value === undefined) { | |
| return; | |
| } | |
| if (typeof value === 'string') { | |
| const formatted = formatPdfTextWithEmoji(value); | |
| if (Array.isArray(formatted)) { | |
| target.push(...formatted); | |
| } else { | |
| target.push(formatted); | |
| } | |
| return; | |
| } | |
| if (Array.isArray(value)) { | |
| value.forEach((entry) => appendPdfTextValue(target, entry)); | |
| return; | |
| } | |
| if (typeof value === 'object') { | |
| target.push(applyEmojiFontToTree(value)); | |
| return; | |
| } | |
| target.push(value); | |
| } | |
| function formatPdfTextWithEmoji(text) { | |
| const raw = ensureString(text); | |
| if (!raw) { | |
| return ''; | |
| } | |
| const fontContext = activePdfFontContext; | |
| const hasScriptFonts = Boolean( | |
| fontContext && | |
| fontContext.scriptFonts && | |
| Object.keys(fontContext.scriptFonts).length | |
| ); | |
| if (!activePdfEmojiFontFamily && !hasScriptFonts) { | |
| return raw; | |
| } | |
| const textUnits = splitPdfTextForFontRouting(raw, fontContext); | |
| const chunks = []; | |
| let currentText = ''; | |
| let currentFont = null; | |
| let currentForceSeparate = false; | |
| textUnits.forEach((unit) => { | |
| const segment = unit && typeof unit === 'object' ? ensureString(unit.text) : ensureString(unit); | |
| const nextFont = resolvePdfFontFamilyForTextUnit(unit, fontContext); | |
| const forceSeparate = Boolean(unit && typeof unit === 'object' && unit.forceSeparate); | |
| if (currentFont === null) { | |
| currentText = segment; | |
| currentFont = nextFont; | |
| currentForceSeparate = forceSeparate; | |
| return; | |
| } | |
| if (currentFont === nextFont && !currentForceSeparate && !forceSeparate) { | |
| currentText += segment; | |
| currentFont = nextFont; | |
| return; | |
| } | |
| chunks.push({ text: currentText, font: currentFont || '' }); | |
| currentText = segment; | |
| currentFont = nextFont; | |
| currentForceSeparate = forceSeparate; | |
| }); | |
| if (currentText) { | |
| chunks.push({ text: currentText, font: currentFont || '' }); | |
| } | |
| if (chunks.length === 1 && !chunks[0].font) { | |
| return chunks[0].text; | |
| } | |
| return chunks.map((chunk) => { | |
| if (chunk.font) { | |
| return { text: chunk.text, font: chunk.font }; | |
| } | |
| return { text: chunk.text }; | |
| }); | |
| } | |
| function splitPdfTextForFontRouting(text, fontContext) { | |
| const graphemes = splitGraphemes(text); | |
| const output = []; | |
| graphemes.forEach((segment, index) => { | |
| const scriptHint = detectPdfScriptForSegment(segment, graphemes, index, fontContext); | |
| const forceSeparate = shouldUseSafePdfScriptSegmentation(scriptHint, fontContext); | |
| if (!forceSeparate) { | |
| output.push({ text: segment, scriptHint: scriptHint, forceSeparate: false }); | |
| return; | |
| } | |
| Array.from(segment).forEach((codePoint) => { | |
| if (codePoint) { | |
| output.push({ text: codePoint, scriptHint: scriptHint, forceSeparate: true }); | |
| } | |
| }); | |
| }); | |
| return output; | |
| } | |
| function shouldUseSafePdfScriptSegmentation(script, fontContext) { | |
| if (!script || !fontContext || !Array.isArray(fontContext.safeSegmentationScripts)) { | |
| return false; | |
| } | |
| return fontContext.safeSegmentationScripts.indexOf(script) !== -1; | |
| } | |
| function resolvePdfFontFamilyForTextUnit(unit, fontContext) { | |
| const segment = unit && typeof unit === 'object' ? ensureString(unit.text) : ensureString(unit); | |
| if (!fontContext || !fontContext.scriptFonts) { | |
| if (activePdfEmojiFontFamily && containsEmojiStyleForPdf(segment)) { | |
| return activePdfEmojiFontFamily; | |
| } | |
| return ''; | |
| } | |
| const script = unit && typeof unit === 'object' ? ensureString(unit.scriptHint) : ''; | |
| if (script === 'symbols' && activePdfEmojiFontFamily) { | |
| return activePdfEmojiFontFamily; | |
| } | |
| if (script === 'symbols' && fontContext.scriptFonts.symbolsText) { | |
| return fontContext.scriptFonts.symbolsText; | |
| } | |
| if (script === 'symbolsText' && fontContext.scriptFonts.symbolsText) { | |
| return fontContext.scriptFonts.symbolsText; | |
| } | |
| if (script === 'symbolsText' && activePdfEmojiFontFamily) { | |
| return activePdfEmojiFontFamily; | |
| } | |
| if (script && fontContext.scriptFonts[script]) { | |
| return fontContext.scriptFonts[script]; | |
| } | |
| if (activePdfEmojiFontFamily && containsEmojiStyleForPdf(segment)) { | |
| return activePdfEmojiFontFamily; | |
| } | |
| return ''; | |
| } | |
| function detectPdfScriptForSegment(segment, graphemes, index, fontContext) { | |
| if (PDF_SCRIPT_DETECTION_PATTERNS.latinExtended.test(segment)) { | |
| return 'latinExtended'; | |
| } | |
| if (PDF_LATIN_COMBINING_MARK_PATTERN.test(segment) && hasLatinExtendedContext(graphemes, index)) { | |
| return 'latinExtended'; | |
| } | |
| if (PDF_SCRIPT_DETECTION_PATTERNS.latin.test(segment) && hasLatinExtendedContext(graphemes, index)) { | |
| return 'latinExtended'; | |
| } | |
| if (PDF_SCRIPT_DETECTION_PATTERNS.japanese.test(segment)) { | |
| return 'japanese'; | |
| } | |
| if (PDF_SCRIPT_DETECTION_PATTERNS.korean.test(segment)) { | |
| return 'korean'; | |
| } | |
| if (PDF_CJK_SYMBOL_PATTERN.test(segment) || PDF_HAN_PATTERN.test(segment)) { | |
| return resolveCjkPdfScript(graphemes, index, fontContext); | |
| } | |
| if (containsEmojiStyleForPdf(segment)) { | |
| return 'symbols'; | |
| } | |
| if (containsPdfSymbolTextForRouting(segment)) { | |
| return 'symbolsText'; | |
| } | |
| if (containsEmojiForPdf(segment)) { | |
| return 'symbols'; | |
| } | |
| if (PDF_SCRIPT_DETECTION_PATTERNS.sinhala.test(segment)) { | |
| return 'sinhala'; | |
| } | |
| for (const script of PDF_DIRECT_SCRIPT_SCAN_ORDER) { | |
| const pattern = PDF_SCRIPT_DETECTION_PATTERNS[script]; | |
| if (pattern && pattern.test(segment)) { | |
| return script; | |
| } | |
| } | |
| return ''; | |
| } | |
| function hasLatinExtendedContext(graphemes, index) { | |
| return collectPdfTokenAroundIndex(graphemes, index, 12).some((segment) => { | |
| return PDF_SCRIPT_DETECTION_PATTERNS.latinExtended.test(segment) || PDF_LATIN_COMBINING_MARK_PATTERN.test(segment); | |
| }); | |
| } | |
| function containsPdfSymbolTextForRouting(text) { | |
| return PDF_SYMBOL_TEXT_PATTERN.test(ensureString(text)); | |
| } | |
| function containsEmojiStyleForPdf(text) { | |
| return PDF_EMOJI_STYLE_PATTERN.test(ensureString(text)); | |
| } | |
| function collectPdfTokenAroundIndex(graphemes, index, maxLength) { | |
| const output = []; | |
| const start = Math.max(0, index - maxLength); | |
| const end = Math.min(graphemes.length - 1, index + maxLength); | |
| for (let cursor = start; cursor <= end; cursor += 1) { | |
| const value = ensureString(graphemes[cursor]); | |
| if (!value) { | |
| continue; | |
| } | |
| if (cursor !== index && PDF_TOKEN_BREAK_PATTERN.test(value)) { | |
| if (cursor < index) { | |
| output.length = 0; | |
| continue; | |
| } | |
| break; | |
| } | |
| output.push(value); | |
| } | |
| return output; | |
| } | |
| function resolveCjkPdfScript(graphemes, index, fontContext) { | |
| const around = [ | |
| graphemes[index - 2] || '', | |
| graphemes[index - 1] || '', | |
| graphemes[index + 1] || '', | |
| graphemes[index + 2] || '' | |
| ].join(''); | |
| if (PDF_SCRIPT_DETECTION_PATTERNS.japanese.test(around)) { | |
| return 'japanese'; | |
| } | |
| if (PDF_SCRIPT_DETECTION_PATTERNS.korean.test(around)) { | |
| return 'korean'; | |
| } | |
| const detectedScripts = new Set( | |
| fontContext && Array.isArray(fontContext.detectedScripts) ? fontContext.detectedScripts : [] | |
| ); | |
| const detectedLanguages = fontContext && Array.isArray(fontContext.detectedLanguages) | |
| ? fontContext.detectedLanguages | |
| : []; | |
| const mainLanguage = fontContext && fontContext.mainLanguage ? fontContext.mainLanguage : ''; | |
| if (mainLanguage === 'ja' && detectedScripts.has('japanese')) { | |
| return 'japanese'; | |
| } | |
| if (mainLanguage === 'ko' && detectedScripts.has('korean')) { | |
| return 'korean'; | |
| } | |
| if (mainLanguage === 'zh' && detectedScripts.has('chinese')) { | |
| return 'chinese'; | |
| } | |
| if (detectedLanguages.indexOf('ja') !== -1 && detectedScripts.has('japanese')) { | |
| return 'japanese'; | |
| } | |
| if (detectedLanguages.indexOf('ko') !== -1 && detectedScripts.has('korean')) { | |
| return 'korean'; | |
| } | |
| if (detectedLanguages.indexOf('zh') !== -1 && detectedScripts.has('chinese')) { | |
| return 'chinese'; | |
| } | |
| if (detectedScripts.has('chinese') && !detectedScripts.has('japanese')) { | |
| return 'chinese'; | |
| } | |
| if (detectedScripts.has('japanese') && !detectedScripts.has('chinese')) { | |
| return 'japanese'; | |
| } | |
| if (detectedScripts.has('chinese')) { | |
| return 'chinese'; | |
| } | |
| if (detectedScripts.has('japanese')) { | |
| return 'japanese'; | |
| } | |
| if (detectedScripts.has('korean')) { | |
| return 'korean'; | |
| } | |
| return ''; | |
| } | |
| function splitGraphemes(text) { | |
| if (!graphemeSegmenterRef && typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') { | |
| try { | |
| graphemeSegmenterRef = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); | |
| } catch (err) { | |
| graphemeSegmenterRef = null; | |
| } | |
| } | |
| if (graphemeSegmenterRef) { | |
| return Array.from(graphemeSegmenterRef.segment(text), part => part.segment); | |
| } | |
| return Array.from(text); | |
| } | |
| function containsEmojiForPdf(text) { | |
| if (!emojiRegexRef) { | |
| try { | |
| emojiRegexRef = new RegExp( | |
| '(?:\\p{Extended_Pictographic}|\\p{Regional_Indicator}|\\p{Emoji_Modifier}|\\u{FE0F}|\\u{20E3}|\\u{200D})', | |
| 'u' | |
| ); | |
| } catch (err) { | |
| emojiRegexRef = /[\uD83C-\uDBFF][\uDC00-\uDFFF]|[\u2600-\u27BF]|\uFE0F/; | |
| } | |
| } | |
| return emojiRegexRef.test(text); | |
| } | |
| function ensureBaseFont(pdfMakeInstance) { | |
| const vfs = pdfMakeInstance.vfs || {}; | |
| const regular = vfs['Roboto-Regular.ttf'] ? 'Roboto-Regular.ttf' : | |
| (vfs['Roboto-Medium.ttf'] ? 'Roboto-Medium.ttf' : null); | |
| if (!regular) { | |
| pdfMakeInstance.fonts = Object.assign({}, pdfMakeInstance.fonts, { | |
| Helvetica: { | |
| normal: 'Helvetica', | |
| bold: 'Helvetica-Bold', | |
| italics: 'Helvetica-Oblique', | |
| bolditalics: 'Helvetica-BoldOblique' | |
| }, | |
| monospace: { | |
| normal: 'Helvetica', | |
| bold: 'Helvetica-Bold', | |
| italics: 'Helvetica-Oblique', | |
| bolditalics: 'Helvetica-BoldOblique' | |
| } | |
| }); | |
| return 'Helvetica'; | |
| } | |
| const italic = vfs['Roboto-Italic.ttf'] ? 'Roboto-Italic.ttf' : regular; | |
| const bold = vfs['Roboto-Medium.ttf'] ? 'Roboto-Medium.ttf' : regular; | |
| const bolditalic = vfs['Roboto-MediumItalic.ttf'] ? 'Roboto-MediumItalic.ttf' : italic; | |
| pdfMakeInstance.fonts = Object.assign({}, pdfMakeInstance.fonts, { | |
| Roboto: { | |
| normal: regular, | |
| bold: bold, | |
| italics: italic, | |
| bolditalics: bolditalic | |
| }, | |
| Courier: { | |
| normal: regular, | |
| bold: bold, | |
| italics: italic, | |
| bolditalics: bolditalic | |
| }, | |
| monospace: { | |
| normal: regular, | |
| bold: bold, | |
| italics: italic, | |
| bolditalics: bolditalic | |
| } | |
| }); | |
| return 'Roboto'; | |
| } | |
| function getLocalPdfMake() { | |
| try { | |
| if (typeof pdfMake !== 'undefined') { | |
| return pdfMake; | |
| } | |
| } catch (err) { | |
| return null; | |
| } | |
| return null; | |
| } | |
| function startObserver() { | |
| const observer = new MutationObserver((mutations) => { | |
| for (const mutation of mutations) { | |
| mutation.addedNodes.forEach((node) => { | |
| queueScanForNode(node); | |
| }); | |
| } | |
| }); | |
| observer.observe(document.body, { childList: true, subtree: true }); | |
| } | |
| function startViewportWatcher() { | |
| let resizeTimer = null; | |
| const scheduleRescan = () => { | |
| if (resizeTimer) { | |
| clearTimeout(resizeTimer); | |
| } | |
| resizeTimer = setTimeout(() => { | |
| resizeTimer = null; | |
| attachButtons(document); | |
| }, 120); | |
| }; | |
| window.addEventListener('resize', scheduleRescan, { passive: true }); | |
| window.addEventListener('orientationchange', scheduleRescan, { passive: true }); | |
| } | |
| injectStyles(); | |
| attachButtons(document); | |
| startObserver(); | |
| startViewportWatcher(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment