Skip to content

Instantly share code, notes, and snippets.

@subtleGradient
Last active March 16, 2026 13:08
Show Gist options
  • Select an option

  • Save subtleGradient/1370ba5798156195685eda088896af01 to your computer and use it in GitHub Desktop.

Select an option

Save subtleGradient/1370ba5798156195685eda088896af01 to your computer and use it in GitHub Desktop.
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