Claude Chat Exporter

Export any Claude.ai conversation as Markdown, Plain Text, or JSON with one click — adds a clean Export button to the chat toolbar.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name         Claude Chat Exporter
// @namespace    foxbinner
// @version      1.3.0
// @description  Export any Claude.ai conversation as Markdown, Plain Text, or JSON with one click — adds a clean Export button to the chat toolbar.
// @license      MIT
// @match        *://claude.ai/*
// @icon         https://claude.ai/favicon.ico
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  // ── Styles ─────────────────────────────────────────────────────────────────

  function injectStyles() {
    if (document.getElementById('_exp_styles')) return;
    const s = document.createElement('style');
    s.id = '_exp_styles';
    s.textContent = `
      #_exp_wrap { position: relative; display: inline-flex; }

      #_exp_menu {
        display: none;
        position: absolute;
        top: calc(100% + 6px);
        right: 0;
        z-index: 9999;
        min-width: 148px;
        padding: 4px;
        border-radius: 8px;
        overflow: hidden;
        background: #2b2b2b;
        border: 1px solid rgba(255,255,255,0.09);
        box-shadow: 0 4px 12px rgba(0,0,0,0.38);
      }
      #_exp_menu.open { display: block; }

      .exp-item {
        display: flex;
        align-items: center;
        gap: 8px;
        width: 100%;
        padding: 6px 10px;
        border-radius: 5px;
        border: none;
        background: transparent;
        cursor: pointer;
        text-align: left;
        font-size: 12px;
        font-weight: 600;
        font-family: inherit;
        color: rgba(255,255,255,0.82);
        transition: background 100ms ease, color 100ms ease;
      }
      .exp-item svg { flex-shrink: 0; opacity: 0.65; transition: opacity 100ms ease; }
      .exp-item:hover { background: rgba(255,255,255,0.10); color: #fff; }
      .exp-item:hover svg { opacity: 0.90; }
      .exp-item:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
    `;
    document.head.appendChild(s);
  }

  // ── Icons ──────────────────────────────────────────────────────────────────

  const ICON = {
    download: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`,
    markdown: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>`,
    plain:    `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h12"/></svg>`,
    json:     `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/></svg>`,
  };

  // ── API Data Fetching ───────────────────────────────────────────────────────

  function getConversationId() {
    const m = location.pathname.match(/\/chat\/([0-9a-f-]{16,})/i);
    return m?.[1] ?? null;
  }

  function getOrgId() {
    // Claude stores the active org in a cookie: lastActiveOrg=<uuid>
    return document.cookie.match(/lastActiveOrg=([^;]+)/)?.[1] ?? null;
  }

  async function fetchMessages() {
    const convId = getConversationId();
    const orgId  = getOrgId();

    if (!convId || !orgId) {
      throw new Error(
        `Could not resolve IDs — convId: ${convId}, orgId: ${orgId}. ` +
        `Make sure you are on a /chat/<id> page and logged in.`
      );
    }

    const url = `/api/organizations/${orgId}/chat_conversations/${convId}` +
      `?tree=true&rendering_mode=messages&render_all_tools=true`;

    const res = await fetch(url, {
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
    });

    if (!res.ok) throw new Error(`API returned ${res.status} ${res.statusText}`);

    const data = await res.json();
    return { data, thread: buildActiveThread(data) };
  }

  // Walk the message tree from the current leaf → root, then reverse.
  // This gives only the active branch, ignoring edited/branched siblings.
  function buildActiveThread(data) {
    const messages = data?.chat_messages ?? [];
    if (!messages.length) return [];

    const byUuid = new Map(messages.map(m => [m.uuid, m]));
    const leafId = data.current_leaf_message_uuid ?? findLeaf(messages);

    const chain = [];
    const seen  = new Set();
    let cur = byUuid.get(leafId) ?? messages[messages.length - 1];

    while (cur && !seen.has(cur.uuid)) {
      seen.add(cur.uuid);
      chain.push(cur);
      cur = cur.parent_message_uuid ? byUuid.get(cur.parent_message_uuid) : null;
    }

    return chain.reverse();
  }

  function findLeaf(messages) {
    const hasChildren = new Set(messages.map(m => m.parent_message_uuid).filter(Boolean));
    const leaves = messages.filter(m => !hasChildren.has(m.uuid));
    leaves.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
    return leaves[0]?.uuid ?? messages[messages.length - 1]?.uuid;
  }

  // ── Title Resolution ────────────────────────────────────────────────────────

  function resolveTitle(data) {
    const api = data?.name?.trim();
    if (api && !/^new conversation$/i.test(api)) return api;

    const dom = document.querySelector(
      '[data-testid="chat-title-button"] .truncate, [data-testid="chat-title-button"]'
    )?.textContent?.trim();
    if (dom && !/^(claude|new conversation)$/i.test(dom)) return dom;

    return document.title.replace(/\s*[–-]\s*Claude.*$/i, '').trim() || 'claude-chat';
  }

  // ── Content Rendering ───────────────────────────────────────────────────────

  // Internal Claude tool names — system plumbing, not conversation content.
  // Exporting their inputs/results pollutes files with unrelated data
  // (memory search dumps from other chats, file reads, bash output, etc.)
  const INTERNAL_TOOLS = new Set([
    'conversation_search', 'recent_chats',
    'web_search', 'web_fetch',
    'bash_tool', 'view', 'str_replace',
    'create_file', 'present_files',
    'image_search', 'weather_fetch',
    'places_search', 'places_map_display_v0',
    'fetch_sports_data', 'ask_user_input_v0',
    'search_mcp_registry', 'suggest_connectors',
    'visualize:read_me', 'visualize:show_widget',
  ]);

  // Flatten a message's structured content into plain text.
  // Only text blocks and user-uploaded attachments are kept.
  // Internal tool calls and ALL tool_results are stripped.
  function messageToText(msg) {
    const parts = Array.isArray(msg.content) ? msg.content : [];
    const out   = [];

    for (const p of parts) {
      if (p?.type === 'text' && p.text) {
        out.push(p.text.trim());

      } else if (p?.type === 'thinking') {
        const t = (p.thinking ?? p.text ?? '').trim();
        if (t) out.push(`[thinking]\n${t}`);

      } else if (p?.type === 'tool_use') {
        // Skip internal system tools entirely
        if (INTERNAL_TOOLS.has(p.name)) continue;
        // Non-internal tool_use (rare in normal chat) — mention briefly
        out.push(`[tool: ${p.name ?? 'unknown'}]`);

      } else if (p?.type === 'tool_result') {
        // Always skip — contains raw system data irrelevant to the conversation
        continue;

      } else if (p?.type === 'image') {
        out.push('[image]');
      }
    }

    // User-uploaded attachments are part of the conversation, keep them
    for (const att of [...(msg.attachments ?? []), ...(msg.files_v2 ?? [])]) {
      const name    = att.file_name ?? att.name ?? 'attachment';
      const content = att.extracted_content ?? att.content ?? '';
      out.push(content ? `[attachment: ${name}]\n${content}` : `[attachment: ${name}]`);
    }

    return out.join('\n\n').replace(/\n{3,}/g, '\n\n').trim();
  }

  // ── Export Formatters ───────────────────────────────────────────────────────

  // Single source of truth for all metadata — every format pulls from this.
  function buildMeta(thread, title, url, data) {
    const timestamps = thread.map(m => m.created_at).filter(Boolean);
    return {
      title,
      url,
      exportedAt:      new Date().toISOString(),
      conversation_id: data?.uuid  ?? null,
      model:           data?.model ?? null,
      message_count:   thread.length,
      started_at:      timestamps[0]                   ?? null,
      ended_at:        timestamps[timestamps.length - 1] ?? null,
    };
  }

  function fmtTs(iso) {
    if (!iso) return null;
    try {
      return new Date(iso).toLocaleString(undefined, {
        year: 'numeric', month: 'short', day: 'numeric',
        hour: 'numeric', minute: '2-digit',
      });
    } catch { return iso; }
  }

  function toMarkdown(thread, title, url, data) {
    const m = buildMeta(thread, title, url, data);
    const header = [
      `# ${m.title}`,
      '',
      `| Field | Value |`,
      `|---|---|`,
      `| Conversation ID | \`${m.conversation_id ?? '—'}\` |`,
      `| Model           | ${m.model ?? '—'} |`,
      `| Messages        | ${m.message_count} |`,
      `| Started         | ${fmtTs(m.started_at) ?? '—'} |`,
      `| Ended           | ${fmtTs(m.ended_at)   ?? '—'} |`,
      `| Exported        | ${fmtTs(m.exportedAt)} |`,
      `| Source          | ${m.url} |`,
      '',
      '---',
      '',
    ].join('\n');

    const body = thread.map(msg => {
      const label = msg.sender === 'human' ? '🧑 You' : '🤖 Claude';
      const ts    = fmtTs(msg.created_at);
      const heading = ts ? `## ${label} — ${ts}` : `## ${label}`;
      return `${heading}\n\n${messageToText(msg) || '*[empty]*'}\n`;
    }).join('\n---\n\n');

    return header + body;
  }

  function toPlain(thread, title, url, data) {
    const m = buildMeta(thread, title, url, data);
    const sep = '='.repeat(Math.min(title.length, 60));
    const header = [
      m.title,
      sep,
      `Conversation ID : ${m.conversation_id ?? '—'}`,
      `Model           : ${m.model           ?? '—'}`,
      `Messages        : ${m.message_count}`,
      `Started         : ${fmtTs(m.started_at) ?? '—'}`,
      `Ended           : ${fmtTs(m.ended_at)   ?? '—'}`,
      `Exported        : ${fmtTs(m.exportedAt)}`,
      `Source          : ${m.url}`,
      sep,
      '',
    ].join('\n');

    const body = thread.map(msg => {
      const label = msg.sender === 'human' ? 'You' : 'Claude';
      const ts    = fmtTs(msg.created_at);
      const heading = ts ? `[${label} — ${ts}]` : `[${label}]`;
      return `${heading}\n${messageToText(msg) || '(empty)'}`;
    }).join('\n\n');

    return header + body + '\n';
  }

  function toJSON(thread, title, url, data) {
    const m = buildMeta(thread, title, url, data);
    return JSON.stringify({
      ...m,
      messages: thread.map(msg => ({
        uuid:       msg.uuid,
        sender:     msg.sender,
        created_at: msg.created_at,
        text:       messageToText(msg),
        content:    msg.content,   // raw structured content for power users
      })),
    }, null, 2);
  }

  // ── Download ────────────────────────────────────────────────────────────────

  function triggerDownload(filename, content, mime) {
    const url = URL.createObjectURL(new Blob([content], { type: mime }));
    Object.assign(document.createElement('a'), { href: url, download: filename }).click();
    setTimeout(() => URL.revokeObjectURL(url), 3000);
  }

  // ── Export Entry Point ──────────────────────────────────────────────────────

  async function runExport(format) {
    const items = document.querySelectorAll('.exp-item');
    items.forEach(b => (b.disabled = true));

    try {
      const { data, thread } = await fetchMessages();

      if (!thread.length) throw new Error('The API returned 0 messages.');

      const title = resolveTitle(data);
      const url   = location.href;

      if (format === 'markdown') {
        triggerDownload(`${title}.md`,   toMarkdown(thread, title, url, data), 'text/markdown');
      } else if (format === 'plain') {
        triggerDownload(`${title}.txt`,  toPlain(thread, title, url, data),    'text/plain');
      } else if (format === 'json') {
        triggerDownload(`${title}.json`, toJSON(thread, title, url, data), 'application/json');
      }
    } catch (err) {
      alert(`Export failed: ${err.message}`);
      console.error('[Claude Exporter]', err);
    } finally {
      items.forEach(b => (b.disabled = false));
    }
  }

  // ── UI ──────────────────────────────────────────────────────────────────────

  const FORMATS = [
    { label: 'Markdown',   icon: ICON.markdown, fmt: 'markdown' },
    { label: 'Plain Text', icon: ICON.plain,     fmt: 'plain'    },
    { label: 'JSON',       icon: ICON.json,      fmt: 'json'     },
  ];

  function buildWidget() {
    const wrap = document.createElement('div');
    wrap.id = '_exp_wrap';

    const btn = document.createElement('button');
    btn.id = '_exp_btn';
    btn.type = 'button';
    btn.className =
      'inline-flex items-center justify-center relative isolate shrink-0 can-focus select-none ' +
      'disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none disabled:drop-shadow-none ' +
      'font-base-bold border-0.5 overflow-hidden transition duration-100 backface-hidden ' +
      'h-8 rounded-md px-3 min-w-[4rem] whitespace-nowrap !text-xs _fill_10ocf_9 _secondary_10ocf_72';
    btn.innerHTML = `${ICON.download}Export`;

    const menu = document.createElement('div');
    menu.id = '_exp_menu';
    menu.role = 'menu';

    for (const { label, icon, fmt } of FORMATS) {
      const item = document.createElement('button');
      item.type = 'button';
      item.className = 'exp-item';
      item.role = 'menuitem';
      item.innerHTML = `${icon}${label}`;
      item.addEventListener('click', () => {
        menu.classList.remove('open');
        runExport(fmt);
      });
      menu.appendChild(item);
    }

    wrap.append(btn, menu);

    btn.addEventListener('click', e => { e.stopPropagation(); menu.classList.toggle('open'); });
    document.addEventListener('click', e => { if (!wrap.contains(e.target)) menu.classList.remove('open'); });
    document.addEventListener('keydown', e => { if (e.key === 'Escape') menu.classList.remove('open'); });

    return wrap;
  }

  // ── Bootstrap ───────────────────────────────────────────────────────────────

  function inject() {
    if (!/^\/chat\//.test(location.pathname)) {
      document.getElementById('_exp_wrap')?.remove();
      return;
    }

    if (document.getElementById('_exp_wrap')) return;

    const shareBtn = document.querySelector('[data-testid="wiggle-controls-actions-share"]');
    if (!shareBtn) return;

    injectStyles();
    shareBtn.before(buildWidget());
  }

  // Intercept pushState for SPA sidebar navigations
  const _origPush = history.pushState.bind(history);
  history.pushState = function (...args) {
    _origPush(...args);
    setTimeout(inject, 300);
  };
  window.addEventListener('popstate', () => setTimeout(inject, 300));

  inject();
  new MutationObserver(inject).observe(document.body, { childList: true, subtree: true });

})();