Greasy Fork is available in English.
Export the current ChatGPT conversation as a Markdown document.
// ==UserScript==
// @name ChatGPT Markdown Export
// @name:zh-CN ChatGPT 对话导出(Markdown)
// @name:zh-TW ChatGPT 對話匯出(Markdown)
// @name:ja ChatGPT 会話エクスポート(Markdown)
//
// @namespace https://github.com/yoyoithink/ChatGPT-Markdown-File-Export
// @version 0.6.0
//
// @description Export the current ChatGPT conversation as a Markdown document.
// @description:zh-CN 将当前 ChatGPT 对话导出为 Markdown 文档。
// @description:zh-TW 將目前的 ChatGPT 對話匯出為 Markdown 文件。
// @description:ja 現在の ChatGPT 会話を Markdown ドキュメントとしてエクスポートします。
//
// @license MIT
//
// @match https://chatgpt.com/*
// @match https://chat.openai.com/*
// @run-at document-idle
// @noframes
// ==/UserScript==
(() => {
"use strict";
// ── Constants ─────────────────────────────────────────────────────────
const CGE_BTN_ATTR = "data-cge-export";
const CGE_TOAST_ID = "cge-toast-root";
const CGE_NAV_FLAG = "__cgeNavWrapped";
// ── i18n ──────────────────────────────────────────────────────────────
const I18N = {
en: {
exportBtn: "Export Markdown",
exportLabel: "Export",
noContent: "No conversation content found",
exportFailed: "Export failed",
roleUser: "User",
roleAssistant: "Assistant",
roleSystem: "System",
roleTool: "Tool",
roleMessage: "Message",
thinkingLabel: "Thinking",
imageLabel: "Image",
fileLabel: "File",
exportOk: "Exported successfully",
},
zh: {
exportBtn: "导出 Markdown",
exportLabel: "导出",
noContent: "未找到对话内容",
exportFailed: "导出失败",
roleUser: "用户",
roleAssistant: "助手",
roleSystem: "系统",
roleTool: "工具",
roleMessage: "消息",
thinkingLabel: "思考过程",
imageLabel: "图片",
fileLabel: "文件",
exportOk: "导出成功",
},
ja: {
exportBtn: "Markdown エクスポート",
exportLabel: "Export",
noContent: "会話内容が見つかりません",
exportFailed: "エクスポート失敗",
roleUser: "ユーザー",
roleAssistant: "アシスタント",
roleSystem: "システム",
roleTool: "ツール",
roleMessage: "メッセージ",
thinkingLabel: "思考プロセス",
imageLabel: "画像",
fileLabel: "ファイル",
exportOk: "エクスポート完了",
},
};
function detectLocale() {
const tag = (
document.documentElement?.getAttribute("lang") ||
navigator.language ||
"en"
).toLowerCase();
if (tag.startsWith("zh")) return "zh";
if (tag.startsWith("ja")) return "ja";
return "en";
}
let _locale = detectLocale();
function te(key) {
return (I18N[_locale] || I18N.en)[key] || I18N.en[key] || key;
}
// ── Utilities ─────────────────────────────────────────────────────────
function sanitizeFilename(input, replacement = "_") {
const illegalRe = /[\/\\\?\%\*\:\|"<>\u0000-\u001F]/g;
const reservedRe = /^\.+$/;
const windowsReservedRe = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
let name = String(input ?? "")
.replace(illegalRe, replacement)
.replace(/\s+/g, " ")
.trim()
.replace(/[. ]+$/g, "");
if (!name || reservedRe.test(name)) name = "chat_export";
if (windowsReservedRe.test(name)) name = `chat_${name}`;
return name;
}
function downloadText(filename, text, mime = "text/plain") {
const blob = new Blob([text], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
// ── Theme detection ─────────────────────────────────────────────────
function isDarkTheme() {
const root = document.documentElement;
if (root.classList.contains("dark")) return true;
if (root.classList.contains("light")) return false;
if (root.dataset.theme === "dark") return true;
if (root.dataset.theme === "light") return false;
const cs = window.getComputedStyle(root).colorScheme;
if (cs && cs.includes("dark")) return true;
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
// ── Conversation extraction ───────────────────────────────────────────
function getConversationTitle() {
const candidates = [
document.querySelector('nav a[aria-current="page"]')?.textContent,
document.querySelector("#history a[data-active]")?.textContent,
document.querySelector("[data-sidebar-item][aria-current]")?.textContent,
document.querySelector("main h1")?.textContent,
document.title,
]
.map((v) => (v ?? "").trim())
.filter(Boolean);
const title = (candidates[0] || "chat_export")
.replace(/\s*[-–—]\s*ChatGPT\s*$/i, "")
.trim();
return title || "chat_export";
}
function getModelName() {
const switcher = document.querySelector(
'[data-testid="model-switcher-dropdown-button"]'
);
if (!switcher) return null;
const spans = switcher.querySelectorAll("span");
for (const span of spans) {
const text = span.textContent?.trim();
if (text && text !== "ChatGPT" && text.length < 40) return text;
}
return null;
}
function getMessageNodes() {
const main = document.querySelector("main");
if (!main) return [];
const roleNodes = Array.from(
main.querySelectorAll("[data-message-author-role]")
).filter(
(node) => !node.parentElement?.closest("[data-message-author-role]")
);
if (roleNodes.length) return roleNodes;
return Array.from(main.querySelectorAll("div[data-message-id]"));
}
function getMessageRole(node, index) {
const role =
node.getAttribute?.("data-message-author-role") ||
node
.querySelector?.("[data-message-author-role]")
?.getAttribute("data-message-author-role");
if (role) return role;
return index % 2 === 0 ? "user" : "assistant";
}
function getMessageContentElement(node) {
const selectors = [
"[data-message-content]",
".markdown",
".prose",
".whitespace-pre-wrap",
"[data-testid='markdown']",
];
for (const selector of selectors) {
const el = node.querySelector?.(selector);
if (el && el.textContent?.trim()) return el;
}
return node;
}
// ── Markdown conversion ───────────────────────────────────────────────
function normalizeMarkdown(markdown) {
return String(markdown ?? "")
.replace(/\r\n/g, "\n")
.replace(/\u00a0/g, " ")
.replace(/[\u200b\u200c\u200d\ufeff]/g, "")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function extractLanguageFromCodeElement(codeEl) {
const dataLang = codeEl?.getAttribute?.("data-language");
if (dataLang) return dataLang.trim();
for (const className of Array.from(codeEl?.classList || [])) {
if (className.startsWith("language-")) return className.slice(9).trim();
if (className.startsWith("lang-")) return className.slice(5).trim();
}
const pre = codeEl?.closest?.("pre");
if (pre) {
const preLang = pre.getAttribute("data-language");
if (preLang) return preLang.trim();
for (const className of Array.from(pre.classList || [])) {
if (className.startsWith("language-")) return className.slice(9).trim();
if (className.startsWith("lang-")) return className.slice(5).trim();
}
}
const wrapper = pre?.parentElement;
if (wrapper) {
const langSpan = wrapper.querySelector(
"span.font-mono, span[data-language]"
);
if (langSpan) {
const text =
langSpan.getAttribute("data-language") ||
langSpan.textContent?.trim();
if (text && text.length < 30) return text;
}
}
return "";
}
function replaceKatex(root) {
const doc = root.ownerDocument;
root.querySelectorAll(".katex-display").forEach((el) => {
const ann = el.querySelector('annotation[encoding="application/x-tex"]');
const latex = ann?.textContent?.trim();
if (!latex) return;
el.replaceWith(doc.createTextNode(`\n\n$$\n${latex}\n$$\n\n`));
});
root.querySelectorAll(".katex").forEach((el) => {
if (el.closest(".katex-display")) return;
const ann = el.querySelector('annotation[encoding="application/x-tex"]');
const latex = ann?.textContent?.trim();
if (!latex) return;
el.replaceWith(doc.createTextNode(`$${latex}$`));
});
}
/** Clean ChatGPT citation artifacts: 【n†source】 → [n] */
function cleanCitations(root) {
const doc = root.ownerDocument;
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const textNodes = [];
let current;
while ((current = walker.nextNode())) textNodes.push(current);
for (const node of textNodes) {
const text = node.nodeValue;
if (text && /\u3010\d+\u2020[^\u3011]*\u3011/.test(text)) {
node.nodeValue = text.replace(
/\u3010(\d+)\u2020[^\u3011]*\u3011/g,
"[$1]"
);
}
}
}
/** Collect citation reference links for footnote output */
function collectCitationLinks(root) {
const links = [];
const seen = new Set();
root.querySelectorAll("a[href]").forEach((a) => {
const text = a.textContent?.trim();
const href = a.getAttribute("href") || "";
if (
/^\d+$/.test(text) &&
href &&
!href.startsWith("javascript:") &&
!href.startsWith("#") &&
!seen.has(text)
) {
seen.add(text);
links.push({ num: text, url: href });
}
});
return links;
}
function htmlToMarkdown(html) {
const doc = new DOMParser().parseFromString(
`<div id="cge-tmp">${html}</div>`,
"text/html"
);
const root = doc.getElementById("cge-tmp");
if (!root) return "";
// Collect citation links before modifying the DOM
const citationLinks = collectCitationLinks(root);
replaceKatex(root);
cleanCitations(root);
const blocks = new Set([
"div",
"section",
"article",
"header",
"footer",
"main",
]);
function childrenToMarkdown(nodes) {
let out = "";
nodes.forEach((n) => {
out += toMarkdown(n);
});
return out;
}
function listToMarkdown(node, depth) {
const tag = node.tagName.toLowerCase();
const ordered = tag === "ol";
const items = Array.from(node.children).filter(
(c) => c.tagName.toLowerCase() === "li"
);
const indent = " ".repeat(depth);
const lines = items.map((li, idx) => {
const prefix = ordered ? `${idx + 1}. ` : "- ";
const parts = [];
for (const child of li.childNodes) {
const childTag = child.tagName?.toLowerCase();
if (childTag === "ul" || childTag === "ol") {
parts.push("\n" + listToMarkdown(child, depth + 1));
} else {
parts.push(toMarkdown(child));
}
}
let item = normalizeMarkdown(parts.join(""));
const firstNewline = item.indexOf("\n");
if (firstNewline >= 0) {
const firstLine = item.slice(0, firstNewline);
const rest = item
.slice(firstNewline + 1)
.replace(/\n/g, `\n${indent} `);
item = firstLine + "\n" + indent + " " + rest;
}
return indent + prefix + item;
});
return (
(depth === 0 ? "\n\n" : "") +
lines.join("\n") +
(depth === 0 ? "\n\n" : "")
);
}
function toMarkdown(node) {
if (node.nodeType === Node.TEXT_NODE) return node.nodeValue || "";
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const tag = node.tagName.toLowerCase();
if (["script", "style", "noscript", "button", "svg"].includes(tag))
return "";
if (tag === "br") return "\n";
if (tag === "hr") return "\n\n---\n\n";
if (tag === "input") {
if (node.getAttribute("type") === "checkbox") {
return node.checked ? "[x] " : "[ ] ";
}
return "";
}
if (tag === "pre") {
const codeEl = node.querySelector("code") || node;
const lang = extractLanguageFromCodeElement(codeEl);
const code = (codeEl.textContent || "").replace(/\n$/, "");
return `\n\n\`\`\`${lang}\n${code}\n\`\`\`\n\n`;
}
if (tag === "code") {
if (node.closest("pre")) return node.textContent || "";
const text = node.textContent || "";
if (!text) return "";
const fence = text.includes("`") ? "``" : "`";
return `${fence}${text}${fence}`;
}
if (tag === "strong" || tag === "b") {
const text = childrenToMarkdown(Array.from(node.childNodes));
if (!text.trim()) return text;
return `**${text}**`;
}
if (tag === "em" || tag === "i") {
const text = childrenToMarkdown(Array.from(node.childNodes));
if (!text.trim()) return text;
return `*${text}*`;
}
if (tag === "del" || tag === "s" || tag === "strike") {
const text = childrenToMarkdown(Array.from(node.childNodes));
if (!text.trim()) return text;
return `~~${text}~~`;
}
if (tag === "mark") {
const text = childrenToMarkdown(Array.from(node.childNodes));
if (!text.trim()) return text;
return `==${text}==`;
}
if (tag === "sup") {
const text = childrenToMarkdown(Array.from(node.childNodes));
return `^${text}^`;
}
if (tag === "sub") {
const text = childrenToMarkdown(Array.from(node.childNodes));
return `~${text}~`;
}
if (tag === "a") {
const href = node.getAttribute("href") || "";
const text =
childrenToMarkdown(Array.from(node.childNodes)).trim() || href;
if (!href || href.startsWith("javascript:")) return text;
return `[${text}](${href})`;
}
if (tag === "img") {
const alt = node.getAttribute("alt") || "";
const src = node.getAttribute("src") || "";
if (!src || src.startsWith("blob:") || src.startsWith("data:"))
return alt
? `[${te("imageLabel")}: ${alt}]`
: `[${te("imageLabel")}]`;
return ``;
}
if (tag === "figure") {
const caption = node.querySelector("figcaption");
const captionText = caption
? normalizeMarkdown(
childrenToMarkdown(Array.from(caption.childNodes))
)
: "";
const body = Array.from(node.childNodes)
.filter((n) => n !== caption)
.map(toMarkdown)
.join("");
return `\n\n${body.trim()}${captionText ? `\n*${captionText}*` : ""}\n\n`;
}
if (tag === "figcaption") return "";
if (/^h[1-6]$/.test(tag)) {
const level = Number(tag.slice(1));
const text = childrenToMarkdown(Array.from(node.childNodes)).trim();
if (!text) return "";
return `\n\n${"#".repeat(level)} ${text}\n\n`;
}
if (tag === "blockquote") {
const content = normalizeMarkdown(
childrenToMarkdown(Array.from(node.childNodes))
);
const quoted = content
.split("\n")
.map((line) => `> ${line}`)
.join("\n");
return `\n\n${quoted}\n\n`;
}
if (tag === "ul" || tag === "ol") {
return listToMarkdown(node, 0);
}
// Details / thinking blocks
if (tag === "details") {
const summary = node.querySelector("summary");
const summaryText = summary
? childrenToMarkdown(Array.from(summary.childNodes)).trim()
: te("thinkingLabel");
const bodyNodes = Array.from(node.childNodes).filter(
(n) => n !== summary
);
const bodyText = normalizeMarkdown(childrenToMarkdown(bodyNodes));
return `\n\n<details>\n<summary>${summaryText}</summary>\n\n${bodyText}\n\n</details>\n\n`;
}
// Tables with alignment detection
if (tag === "table") {
const rows = Array.from(node.querySelectorAll("tr"));
if (!rows.length)
return childrenToMarkdown(Array.from(node.childNodes));
const cellText = (cell) =>
normalizeMarkdown(
childrenToMarkdown(Array.from(cell.childNodes))
).replace(/\n+/g, "<br>");
const headerCells = Array.from(rows[0].querySelectorAll("th,td"));
const headers = headerCells.map(cellText);
const aligns = headerCells.map((cell) => {
const align =
cell.getAttribute("align") || cell.style.textAlign || "";
if (align === "center") return ":---:";
if (align === "right") return "---:";
return "---";
});
const lines = [
`| ${headers.join(" | ")} |`,
`| ${aligns.join(" | ")} |`,
];
rows.slice(1).forEach((row) => {
const cells = Array.from(row.querySelectorAll("td,th")).map(cellText);
while (cells.length < headers.length) cells.push("");
lines.push(`| ${cells.join(" | ")} |`);
});
return `\n\n${lines.join("\n")}\n\n`;
}
// File attachment elements
if (
node.querySelector?.(
'[data-testid*="file"], [data-testid*="attachment"]'
) &&
!node.querySelector?.("[data-message-author-role]")
) {
const nameEl = node.querySelector(
'[data-testid*="file-name"], [data-testid*="filename"]'
);
const fileName =
nameEl?.textContent?.trim() || node.textContent?.trim() || "";
if (fileName && fileName.length < 200) {
return `[${te("fileLabel")}: ${fileName}]`;
}
}
const content = childrenToMarkdown(Array.from(node.childNodes));
if (tag === "p") return `\n\n${content.trim()}\n\n`;
if (blocks.has(tag)) return content;
return content;
}
let result = normalizeMarkdown(
childrenToMarkdown(Array.from(root.childNodes))
);
// Append citation footnotes if any were collected
if (citationLinks.length) {
const footnotes = citationLinks
.map((c) => `[${c.num}]: ${c.url}`)
.join("\n");
result += "\n\n" + footnotes;
}
return result;
}
// ── Export assembly ───────────────────────────────────────────────────
function extractConversation() {
const title = getConversationTitle();
const nodes = getMessageNodes();
const messages = nodes
.map((node, index) => {
const role = getMessageRole(node, index);
const contentEl = getMessageContentElement(node);
const html = contentEl?.innerHTML || "";
const markdown = htmlToMarkdown(html);
return { role, markdown };
})
.filter((m) => m.markdown.trim());
return { title, messages };
}
function roleLabel(role) {
if (role === "user") return te("roleUser");
if (role === "assistant") return te("roleAssistant");
if (role === "system") return te("roleSystem");
if (role === "tool") return te("roleTool");
return role || te("roleMessage");
}
function buildMarkdownExport(conversation) {
const now = new Date();
const dateStr = now.toISOString().replace("T", " ").slice(0, 19);
const meta = [
`> **Source:** ${window.location.href}`,
`> **Exported:** ${dateStr}`,
];
const model = getModelName();
if (model) meta.splice(1, 0, `> **Model:** ${model}`);
const parts = [`# ${conversation.title}`, "", ...meta, ""];
conversation.messages.forEach((m) => {
parts.push("---", "", `### ${roleLabel(m.role)}`, "", m.markdown, "");
});
return normalizeMarkdown(parts.join("\n"));
}
function exportMarkdown() {
const conversation = extractConversation();
if (!conversation.messages.length)
return { ok: false, message: te("noContent") };
const filename = `${sanitizeFilename(conversation.title)}.md`;
downloadText(
filename,
buildMarkdownExport(conversation),
"text/markdown;charset=utf-8"
);
return { ok: true };
}
// ── UI: Toast ─────────────────────────────────────────────────────────
let _toastTimer = null;
function syncToastTheme() {
const host = document.getElementById(CGE_TOAST_ID);
if (host) host.dataset.theme = isDarkTheme() ? "dark" : "light";
}
function ensureToastHost() {
let host = document.getElementById(CGE_TOAST_ID);
if (host) {
syncToastTheme();
return host.shadowRoot;
}
host = document.createElement("div");
host.id = CGE_TOAST_ID;
host.dataset.theme = isDarkTheme() ? "dark" : "light";
Object.assign(host.style, {
position: "fixed",
bottom: "24px",
left: "50%",
transform: "translateX(-50%)",
zIndex: "10001",
pointerEvents: "none",
});
const shadow = host.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
:host { all: initial; }
.cge-toast {
padding: 10px 18px;
border-radius: 12px;
font-family: "S\u00F6hne", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
font-weight: 500;
line-height: 1.4;
pointer-events: auto;
opacity: 0;
transform: translateY(8px);
transition: opacity 0.2s ease, transform 0.2s ease;
white-space: nowrap;
/* Light theme default */
background: #f4f4f4;
color: #0d0d0d;
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
:host([data-theme="dark"]) .cge-toast {
background: #2f2f2f;
color: #ececec;
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.cge-toast.cge-show {
opacity: 1;
transform: translateY(0);
}
.cge-toast[data-kind="error"] {
border-color: rgba(220, 38, 38, 0.3);
color: #dc2626;
}
:host([data-theme="dark"]) .cge-toast[data-kind="error"] {
border-color: rgba(239, 68, 68, 0.4);
color: #ef4444;
}
</style>
<div class="cge-toast"></div>
`;
document.body.appendChild(host);
return shadow;
}
function showToast(text, isError = false) {
const shadow = ensureToastHost();
const toast = shadow.querySelector(".cge-toast");
if (!toast) return;
toast.textContent = text;
toast.dataset.kind = isError ? "error" : "info";
toast.classList.add("cge-show");
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => {
toast.classList.remove("cge-show");
}, 2400);
}
// ── UI: Header Button ─────────────────────────────────────────────────
// Download arrow path data (filled, 24×24 viewBox)
const EXPORT_PATH =
"M13 3a1 1 0 0 0-2 0v9.586l-2.293-2.293a1 1 0 0 0-1.414 1.414l4 4a1 1 0 0 0 1.414 0l4-4a1 1 0 0 0-1.414-1.414L13 12.586V3ZM5 16a1 1 0 0 0-2 0v1a4 4 0 0 0 4 4h10a4 4 0 0 0 4-4v-1a1 1 0 1 0-2 0v1a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-1Z";
/**
* Find the native Share button inside #conversation-header-actions
* so we can clone its exact structure for pixel-perfect matching.
*/
function findShareBtn(actions) {
if (!actions) return null;
// data-testid first
const byTestId = actions.querySelector(
'button[data-testid*="share"], button[data-testid*="Share"]'
);
if (byTestId) return byTestId;
// aria-label
const byLabel = actions.querySelector(
'button[aria-label*="Share"], button[aria-label*="分享"], button[aria-label*="共有"]'
);
if (byLabel) return byLabel;
// Visible text
for (const b of actions.querySelectorAll("button")) {
const t = b.textContent?.trim();
if ((t && /^share$/i.test(t)) || t === "分享" || t === "共有") return b;
}
return null;
}
/**
* Create a pill-shaped Export button that mirrors the native Share button.
* Deep-clones the Share button (children included) so icon size, class,
* internal wrappers, and gap are all inherited; then swaps just the SVG
* path data and the visible text label.
* Falls back to a manually-constructed equivalent when Share is absent.
*/
function createHeaderBtn() {
const actions = document.getElementById("conversation-header-actions");
const shareBtn = findShareBtn(actions);
let btn;
if (shareBtn) {
// Deep-clone: preserves every child, class, and inline style
btn = shareBtn.cloneNode(true);
btn.removeAttribute("id");
btn.removeAttribute("data-testid");
btn.removeAttribute("data-state");
btn.removeAttribute("aria-controls");
btn.removeAttribute("aria-expanded");
btn.removeAttribute("aria-describedby");
btn.removeAttribute("aria-haspopup");
// Swap SVG icon: keep the <svg> element (same size/class), replace paths
const svg = btn.querySelector("svg");
if (svg) {
svg.innerHTML = `<path d="${EXPORT_PATH}"/>`;
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "currentColor");
svg.removeAttribute("stroke");
svg.removeAttribute("stroke-width");
svg.removeAttribute("stroke-linecap");
svg.removeAttribute("stroke-linejoin");
}
// Swap text label: "Share"/"分享"/"共有" → our export label
const walker = document.createTreeWalker(btn, NodeFilter.SHOW_TEXT);
let tNode;
while ((tNode = walker.nextNode())) {
if (/share|分享|共有/i.test(tNode.nodeValue?.trim() || "")) {
tNode.nodeValue = tNode.nodeValue.replace(
/share|分享|共有/i,
te("exportLabel")
);
}
}
} else {
// Fallback: build manually with ChatGPT's pill-button pattern
btn = document.createElement("button");
btn.className = [
"flex",
"items-center",
"gap-1.5",
"rounded-lg",
"border",
"border-token-border-light",
"px-3",
"h-9",
"text-token-text-primary",
"bg-token-main-surface-primary",
"hover:bg-token-main-surface-secondary",
"text-sm",
"font-semibold",
"whitespace-nowrap",
"focus:outline-none",
"transition-colors",
].join(" ");
btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" class="icon-sm"><path d="${EXPORT_PATH}"/></svg><span>${te("exportLabel")}</span>`;
}
btn.setAttribute(CGE_BTN_ATTR, "true");
btn.type = "button";
btn.title = te("exportBtn");
btn.setAttribute("aria-label", te("exportBtn"));
// Entrance fade-in animation
btn.style.opacity = "0";
btn.style.transition = "opacity 200ms ease, background-color 150ms ease";
requestAnimationFrame(() => {
requestAnimationFrame(() => {
btn.style.opacity = "1";
});
});
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
try {
const result = exportMarkdown();
if (result?.ok) {
showToast(te("exportOk"));
} else {
showToast(result?.message || te("exportFailed"), true);
}
} catch (err) {
showToast(err?.message || te("exportFailed"), true);
}
});
return btn;
}
/**
* Inject export button into #conversation-header-actions.
* Returns true if injected or already present.
*/
function injectHeaderBtn() {
if (document.querySelector(`[${CGE_BTN_ATTR}]`)) return true;
const actions = document.getElementById("conversation-header-actions");
if (!actions) return false;
const btn = createHeaderBtn();
// Place next to the Share button for visual symmetry
const shareBtn = findShareBtn(actions);
if (shareBtn) {
// Walk up to the direct child of actions that contains Share
let anchor = shareBtn;
while (anchor.parentElement && anchor.parentElement !== actions) {
anchor = anchor.parentElement;
}
actions.insertBefore(btn, anchor);
} else {
actions.insertBefore(btn, actions.firstChild);
}
// Re-inject if React re-renders the header and removes our button
new MutationObserver((_, obs) => {
if (!document.querySelector(`[${CGE_BTN_ATTR}]`)) {
obs.disconnect();
scheduleInject();
}
}).observe(actions, { childList: true, subtree: true });
return true;
}
/** Remove stale export button (e.g., after navigating to new chat page). */
function removeHeaderBtn() {
const btn = document.querySelector(`[${CGE_BTN_ATTR}]`);
if (btn) btn.remove();
}
// ── Lifecycle ─────────────────────────────────────────────────────────
function isOnConversationPage() {
const path = window.location.pathname;
// Known conversation URL patterns
if (path.startsWith("/c/") || path.startsWith("/g/")) return true;
// Known non-conversation pages
if (
path === "/" ||
path === "/chat" ||
path === "/chat/" ||
path.startsWith("/gpts") ||
path.startsWith("/explore") ||
path.startsWith("/settings") ||
path.startsWith("/auth")
)
return false;
// Fallback: check if conversation content exists in DOM
const main = document.querySelector("main");
return !!main?.querySelector("[data-message-author-role]");
}
let _scheduleTimer = null;
function scheduleInject() {
if (_scheduleTimer) return;
_scheduleTimer = setTimeout(() => {
_scheduleTimer = null;
syncUi();
}, 200);
}
function syncUi() {
_locale = detectLocale();
if (isOnConversationPage()) {
injectHeaderBtn();
} else {
removeHeaderBtn();
}
const btn = document.querySelector(`[${CGE_BTN_ATTR}]`);
if (btn) {
btn.title = te("exportBtn");
btn.setAttribute("aria-label", te("exportBtn"));
}
}
// ── Navigation detection ──────────────────────────────────────────────
function setupObservers() {
// Watch #__next for major SPA route changes (direct children only)
const nextRoot = document.getElementById("__next");
if (nextRoot) {
new MutationObserver(scheduleInject).observe(nextRoot, {
childList: true,
});
}
// Watch #page-header for React re-renders that affect the header area
const header = document.getElementById("page-header");
if (header) {
new MutationObserver(() => {
if (
!document.querySelector(`[${CGE_BTN_ATTR}]`) &&
isOnConversationPage()
) {
scheduleInject();
}
}).observe(header, { childList: true, subtree: true });
}
// Hook history API for SPA navigation (guarded to prevent double-wrapping)
if (!window[CGE_NAV_FLAG]) {
window[CGE_NAV_FLAG] = true;
const origPush = history.pushState;
const origReplace = history.replaceState;
history.pushState = function (...args) {
const result = origPush.apply(this, args);
scheduleInject();
return result;
};
history.replaceState = function (...args) {
const result = origReplace.apply(this, args);
scheduleInject();
return result;
};
window.addEventListener("popstate", scheduleInject);
}
// Resilience: re-check when tab becomes visible or page is restored from bfcache
document.addEventListener("visibilitychange", () => {
if (!document.hidden) scheduleInject();
});
window.addEventListener("pageshow", () => scheduleInject());
}
function setupThemeAndLocaleSync() {
new MutationObserver(() => {
// Locale sync
const newLocale = detectLocale();
if (newLocale !== _locale) {
_locale = newLocale;
const btn = document.querySelector(`[${CGE_BTN_ATTR}]`);
if (btn) {
btn.title = te("exportBtn");
btn.setAttribute("aria-label", te("exportBtn"));
}
}
// Toast theme sync
syncToastTheme();
}).observe(document.documentElement, {
attributes: true,
attributeFilter: ["lang", "class", "style", "data-chat-theme"],
});
}
// ── Bootstrap ─────────────────────────────────────────────────────────
function init() {
syncUi();
setupObservers();
setupThemeAndLocaleSync();
}
init();
})();