ChatGPT Multi Format Exporter

Export current ChatGPT chat as JSON / Markdown / HTML via backend fetch. Button sits beside Share in the header.

// ==UserScript==
// @name         ChatGPT Multi Format Exporter
// @namespace    https://giths.com/random/chatgpt-exporter
// @version      2.0
// @description  Export current ChatGPT chat as JSON / Markdown / HTML via backend fetch. Button sits beside Share in the header.
// @author       Mr005K
// @license      MIT
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @grant        GM_addStyle
// @grant        GM_download
// @grant        GM_info
// @connect      chatgpt.com
// @connect      chat.openai.com
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  // ---------- mini utils ----------
  const $ = (sel, root = document) => root.querySelector(sel);
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  const log = (...a) => console.log('[CGPT Export]', ...a);

  function canGMDownloadBlob() {
    try {
      if (typeof GM_download !== 'function') return false;
      const v = (GM_info && GM_info.scriptHandler === 'Tampermonkey' && GM_info.version) || '';
      const num = parseFloat((v.match(/^\d+\.\d+/) || ['0'])[0]);
      return num >= 5.4; // TM 5.4+ accepts Blob/File directly
    } catch { return false; }
  }

  function downloadBlobSmart(filename, blob) {
    if (canGMDownloadBlob()) {
      GM_download({ url: blob, name: filename });
      return;
    }
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1500);
  }

  function esc(s) { return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
  function safeTitle(s) { return (s || 'chat').replace(/[<>:"/\\|?*\x00-\x1F]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80) || 'chat'; }

  function getConversationIdFromUrl() {
    const m = location.pathname.match(/\/c\/([0-9a-f-]{36})/i)
          || location.pathname.match(/\/conversation\/([0-9a-f-]{36})/i);
    return m ? m[1] : null;
  }

  async function getAccessToken() {
    const candidates = ['/api/auth/session', '/auth/session'];
    for (const ep of candidates) {
      try {
        const res = await fetch(ep, { credentials: 'include' });
        if (res.ok) {
          const data = await res.json();
          if (data && data.accessToken) return data.accessToken;
        }
      } catch {}
    }
    return null; // cookie-auth may still work
  }

  async function fetchConversation(convId, accessToken) {
    const endpoints = [
      `/backend-api/conversation/${convId}`,
      `/backend-api/conversations/${convId}`
    ];
    let lastErr;
    for (const ep of endpoints) {
      try {
        const res = await fetch(ep, {
          method: 'GET',
          headers: Object.assign(
            { 'Content-Type': 'application/json' },
            accessToken ? { 'Authorization': `Bearer ${accessToken}` } : {}
          ),
          credentials: 'include'
        });
        if (res.ok) {
          const json = await res.json();
          if (json && (json.mapping || json.messages)) return json;
          lastErr = new Error('Malformed conversation payload');
        } else {
          lastErr = new Error(`HTTP ${res.status} at ${ep}`);
        }
      } catch (e) { lastErr = e; }
    }
    throw lastErr || new Error('Failed to fetch conversation.');
  }

  function messageToText(msg) {
    try {
      const c = msg?.content || {};
      if (Array.isArray(c.parts)) {
        return c.parts.map(p => (typeof p === 'string' ? p : p?.text || '')).filter(Boolean).join('\n');
      }
      if (typeof c?.text === 'string') return c.text;
      return JSON.stringify(c);
    } catch { return ''; }
  }

  function linearizeMessages(data) {
    const out = [];
    if (data?.mapping && data?.current_node) {
      const map = data.mapping;
      const chain = [];
      const seen = new Set();
      let node = data.current_node;
      while (node && map[node] && !seen.has(node)) {
        seen.add(node);
        chain.push(node);
        node = map[node].parent;
      }
      chain.reverse();
      for (const id of chain) {
        const nodeObj = map[id];
        if (!nodeObj?.message) continue;
        const msg = nodeObj.message;
        const role = msg.author?.role || 'unknown';
        if (role === 'tool' || role === 'function') continue;
        out.push({
          id,
          role,
          author: msg.author?.name || role,
          text: messageToText(msg),
          create_time: msg.create_time ? new Date(msg.create_time * 1000).toISOString() : ''
        });
      }
      return out;
    }
    if (Array.isArray(data?.messages)) {
      const msgs = data.messages.slice().sort((a, b) => (a.create_time || 0) - (b.create_time || 0));
      for (const msg of msgs) {
        const role = msg.author?.role || 'unknown';
        if (role === 'tool' || role === 'function') continue;
        out.push({
          id: msg.id,
          role,
          author: msg.author?.name || role,
          text: messageToText(msg),
          create_time: msg.create_time ? new Date(msg.create_time * 1000).toISOString() : ''
        });
      }
    }
    return out;
  }

  function toMarkdown(meta, messages) {
    let md = `# ${meta.title || 'Chat'}\n\n`;
    md += `- **ID:** ${meta.id}\n`;
    if (meta.create_time) md += `- **Created:** ${meta.create_time}\n`;
    if (meta.update_time) md += `- **Updated:** ${meta.update_time}\n`;
    if (meta.model) md += `- **Model:** ${meta.model}\n`;
    md += `\n---\n\n`;
    for (const m of messages) {
      const who = m.role === 'assistant' ? 'Assistant' : m.role === 'user' ? 'User' : (m.author || m.role);
      md += `## ${who}${m.create_time ? ` • ${m.create_time}` : ''}\n\n`;
      md += `${m.text || ''}\n\n`;
    }
    return md;
  }

  function renderMarkdownToHtml(md) {
    let s = String(md ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
    s = s.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) =>
      `<pre><code class="language-${lang || ''}">${code}</code></pre>`);
    s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
    s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
    s = s.replace(/\*([^*]+)\*/g, '<em>$1</em>');
    s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
    s = s.replace(/\n/g, '<br>');
    return s;
  }

  function toHTML(meta, messages) {
    return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>${esc(meta.title || 'Chat')}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
  body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,"Helvetica Neue",Arial,sans-serif;max-width:920px;margin:0 auto;padding:24px;line-height:1.55;background:#fff;color:#111}
  h1{margin:0 0 8px}
  .meta{font-size:12px;color:#666;margin:4px 0 20px}
  .msg{border:1px solid #e5e7eb;border-radius:12px;padding:16px;margin:14px 0}
  .msg.user{background:#f9fafb}
  .msg.assistant{background:#f5faff}
  .who{font-weight:600;margin-bottom:8px}
  pre{overflow:auto;padding:12px;background:#0b1021;color:#e6eaf2;border-radius:8px}
  code{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}
  a{color:#2563eb}
  hr{border:none;border-top:1px solid #e5e7eb;margin:28px 0}
</style>
</head>
<body>
<h1>${esc(meta.title || 'Chat')}</h1>
<div class="meta">
  ID: ${esc(meta.id)}
  ${meta.create_time ? ` · Created: ${esc(meta.create_time)}` : ''}
  ${meta.update_time ? ` · Updated: ${esc(meta.update_time)}` : ''}
  ${meta.model ? ` · Model: ${esc(meta.model)}` : ''}
</div>
<hr/>
${messages.map(m => `
  <div class="msg ${esc(m.role)}">
    <div class="who">${esc(m.role === 'assistant' ? 'Assistant' : m.role === 'user' ? 'User' : (m.author || m.role))}${m.create_time ? ` • ${esc(m.create_time)}` : ''}</div>
    <div class="text">${renderMarkdownToHtml(m.text || '')}</div>
  </div>
`).join('\n')}
</body>
</html>`;
  }

  // ---------- styles (popover uses token vars w/ fallbacks + fixed positioning) ----------
  GM_addStyle(`
    #cgpt-export-button.btn { /* inherit ChatGPT button look via classes; no hard colors here */ }

    .cgpt-export-menu {
      position: fixed; /* anchored to viewport based on button rect */
      min-width: 200px;
      background: var(--token-surface, var(--surface-primary, #ffffff));
      color: var(--token-text-primary, #111111);
      border: 1px solid var(--token-border-light, #e5e7eb);
      border-radius: 10px;
      padding: 6px;
      box-shadow: var(--shadow-floating, 0 12px 40px rgba(0,0,0,.25));
      z-index: 100000;
      display: none;
    }
    .cgpt-export-menu.open { display: block; }

    .cgpt-export-item {
      display: block;
      width: 100%;
      text-align: left;
      background: transparent;
      color: inherit;
      border: none;
      padding: 10px 12px;
      border-radius: 8px;
      cursor: pointer;
      font: inherit;
    }
    .cgpt-export-item:hover {
      background: var(--token-surface-hover, #f3f4f6);
    }
  `);

  // ---------- UI mount beside Share ----------
  function findHeaderActions() {
    // primary target per your snippet
    return document.getElementById('conversation-header-actions')
        || document.querySelector('[data-testid="conversation-header-actions"]')
        || document.querySelector('#__next header div[id*="actions"]')
        || null;
  }

  function ensureMenuSingleton() {
    let menu = document.getElementById('cgpt-export-menu');
    if (!menu) {
      menu = document.createElement('div');
      menu.id = 'cgpt-export-menu';
      menu.className = 'cgpt-export-menu';
      menu.setAttribute('role', 'menu');
      menu.innerHTML = `
        <button class="cgpt-export-item" data-format="json" role="menuitem">Export as JSON</button>
        <button class="cgpt-export-item" data-format="md" role="menuitem">Export as Markdown</button>
        <button class="cgpt-export-item" data-format="html" role="menuitem">Export as HTML</button>
      `;
      document.body.appendChild(menu);
    }
    return menu;
  }

  function positionMenuToButton(menu, btn) {
    const r = btn.getBoundingClientRect();
    const margin = 6;
    const top = Math.round(r.bottom + margin);
    let left = Math.round(r.left);
    // keep on-screen
    const width = menu.offsetWidth || 220;
    const maxLeft = window.innerWidth - width - 8;
    if (left > maxLeft) left = maxLeft;
    menu.style.top = `${top}px`;
    menu.style.left = `${left}px`;
  }

  function ensureHeaderButton() {
    const host = findHeaderActions();
    if (!host) return false;
    if ($('#cgpt-export-button', host)) return true;

    // Build button that visually matches Share (class names copied)
    const btn = document.createElement('button');
    btn.id = 'cgpt-export-button';
    btn.className = 'btn relative btn-ghost text-token-text-primary mx-2';
    btn.setAttribute('aria-label', 'Export');
    btn.setAttribute('data-testid', 'export-chat-button');
    btn.innerHTML = `
      <div class="flex w-full items-center justify-center gap-1.5">
        <svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-label="" class="-ms-0.5 icon">
          <path d="M10.0002 2.5c.368 0 .666.298.666.666v7.395l2.2-2.199a.666.666 0 1 1 .942.942l-3.333 3.333a.666.666 0 0 1-.942 0L6.2 9.304a.666.666 0 0 1 .943-.942l2.2 2.199V3.166c0-.368.298-.666.666-.666Z"/>
          <path d="M4.167 12.5c-.92 0-1.667.746-1.667 1.666v1.667c0 .92.746 1.667 1.667 1.667h11.666c.92 0 1.667-.746 1.667-1.667v-1.667c0-.92-.746-1.666-1.667-1.666h-.167a.667.667 0 0 0 0 1.333h.167c.184 0 .333.149.333.333v1.667c0 .184-.149.333-.333.333H4.167a.333.333 0 0 1-.334-.333v-1.667c0-.184.15-.333.334-.333h.166a.667.667 0 0 0 0-1.333H4.167Z"/>
        </svg>
        Export
      </div>
    `;

    // Insert just before the 3-dot options button if present, else append
    const optionsBtn = host.querySelector('[data-testid="conversation-options-button"]') || host.lastElementChild;
    if (optionsBtn?.parentElement === host) {
      host.insertBefore(btn, optionsBtn);
    } else {
      host.appendChild(btn);
    }

    // Menu (fixed to body)
    const menu = ensureMenuSingleton();

    // Open/close & actions
    btn.addEventListener('click', async (e) => {
      e.stopPropagation();
      if (!menu.classList.contains('open')) {
        menu.classList.add('open');
        // ensure it has dimensions before positioning
        menu.style.visibility = 'hidden';
        menu.style.display = 'block';
        await sleep(0);
        positionMenuToButton(menu, btn);
        menu.style.visibility = '';
      } else {
        menu.classList.remove('open');
        menu.style.display = 'none';
      }
    });

    document.addEventListener('click', (ev) => {
      if (!menu.classList.contains('open')) return;
      const t = ev.target;
      if (t === btn || btn.contains(t)) return;
      if (t === menu || menu.contains(t)) return;
      menu.classList.remove('open');
      menu.style.display = 'none';
    });

    window.addEventListener('resize', () => {
      if (menu.classList.contains('open')) positionMenuToButton(menu, btn);
    });
    window.addEventListener('scroll', () => {
      if (menu.classList.contains('open')) positionMenuToButton(menu, btn);
    });

    menu.addEventListener('click', async (e) => {
      const item = e.target.closest('.cgpt-export-item');
      if (!item) return;
      menu.classList.remove('open');
      menu.style.display = 'none';
      try {
        const convId = getConversationIdFromUrl();
        if (!convId) return alert('No conversation ID in URL. Open a chat first.');
        const token = await getAccessToken(); // may be null
        const data = await fetchConversation(convId, token);
        const messages = linearizeMessages(data);
        const meta = {
          id: data.id || convId,
          title: data.title || document.title.replace(/ - ChatGPT$/, ''),
          create_time: data.create_time ? new Date(data.create_time * 1000).toISOString() : '',
          update_time: data.update_time ? new Date(data.update_time * 1000).toISOString() : '',
          model: data.current_model || data.model_slug || data.model || ''
        };
        const base = safeTitle(meta.title);
        const ts = new Date().toISOString().replace(/[:.]/g, '-');

        switch (item.dataset.format) {
          case 'json': {
            const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
            downloadBlobSmart(`${base}.${ts}.json`, blob);
            break;
          }
          case 'md': {
            const md = toMarkdown(meta, messages);
            const blob = new Blob([md], { type: 'text/markdown' });
            downloadBlobSmart(`${base}.${ts}.md`, blob);
            break;
          }
          case 'html': {
            const html = toHTML(meta, messages);
            const blob = new Blob([html], { type: 'text/html' });
            downloadBlobSmart(`${base}.${ts}.html`, blob);
            break;
          }
        }
      } catch (err) {
        console.error(err);
        alert('Export failed: ' + (err?.message || err));
      }
    });

    return true;
  }

  async function boot() {
    // Give chatgpt.js a beat to attach (you asked to integrate it; we don't rely on it heavily)
    for (let i = 0; i < 30; i++) { if (window.chatgpt) break; await sleep(100); }

    ensureHeaderButton();

    // React re-renders: keep our button alive
    const obs = new MutationObserver(() => ensureHeaderButton());
    obs.observe(document.documentElement, { childList: true, subtree: true });

    // Route changes without reload
    let lastPath = location.pathname;
    setInterval(() => {
      if (location.pathname !== lastPath) {
        lastPath = location.pathname;
        ensureHeaderButton();
      }
    }, 600);
  }

  boot();
})();