Last active
March 16, 2026 13:08
-
-
Save subtleGradient/1370ba5798156195685eda088896af01 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
| javascript:(async()=>{ | |
| const nativeConsoleState = (() => { | |
| try { | |
| const iframeHost = document.body || document.documentElement; | |
| if (!iframeHost) { | |
| return { | |
| cleanup: () => {}, | |
| host: window.console | |
| }; | |
| } | |
| const iframe = document.createElement("iframe"); | |
| iframe.setAttribute("aria-hidden", "true"); | |
| iframe.tabIndex = -1; | |
| iframe.style.display = "none"; | |
| iframeHost.appendChild(iframe); | |
| if (iframe.contentWindow?.console) { | |
| return { | |
| cleanup: () => { | |
| iframe.remove(); | |
| }, | |
| host: iframe.contentWindow.console | |
| }; | |
| } | |
| iframe.remove(); | |
| } catch (error) { | |
| window.console?.warn?.("[chatgpt-bookmarklet-zip] Failed to create native console iframe", error); | |
| } | |
| return { | |
| cleanup: () => {}, | |
| host: window.console | |
| }; | |
| })(); | |
| const nativeConsoleHost = nativeConsoleState.host; | |
| const cleanupNativeConsole = nativeConsoleState.cleanup; | |
| const nativeConsoleError = typeof nativeConsoleHost?.error === "function" | |
| ? nativeConsoleHost.error.bind(nativeConsoleHost) | |
| : () => {}; | |
| const nativeConsoleWarn = typeof nativeConsoleHost?.warn === "function" | |
| ? nativeConsoleHost.warn.bind(nativeConsoleHost) | |
| : nativeConsoleError; | |
| const clipboard = navigator.clipboard; | |
| if (!clipboard) { | |
| nativeConsoleError("[chatgpt-bookmarklet-zip] Clipboard API unavailable on this page."); | |
| cleanupNativeConsole(); | |
| alert("Clipboard API unavailable on this page."); | |
| return; | |
| } | |
| const buttons = Array.from( | |
| document.querySelectorAll('[data-testid="copy-turn-action-button"]') | |
| ); | |
| if (!buttons.length) { | |
| nativeConsoleError("[chatgpt-bookmarklet-zip] No copy buttons found."); | |
| cleanupNativeConsole(); | |
| alert("No copy buttons found."); | |
| return; | |
| } | |
| const exportedAt = new Date(); | |
| const payload = { | |
| url: location.href, | |
| title: document.title, | |
| exportedAt: exportedAt.toISOString(), | |
| buttonCount: buttons.length, | |
| capturedCount: 0, | |
| items: [], | |
| errors: [] | |
| }; | |
| const captureQueue = []; | |
| const waiters = []; | |
| const restores = []; | |
| restores.push(cleanupNativeConsole); | |
| const slowCaptureNoticeMs = 1500; | |
| const slowCaptureRepeatMs = 1000; | |
| const slowCaptureConsoleIntervalMs = 5000; | |
| const iframeOnlyCaptureTimeoutMs = 30000; | |
| const progressRevealDelayMs = 333; | |
| const finalToastDurationMs = 4000; | |
| const preferredMetadataKeys = ["testid", "turn", "turn-id", "model"]; | |
| let currentButtonIndex = null; | |
| let currentButtonNode = null; | |
| let currentButtonMetadata = null; | |
| let progressToast = null; | |
| const nextFrame = () => new Promise((resolve) => { | |
| window.requestAnimationFrame(() => { | |
| resolve(); | |
| }); | |
| }); | |
| const enqueueCapture = (entry) => { | |
| payload.items.push(entry); | |
| const waiter = waiters.shift(); | |
| if (waiter) { | |
| waiter(entry); | |
| return; | |
| } | |
| captureQueue.push(entry); | |
| }; | |
| const waitForCapture = ({ onSlowCapture, maxWaitMs } = {}) => new Promise((resolve) => { | |
| if (captureQueue.length) { | |
| resolve({ | |
| entry: captureQueue.shift(), | |
| status: "captured" | |
| }); | |
| return; | |
| } | |
| let settled = false; | |
| let slowCaptureTimer = null; | |
| let slowCaptureInterval = null; | |
| let maxWaitTimer = null; | |
| const clearSlowCaptureTimers = () => { | |
| if (slowCaptureTimer !== null) { | |
| window.clearTimeout(slowCaptureTimer); | |
| slowCaptureTimer = null; | |
| } | |
| if (slowCaptureInterval !== null) { | |
| window.clearInterval(slowCaptureInterval); | |
| slowCaptureInterval = null; | |
| } | |
| }; | |
| const clearMaxWaitTimer = () => { | |
| if (maxWaitTimer !== null) { | |
| window.clearTimeout(maxWaitTimer); | |
| maxWaitTimer = null; | |
| } | |
| }; | |
| const finish = (result) => { | |
| if (settled) { | |
| return; | |
| } | |
| settled = true; | |
| clearSlowCaptureTimers(); | |
| clearMaxWaitTimer(); | |
| const waiterIndex = waiters.indexOf(onCapture); | |
| if (waiterIndex >= 0) { | |
| waiters.splice(waiterIndex, 1); | |
| } | |
| resolve(result); | |
| }; | |
| const onCapture = (entry) => { | |
| finish({ | |
| entry, | |
| status: "captured" | |
| }); | |
| }; | |
| if (typeof onSlowCapture === "function") { | |
| slowCaptureTimer = window.setTimeout(() => { | |
| let elapsedMs = slowCaptureNoticeMs; | |
| onSlowCapture(elapsedMs); | |
| slowCaptureInterval = window.setInterval(() => { | |
| elapsedMs += slowCaptureRepeatMs; | |
| onSlowCapture(elapsedMs); | |
| }, slowCaptureRepeatMs); | |
| }, slowCaptureNoticeMs); | |
| } | |
| if (typeof maxWaitMs === "number" && maxWaitMs > 0) { | |
| maxWaitTimer = window.setTimeout(() => { | |
| finish({ | |
| status: "timedOut" | |
| }); | |
| }, maxWaitMs); | |
| } | |
| waiters.push(onCapture); | |
| }); | |
| const toKebabCase = (value) => value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); | |
| const getButtonTurnMetadata = (node) => { | |
| const turnNode = node.closest("[data-turn]"); | |
| const metadata = {}; | |
| if (!turnNode) { | |
| return metadata; | |
| } | |
| for (const [key, value] of Object.entries(turnNode.dataset)) { | |
| if (typeof value !== "string" || !value.length) { | |
| continue; | |
| } | |
| metadata[toKebabCase(key)] = value; | |
| } | |
| return metadata; | |
| }; | |
| const getButtonMessageModelSlug = (node) => { | |
| const turnNode = node.closest("[data-turn]"); | |
| const modelNode = turnNode ? turnNode.querySelector("[data-message-model-slug]") : null; | |
| const messageModelSlug = modelNode?.dataset?.messageModelSlug; | |
| if (typeof messageModelSlug === "string" && messageModelSlug.length) { | |
| return messageModelSlug; | |
| } | |
| return null; | |
| }; | |
| const getButtonMetadata = (node) => { | |
| const metadata = getButtonTurnMetadata(node); | |
| const messageModelSlug = getButtonMessageModelSlug(node); | |
| if (messageModelSlug) { | |
| metadata.model = messageModelSlug; | |
| } | |
| return metadata; | |
| }; | |
| const compareMetadataKeys = (left, right) => { | |
| const leftPriority = preferredMetadataKeys.indexOf(left); | |
| const rightPriority = preferredMetadataKeys.indexOf(right); | |
| if (leftPriority !== -1 || rightPriority !== -1) { | |
| if (leftPriority === -1) { | |
| return 1; | |
| } | |
| if (rightPriority === -1) { | |
| return -1; | |
| } | |
| return leftPriority - rightPriority; | |
| } | |
| return left.localeCompare(right); | |
| }; | |
| const toYamlScalar = (value) => JSON.stringify(String(value)); | |
| const makeYamlFrontmatter = (metadata) => { | |
| const entries = Object.entries(metadata || {}).filter(([, value]) => value != null && value !== ""); | |
| if (!entries.length) { | |
| return ""; | |
| } | |
| const lines = ["---"]; | |
| for (const [key, value] of entries.sort(([left], [right]) => compareMetadataKeys(left, right))) { | |
| lines.push(`${key}: ${toYamlScalar(value)}`); | |
| } | |
| lines.push("---", ""); | |
| return lines.join("\n"); | |
| }; | |
| const sanitizeFilenameSegment = (value, fallback) => { | |
| const normalized = String(value || "") | |
| .trim() | |
| .toLowerCase() | |
| .replace(/[^a-z0-9._-]+/g, "-") | |
| .replace(/^-+|-+$/g, ""); | |
| return normalized || fallback; | |
| }; | |
| const getTurnNumber = (metadata, fallback) => { | |
| const testid = metadata?.testid; | |
| const match = typeof testid === "string" ? testid.match(/turn-(\d+)/) : null; | |
| if (!match) { | |
| return fallback; | |
| } | |
| const parsed = Number.parseInt(match[1], 10); | |
| return Number.isFinite(parsed) ? parsed : fallback; | |
| }; | |
| const getFilenameLabel = (metadata) => { | |
| if (metadata?.turn === "user") { | |
| return "user"; | |
| } | |
| if (metadata?.turn === "assistant") { | |
| return sanitizeFilenameSegment(metadata.model, "assistant"); | |
| } | |
| return sanitizeFilenameSegment(metadata?.turn, "message"); | |
| }; | |
| const makeMarkdownFilename = (item, index, padWidth) => { | |
| const turnNumber = getTurnNumber(item.articleMetadata, index + 1); | |
| const label = getFilenameLabel(item.articleMetadata); | |
| const fileNumber = String(turnNumber).padStart(padWidth, "0"); | |
| return `${fileNumber}-${label}.md`; | |
| }; | |
| const makeEntry = (kind, details) => ({ | |
| index: payload.items.length, | |
| buttonIndex: currentButtonIndex, | |
| articleMetadata: currentButtonMetadata ? { ...currentButtonMetadata } : {}, | |
| copiedAt: new Date().toISOString(), | |
| kind, | |
| ...details | |
| }); | |
| const makeErrorMessage = (error) => error instanceof Error ? error.message : String(error); | |
| const getCurrentTurnDetails = () => { | |
| const turnNode = currentButtonNode?.closest("[data-turn]") || null; | |
| const iframeNode = turnNode?.querySelector("iframe") || null; | |
| if (!turnNode) { | |
| return null; | |
| } | |
| return { | |
| copyButtonAriaLabel: currentButtonNode?.getAttribute("aria-label") || null, | |
| copyButtonState: currentButtonNode?.getAttribute("data-state") || null, | |
| hasIframe: Boolean(iframeNode), | |
| hasMessageIdNode: Boolean(turnNode.querySelector("[data-message-id]")), | |
| hasMessageModelNode: Boolean(turnNode.querySelector("[data-message-model-slug]")), | |
| iframeTitle: iframeNode?.getAttribute("title") || null | |
| }; | |
| }; | |
| const getCaptureTimeoutMs = () => { | |
| const turnDetails = getCurrentTurnDetails(); | |
| if (turnDetails?.hasIframe && !turnDetails?.hasMessageIdNode) { | |
| return iframeOnlyCaptureTimeoutMs; | |
| } | |
| return null; | |
| }; | |
| const getDebugContext = (overrides = {}) => { | |
| const articleMetadata = currentButtonMetadata ? { ...currentButtonMetadata } : {}; | |
| const turnDetails = getCurrentTurnDetails(); | |
| return { | |
| buttonIndex: currentButtonIndex, | |
| articleMetadata, | |
| turnDetails, | |
| ...overrides | |
| }; | |
| }; | |
| const recordError = (stage, error, overrides = {}) => { | |
| const context = getDebugContext(overrides); | |
| const entry = { | |
| stage, | |
| buttonIndex: context.buttonIndex, | |
| error: makeErrorMessage(error) | |
| }; | |
| if (context.articleMetadata && Object.keys(context.articleMetadata).length) { | |
| entry.articleMetadata = context.articleMetadata; | |
| } | |
| for (const [key, value] of Object.entries(context)) { | |
| if (key === "buttonIndex" || key === "articleMetadata" || value == null) { | |
| continue; | |
| } | |
| entry[key] = value; | |
| } | |
| payload.errors.push(entry); | |
| nativeConsoleError("[chatgpt-bookmarklet-zip]", entry, error); | |
| return entry; | |
| }; | |
| const logDebugError = (stage, error, overrides = {}) => { | |
| nativeConsoleError("[chatgpt-bookmarklet-zip]", { | |
| stage, | |
| ...getDebugContext(overrides), | |
| error: makeErrorMessage(error) | |
| }, error); | |
| }; | |
| const maybeLogSlowCapture = (elapsedMs) => { | |
| if ( | |
| elapsedMs !== slowCaptureNoticeMs && | |
| (elapsedMs - slowCaptureNoticeMs) % slowCaptureConsoleIntervalMs !== 0 | |
| ) { | |
| return; | |
| } | |
| const context = getDebugContext({ elapsedMs }); | |
| const note = context.turnDetails?.hasIframe && !context.turnDetails?.hasMessageIdNode | |
| ? "iframe-only turn may not use normal clipboard hooks" | |
| : null; | |
| nativeConsoleWarn("[chatgpt-bookmarklet-zip] Still waiting for clipboard capture", { | |
| ...context, | |
| note | |
| }); | |
| }; | |
| const readBlobText = async (blob) => { | |
| try { | |
| return await blob.text(); | |
| } catch (error) { | |
| logDebugError("read-blob-text", error); | |
| return `[unreadable blob: ${error instanceof Error ? error.message : String(error)}]`; | |
| } | |
| }; | |
| const interceptWriteText = async (text) => { | |
| enqueueCapture( | |
| makeEntry("writeText", { | |
| text: String(text) | |
| }) | |
| ); | |
| }; | |
| const interceptWrite = async (items) => { | |
| const serializedItems = []; | |
| for (const item of Array.from(items || [])) { | |
| const types = Array.isArray(item.types) ? Array.from(item.types) : []; | |
| const serializedItem = { types }; | |
| if (typeof item.getType === "function" && types.includes("text/plain")) { | |
| try { | |
| const blob = await item.getType("text/plain"); | |
| serializedItem.text = await readBlobText(blob); | |
| } catch (error) { | |
| serializedItem.textError = makeErrorMessage(error); | |
| logDebugError("clipboard-item-get-type", error, { types }); | |
| } | |
| } | |
| serializedItems.push(serializedItem); | |
| } | |
| enqueueCapture( | |
| makeEntry("write", { | |
| text: | |
| serializedItems | |
| .map((item) => item.text) | |
| .filter((text) => typeof text === "string" && text.length > 0) | |
| .join("\n\n") || null, | |
| items: serializedItems | |
| }) | |
| ); | |
| }; | |
| const interceptCopyEvent = (event) => { | |
| if (currentButtonIndex == null) { | |
| return; | |
| } | |
| const clipboardData = event.clipboardData; | |
| if (!clipboardData) { | |
| nativeConsoleWarn("[chatgpt-bookmarklet-zip] Copy event had no clipboardData", getDebugContext()); | |
| return; | |
| } | |
| const types = Array.isArray(clipboardData.types) ? Array.from(clipboardData.types) : []; | |
| let text = null; | |
| let html = null; | |
| try { | |
| text = clipboardData.getData("text/plain") || null; | |
| } catch (error) { | |
| logDebugError("copy-event-get-text", error, { types }); | |
| } | |
| try { | |
| html = clipboardData.getData("text/html") || null; | |
| } catch (error) { | |
| logDebugError("copy-event-get-html", error, { types }); | |
| } | |
| if (!text && !html && !types.length) { | |
| nativeConsoleWarn("[chatgpt-bookmarklet-zip] Copy event carried no data", getDebugContext()); | |
| return; | |
| } | |
| enqueueCapture( | |
| makeEntry("copyEvent", { | |
| html, | |
| text, | |
| items: [{ html, text, types }] | |
| }) | |
| ); | |
| }; | |
| const installCopyEventInterception = () => { | |
| document.addEventListener("copy", interceptCopyEvent); | |
| restores.push(() => { | |
| document.removeEventListener("copy", interceptCopyEvent); | |
| }); | |
| }; | |
| const overrideExecCommand = () => { | |
| if (typeof document.execCommand !== "function") { | |
| return false; | |
| } | |
| try { | |
| const originalExecCommand = document.execCommand; | |
| document.execCommand = function execCommandOverride(command, ...args) { | |
| const normalizedCommand = String(command || "").toLowerCase(); | |
| try { | |
| const result = originalExecCommand.call(this, command, ...args); | |
| if (normalizedCommand === "copy" && result === false) { | |
| nativeConsoleWarn("[chatgpt-bookmarklet-zip] document.execCommand('copy') returned false", getDebugContext()); | |
| } | |
| return result; | |
| } catch (error) { | |
| if (normalizedCommand === "copy") { | |
| logDebugError("document-exec-command-copy", error); | |
| } | |
| throw error; | |
| } | |
| }; | |
| restores.push(() => { | |
| document.execCommand = originalExecCommand; | |
| }); | |
| return true; | |
| } catch (error) { | |
| logDebugError("override-document-exec-command", error); | |
| return false; | |
| } | |
| }; | |
| const overrideMethod = (name, replacement) => { | |
| const targets = [clipboard, Object.getPrototypeOf(clipboard)].filter(Boolean); | |
| for (const target of targets) { | |
| if (typeof target[name] !== "function") { | |
| continue; | |
| } | |
| try { | |
| const descriptor = Object.getOwnPropertyDescriptor(target, name); | |
| Object.defineProperty(target, name, { | |
| configurable: true, | |
| writable: true, | |
| value: replacement | |
| }); | |
| restores.push(() => { | |
| if (descriptor) { | |
| Object.defineProperty(target, name, descriptor); | |
| return; | |
| } | |
| delete target[name]; | |
| }); | |
| return true; | |
| } catch (error) { | |
| logDebugError(`override-clipboard-${name}`, error); | |
| } | |
| } | |
| return false; | |
| }; | |
| const restorePageState = () => { | |
| while (restores.length) { | |
| const restore = restores.pop(); | |
| try { | |
| restore(); | |
| } catch (error) { | |
| logDebugError("restore-page-state", error); | |
| } | |
| } | |
| }; | |
| const injectExportStyles = () => { | |
| const styleHost = document.head || document.documentElement; | |
| const style = document.createElement("style"); | |
| style.textContent = [ | |
| "html.opencopy-export *, html.opencopy-export *::before, html.opencopy-export *::after {", | |
| " animation: none !important;", | |
| " transition: none !important;", | |
| " scroll-behavior: auto !important;", | |
| "}", | |
| "html.opencopy-export [data-testid=\"copy-turn-action-button\"] {", | |
| " opacity: 1 !important;", | |
| " visibility: visible !important;", | |
| "}", | |
| "html.opencopy-export [role=\"tooltip\"],", | |
| "html.opencopy-export [data-radix-popper-content-wrapper],", | |
| "html.opencopy-export [data-state=\"open\"] [role=\"status\"] {", | |
| " display: none !important;", | |
| "}", | |
| "html.opencopy-export.opencopy-hide-messages [data-message-id] {", | |
| " display: none !important;", | |
| "}" | |
| ].join("\n"); | |
| document.documentElement.classList.add("opencopy-export"); | |
| styleHost.appendChild(style); | |
| restores.push(() => { | |
| document.documentElement.classList.remove("opencopy-export"); | |
| style.remove(); | |
| }); | |
| }; | |
| const hideConversationMessages = () => { | |
| document.documentElement.classList.add("opencopy-hide-messages"); | |
| restores.push(() => { | |
| document.documentElement.classList.remove("opencopy-hide-messages"); | |
| }); | |
| }; | |
| const createProgressToast = () => { | |
| const existingToast = document.getElementById("opencopy-export-toast"); | |
| if (existingToast) { | |
| existingToast.remove(); | |
| } | |
| const host = document.body || document.documentElement; | |
| const toast = document.createElement("div"); | |
| const title = document.createElement("div"); | |
| const detail = document.createElement("div"); | |
| const progressBar = document.createElement("progress"); | |
| let hideTimer = null; | |
| let progressRevealTimer = null; | |
| let progressPhase = null; | |
| toast.id = "opencopy-export-toast"; | |
| Object.assign(toast.style, { | |
| position: "fixed", | |
| top: "16px", | |
| left: "50%", | |
| transform: "translateX(-50%)", | |
| zIndex: "2147483647", | |
| width: "min(480px, calc(100vw - 24px))", | |
| padding: "12px 14px", | |
| borderRadius: "14px", | |
| boxShadow: "0 12px 32px rgba(0, 0, 0, 0.24)", | |
| backdropFilter: "blur(12px)", | |
| background: "rgba(15, 23, 42, 0.92)", | |
| border: "1px solid rgba(148, 163, 184, 0.35)", | |
| color: "#f8fafc", | |
| pointerEvents: "none", | |
| fontFamily: "ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" | |
| }); | |
| Object.assign(title.style, { | |
| fontSize: "13px", | |
| fontWeight: "700", | |
| lineHeight: "1.2" | |
| }); | |
| Object.assign(detail.style, { | |
| marginTop: "4px", | |
| fontSize: "12px", | |
| lineHeight: "1.4", | |
| color: "rgba(226, 232, 240, 0.88)" | |
| }); | |
| Object.assign(progressBar.style, { | |
| marginTop: "10px", | |
| display: "none", | |
| width: "100%", | |
| height: "10px", | |
| accentColor: "#10a37f" | |
| }); | |
| toast.append(title, detail, progressBar); | |
| host.appendChild(toast); | |
| const clearHideTimer = () => { | |
| if (hideTimer !== null) { | |
| window.clearTimeout(hideTimer); | |
| hideTimer = null; | |
| } | |
| }; | |
| const clearProgressRevealTimer = () => { | |
| if (progressRevealTimer !== null) { | |
| window.clearTimeout(progressRevealTimer); | |
| progressRevealTimer = null; | |
| } | |
| }; | |
| const showProgressBar = () => { | |
| progressBar.style.display = "block"; | |
| }; | |
| const hideProgressBar = () => { | |
| clearProgressRevealTimer(); | |
| progressPhase = null; | |
| progressBar.style.display = "none"; | |
| progressBar.removeAttribute("value"); | |
| progressBar.max = 1; | |
| }; | |
| const setProgress = (progress) => { | |
| if (!progress || progress.mode === "hidden") { | |
| hideProgressBar(); | |
| return; | |
| } | |
| if (progress.mode === "determinate") { | |
| progressBar.max = progress.total; | |
| progressBar.value = progress.current; | |
| } else { | |
| progressBar.removeAttribute("value"); | |
| progressBar.max = 1; | |
| } | |
| const nextPhase = progress.phase || progress.mode; | |
| if (progressPhase === nextPhase) { | |
| return; | |
| } | |
| progressPhase = nextPhase; | |
| progressBar.style.display = "none"; | |
| clearProgressRevealTimer(); | |
| const revealDelayMs = typeof progress.delayMs === "number" | |
| ? progress.delayMs | |
| : progressRevealDelayMs; | |
| if (revealDelayMs <= 0) { | |
| showProgressBar(); | |
| return; | |
| } | |
| progressRevealTimer = window.setTimeout(() => { | |
| progressRevealTimer = null; | |
| if (progressPhase === nextPhase) { | |
| showProgressBar(); | |
| } | |
| }, revealDelayMs); | |
| }; | |
| const setTone = (tone) => { | |
| if (tone === "success") { | |
| toast.style.background = "rgba(6, 95, 70, 0.94)"; | |
| toast.style.borderColor = "rgba(110, 231, 183, 0.42)"; | |
| progressBar.style.accentColor = "#a7f3d0"; | |
| return; | |
| } | |
| if (tone === "error") { | |
| toast.style.background = "rgba(127, 29, 29, 0.94)"; | |
| toast.style.borderColor = "rgba(252, 165, 165, 0.42)"; | |
| progressBar.style.accentColor = "#fca5a5"; | |
| return; | |
| } | |
| toast.style.background = "rgba(15, 23, 42, 0.92)"; | |
| toast.style.borderColor = "rgba(148, 163, 184, 0.35)"; | |
| progressBar.style.accentColor = "#10a37f"; | |
| }; | |
| const destroy = () => { | |
| clearHideTimer(); | |
| clearProgressRevealTimer(); | |
| toast.remove(); | |
| }; | |
| const update = ({ titleText, detailText, tone, hideAfterMs, progress }) => { | |
| clearHideTimer(); | |
| setTone(tone || "active"); | |
| title.textContent = titleText; | |
| detail.textContent = detailText || ""; | |
| setProgress(progress); | |
| if (typeof hideAfterMs === "number") { | |
| hideTimer = window.setTimeout(destroy, hideAfterMs); | |
| } | |
| }; | |
| return { | |
| destroy, | |
| update | |
| }; | |
| }; | |
| const downloadBlob = (blob, filename) => { | |
| const downloadUrl = URL.createObjectURL(blob); | |
| const link = document.createElement("a"); | |
| link.href = downloadUrl; | |
| link.download = filename; | |
| document.body.appendChild(link); | |
| link.click(); | |
| link.remove(); | |
| window.setTimeout(() => { | |
| URL.revokeObjectURL(downloadUrl); | |
| }, 1000); | |
| }; | |
| const downloadJson = (data) => { | |
| const filenameStamp = exportedAt.toISOString().replace(/[:.]/g, "-"); | |
| const blob = new Blob([JSON.stringify(data, null, 2)], { | |
| type: "application/json" | |
| }); | |
| downloadBlob(blob, `chatgpt-copies-${filenameStamp}.json`); | |
| }; | |
| const encoder = new TextEncoder(); | |
| const crc32Table = (() => { | |
| const table = new Uint32Array(256); | |
| for (let index = 0; index < 256; index += 1) { | |
| let value = index; | |
| for (let bit = 0; bit < 8; bit += 1) { | |
| value = (value & 1) ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1); | |
| } | |
| table[index] = value >>> 0; | |
| } | |
| return table; | |
| })(); | |
| const crc32 = (bytes) => { | |
| let value = 0xffffffff; | |
| for (const byte of bytes) { | |
| value = crc32Table[(value ^ byte) & 0xff] ^ (value >>> 8); | |
| } | |
| return (value ^ 0xffffffff) >>> 0; | |
| }; | |
| const getDosTimestamp = (date) => { | |
| const year = Math.max(date.getFullYear(), 1980); | |
| const month = date.getMonth() + 1; | |
| const day = date.getDate(); | |
| const hours = date.getHours(); | |
| const minutes = date.getMinutes(); | |
| const seconds = Math.floor(date.getSeconds() / 2); | |
| return { | |
| date: (((year - 1980) & 0x7f) << 9) | ((month & 0x0f) << 5) | (day & 0x1f), | |
| time: ((hours & 0x1f) << 11) | ((minutes & 0x3f) << 5) | (seconds & 0x1f) | |
| }; | |
| }; | |
| const concatBytes = (chunks) => { | |
| const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); | |
| const merged = new Uint8Array(totalLength); | |
| let offset = 0; | |
| for (const chunk of chunks) { | |
| merged.set(chunk, offset); | |
| offset += chunk.length; | |
| } | |
| return merged; | |
| }; | |
| const createLocalFileHeader = (entry) => { | |
| const header = new Uint8Array(30 + entry.nameBytes.length); | |
| const view = new DataView(header.buffer); | |
| view.setUint32(0, 0x04034b50, true); | |
| view.setUint16(4, 20, true); | |
| view.setUint16(6, 0x0800, true); | |
| view.setUint16(8, 0, true); | |
| view.setUint16(10, entry.time, true); | |
| view.setUint16(12, entry.date, true); | |
| view.setUint32(14, entry.crc, true); | |
| view.setUint32(18, entry.dataBytes.length, true); | |
| view.setUint32(22, entry.dataBytes.length, true); | |
| view.setUint16(26, entry.nameBytes.length, true); | |
| view.setUint16(28, 0, true); | |
| header.set(entry.nameBytes, 30); | |
| return header; | |
| }; | |
| const createCentralDirectoryHeader = (entry) => { | |
| const header = new Uint8Array(46 + entry.nameBytes.length); | |
| const view = new DataView(header.buffer); | |
| view.setUint32(0, 0x02014b50, true); | |
| view.setUint16(4, 20, true); | |
| view.setUint16(6, 20, true); | |
| view.setUint16(8, 0x0800, true); | |
| view.setUint16(10, 0, true); | |
| view.setUint16(12, entry.time, true); | |
| view.setUint16(14, entry.date, true); | |
| view.setUint32(16, entry.crc, true); | |
| view.setUint32(20, entry.dataBytes.length, true); | |
| view.setUint32(24, entry.dataBytes.length, true); | |
| view.setUint16(28, entry.nameBytes.length, true); | |
| view.setUint16(30, 0, true); | |
| view.setUint16(32, 0, true); | |
| view.setUint16(34, 0, true); | |
| view.setUint16(36, 0, true); | |
| view.setUint32(38, 0, true); | |
| view.setUint32(42, entry.offset, true); | |
| header.set(entry.nameBytes, 46); | |
| return header; | |
| }; | |
| const createEndOfCentralDirectory = (entryCount, centralDirectorySize, centralDirectoryOffset) => { | |
| const footer = new Uint8Array(22); | |
| const view = new DataView(footer.buffer); | |
| view.setUint32(0, 0x06054b50, true); | |
| view.setUint16(4, 0, true); | |
| view.setUint16(6, 0, true); | |
| view.setUint16(8, entryCount, true); | |
| view.setUint16(10, entryCount, true); | |
| view.setUint32(12, centralDirectorySize, true); | |
| view.setUint32(16, centralDirectoryOffset, true); | |
| view.setUint16(20, 0, true); | |
| return footer; | |
| }; | |
| const createZipBytes = (files) => { | |
| const timestamp = getDosTimestamp(exportedAt); | |
| const entries = Object.entries(files).map(([filename, content]) => { | |
| const nameBytes = encoder.encode(filename); | |
| const dataBytes = encoder.encode(content); | |
| return { | |
| filename, | |
| nameBytes, | |
| dataBytes, | |
| crc: crc32(dataBytes), | |
| date: timestamp.date, | |
| time: timestamp.time, | |
| offset: 0 | |
| }; | |
| }); | |
| const localParts = []; | |
| let offset = 0; | |
| for (const entry of entries) { | |
| entry.offset = offset; | |
| const header = createLocalFileHeader(entry); | |
| localParts.push(header, entry.dataBytes); | |
| offset += header.length + entry.dataBytes.length; | |
| } | |
| const centralDirectoryParts = []; | |
| let centralDirectorySize = 0; | |
| for (const entry of entries) { | |
| const header = createCentralDirectoryHeader(entry); | |
| centralDirectoryParts.push(header); | |
| centralDirectorySize += header.length; | |
| } | |
| const endOfCentralDirectory = createEndOfCentralDirectory( | |
| entries.length, | |
| centralDirectorySize, | |
| offset | |
| ); | |
| return concatBytes([ | |
| ...localParts, | |
| ...centralDirectoryParts, | |
| endOfCentralDirectory | |
| ]); | |
| }; | |
| const makeMarkdownFiles = (data) => { | |
| const files = {}; | |
| const maxTurnNumber = data.items.reduce((highest, item, index) => { | |
| return Math.max(highest, getTurnNumber(item.articleMetadata, index + 1)); | |
| }, 1); | |
| const padWidth = Math.max(3, String(maxTurnNumber).length); | |
| const readmeLines = [ | |
| "# ChatGPT Export", | |
| "", | |
| `- Title: ${data.title}`, | |
| `- URL: ${data.url}`, | |
| `- Exported At: ${data.exportedAt}`, | |
| `- Buttons Found: ${data.buttonCount}`, | |
| `- Captures Saved: ${data.capturedCount}`, | |
| `- Errors: ${data.errors.length}`, | |
| "", | |
| "## Files", | |
| "" | |
| ]; | |
| data.items.forEach((item, index) => { | |
| const filename = makeMarkdownFilename(item, index, padWidth); | |
| const content = item.kind === "timeout" | |
| ? [ | |
| "# Capture Timed Out", | |
| "", | |
| `This turn did not emit a clipboard capture signal within ${item.timeoutMs}ms, so it was skipped to let the rest of the export finish.`, | |
| "", | |
| `- Button Index: ${item.buttonIndex}`, | |
| `- Captured At: ${item.copiedAt}`, | |
| `- Kind: ${item.kind}` | |
| ].join("\n") | |
| : typeof item.text === "string" && item.text.length | |
| ? item.text | |
| : [ | |
| `# Empty Capture ${index + 1}`, | |
| "", | |
| "No text/plain clipboard content was captured for this entry.", | |
| "", | |
| `- Button Index: ${item.buttonIndex}`, | |
| `- Captured At: ${item.copiedAt}`, | |
| `- Kind: ${item.kind}` | |
| ].join("\n"); | |
| const frontmatter = makeYamlFrontmatter(item.articleMetadata); | |
| files[filename] = `${frontmatter}${content}`; | |
| readmeLines.push( | |
| `- \`${filename}\` - button ${item.buttonIndex} - ${item.kind}` | |
| ); | |
| }); | |
| if (!data.items.length) { | |
| readmeLines.push("- No captures were saved."); | |
| } | |
| files["README.md"] = readmeLines.join("\n"); | |
| if (data.errors.length) { | |
| files["errors.md"] = [ | |
| "# Export Errors", | |
| "", | |
| ...data.errors.flatMap((error, index) => [ | |
| `## Error ${index + 1}`, | |
| "", | |
| `- Button Index: ${error.buttonIndex}`, | |
| `- Message: ${error.error}`, | |
| "" | |
| ]) | |
| ].join("\n"); | |
| } | |
| return files; | |
| }; | |
| const downloadZip = async (data) => { | |
| const filenameStamp = exportedAt.toISOString().replace(/[:.]/g, "-"); | |
| const markdownFiles = makeMarkdownFiles(data); | |
| const zipBytes = createZipBytes(markdownFiles); | |
| const blob = new Blob([zipBytes], { | |
| type: "application/zip" | |
| }); | |
| downloadBlob(blob, `chatgpt-copies-${filenameStamp}.zip`); | |
| }; | |
| try { | |
| const buttonMetadata = buttons.map((button) => getButtonMetadata(button)); | |
| injectExportStyles(); | |
| hideConversationMessages(); | |
| progressToast = createProgressToast(); | |
| progressToast.update({ | |
| titleText: "Preparing export", | |
| detailText: `Found ${buttons.length} copy buttons`, | |
| progress: { | |
| phase: "prepare", | |
| mode: "determinate", | |
| current: 0, | |
| total: buttons.length | |
| } | |
| }); | |
| await nextFrame(); | |
| const didOverrideWriteText = overrideMethod("writeText", interceptWriteText); | |
| const didOverrideWrite = overrideMethod("write", interceptWrite); | |
| const didOverrideExecCommand = overrideExecCommand(); | |
| installCopyEventInterception(); | |
| if (!didOverrideWriteText && !didOverrideWrite && !didOverrideExecCommand) { | |
| throw new Error("Clipboard copy hooks could not be installed."); | |
| } | |
| for (let index = 0; index < buttons.length; index += 1) { | |
| currentButtonIndex = index; | |
| currentButtonNode = buttons[index]; | |
| currentButtonMetadata = buttonMetadata[index] || {}; | |
| const captureTimeoutMs = getCaptureTimeoutMs(); | |
| try { | |
| progressToast.update({ | |
| titleText: "Capturing markdown", | |
| detailText: `Copying ${index + 1} of ${buttons.length}`, | |
| progress: { | |
| phase: "capture", | |
| mode: "determinate", | |
| current: index, | |
| total: buttons.length | |
| } | |
| }); | |
| buttons[index].click(); | |
| const captureResult = await waitForCapture({ | |
| maxWaitMs: captureTimeoutMs, | |
| onSlowCapture: (elapsedMs) => { | |
| maybeLogSlowCapture(elapsedMs); | |
| progressToast.update({ | |
| titleText: "Capturing markdown", | |
| detailText: `Still waiting on ${index + 1} of ${buttons.length} (${Math.round(elapsedMs / 1000)}s)`, | |
| progress: { | |
| phase: "capture", | |
| mode: "determinate", | |
| current: index, | |
| total: buttons.length | |
| } | |
| }); | |
| } | |
| }); | |
| if (captureResult.status === "timedOut") { | |
| recordError( | |
| "capture-timeout", | |
| `Timed out after ${captureTimeoutMs}ms waiting for clipboard capture`, | |
| { | |
| buttonIndex: index, | |
| timeoutMs: captureTimeoutMs | |
| } | |
| ); | |
| enqueueCapture( | |
| makeEntry("timeout", { | |
| text: null, | |
| timeoutMs: captureTimeoutMs | |
| }) | |
| ); | |
| progressToast.update({ | |
| titleText: "Skipped stalled turn", | |
| detailText: `Skipped ${index + 1} of ${buttons.length} after ${Math.round(captureTimeoutMs / 1000)}s`, | |
| progress: { | |
| phase: "capture", | |
| mode: "determinate", | |
| current: index + 1, | |
| total: buttons.length | |
| } | |
| }); | |
| continue; | |
| } | |
| progressToast.update({ | |
| titleText: "Capturing markdown", | |
| detailText: `Processed ${index + 1} of ${buttons.length}`, | |
| progress: { | |
| phase: "capture", | |
| mode: "determinate", | |
| current: index + 1, | |
| total: buttons.length | |
| } | |
| }); | |
| } catch (error) { | |
| recordError("capture-button", error, { buttonIndex: index }); | |
| } | |
| } | |
| } catch (error) { | |
| recordError("capture-loop", error); | |
| } finally { | |
| currentButtonIndex = null; | |
| currentButtonNode = null; | |
| currentButtonMetadata = null; | |
| payload.capturedCount = payload.items.filter((item) => item.kind !== "timeout").length; | |
| try { | |
| if (progressToast) { | |
| progressToast.update({ | |
| titleText: "Building zip", | |
| detailText: `Packaging ${payload.capturedCount} markdown files`, | |
| progress: { | |
| phase: "zip", | |
| mode: "indeterminate" | |
| } | |
| }); | |
| await nextFrame(); | |
| } | |
| await downloadZip(payload); | |
| if (progressToast) { | |
| progressToast.update({ | |
| titleText: "Zip ready", | |
| detailText: `Downloaded ${payload.capturedCount} of ${payload.buttonCount} copy actions as markdown`, | |
| tone: "success", | |
| hideAfterMs: finalToastDurationMs, | |
| progress: { | |
| phase: "complete", | |
| mode: "determinate", | |
| current: 1, | |
| total: 1, | |
| delayMs: 0 | |
| } | |
| }); | |
| } | |
| } catch (error) { | |
| recordError("zip-export", `Zip export failed: ${makeErrorMessage(error)}`, { | |
| buttonIndex: null, | |
| articleMetadata: {} | |
| }); | |
| downloadJson(payload); | |
| if (progressToast) { | |
| progressToast.update({ | |
| titleText: "Zip failed", | |
| detailText: `Downloaded JSON fallback after capturing ${payload.capturedCount} of ${payload.buttonCount} copy actions`, | |
| tone: "error", | |
| hideAfterMs: finalToastDurationMs, | |
| progress: { | |
| phase: "error", | |
| mode: "hidden" | |
| } | |
| }); | |
| } | |
| } finally { | |
| restorePageState(); | |
| } | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment