ChatGPT 会話エクスポート(Markdown)

現在の ChatGPT 会話を Markdown ドキュメントとしてエクスポートします。

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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 `![${alt || te("imageLabel")}](${src})`;
      }

      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();
})();