ChatGPT Markdown Export

Adds "Copy as Markdown" and "Export as Markdown" to the ChatGPT chat options menu (the 3-dots button in the top-right of a chat).

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

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

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

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

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

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         ChatGPT Markdown Export
// @namespace    https://greasyfork.org/en/users/1528865-blati
// @version      0.1.0
// @description  Adds "Copy as Markdown" and "Export as Markdown" to the ChatGPT chat options menu (the 3-dots button in the top-right of a chat).
// @author       Blati
// @license      MIT
// @icon         data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2Ij48cmVjdCB3aWR0aD0iMjU2IiBoZWlnaHQ9IjI1NiIgcng9IjQ4IiBmaWxsPSIjMTBhMzdmIi8+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjQgNjQpIiBmaWxsPSIjZmZmIiBzdHJva2U9IiNmZmYiPjxyZWN0IHdpZHRoPSIxOTgiIGhlaWdodD0iMTE4IiB4PSI1IiB5PSI1IiByeT0iMTAiIHN0cm9rZS13aWR0aD0iMTAiIGZpbGw9Im5vbmUiLz48cGF0aCBkPSJNMzAgOThWMzBoMjBsMjAgMjUgMjAtMjVoMjB2NjhIOTBWNTlMNzAgODQgNTAgNTl2Mzl6TTE1NSA5OGwtMzAtMzNoMjBWMzBoMjB2MzVoMjB6IiBzdHJva2Utd2lkdGg9IjAiLz48L2c+PC9zdmc+
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @run-at       document-end
// @grant        GM_setClipboard
// @grant        GM_download
// @grant        GM_info
// ==/UserScript==

(function () {
    'use strict';

    // ------------------------------------------------------------------
    // Constants
    // ------------------------------------------------------------------

    const MENU_SELECTOR = '[role="menu"][data-radix-menu-content]';
    const CONV_MENU_PROBE = '[data-testid="delete-chat-menu-item"]';
    const TRIGGER_SELECTOR = '[data-testid="conversation-options-button"]';
    const INJECTED_MARK = 'data-md-export-injected';

    // Inline 20x20 stroke icons in Lucide style — matches ChatGPT's icon weight.
    const ICON_COPY = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
    const ICON_DOWNLOAD = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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>`;

    // ------------------------------------------------------------------
    // Auth + API
    // ------------------------------------------------------------------

    let cachedToken = null;
    let cachedTokenAt = 0;
    const TOKEN_TTL_MS = 5 * 60 * 1000;

    async function getAccessToken() {
        const now = Date.now();
        if (cachedToken && now - cachedTokenAt < TOKEN_TTL_MS) return cachedToken;
        const r = await fetch('/api/auth/session', { credentials: 'same-origin' });
        if (!r.ok) throw new Error(`auth/session failed: ${r.status}`);
        const j = await r.json();
        if (!j.accessToken) throw new Error('no accessToken in session response (not signed in?)');
        cachedToken = j.accessToken;
        cachedTokenAt = now;
        return cachedToken;
    }

    async function fetchConversation(conversationId) {
        const token = await getAccessToken();
        const r = await fetch(`/backend-api/conversation/${conversationId}`, {
            headers: { Authorization: `Bearer ${token}` },
            credentials: 'same-origin',
        });
        if (!r.ok) throw new Error(`conversation fetch failed: ${r.status}`);
        return r.json();
    }

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

    // ------------------------------------------------------------------
    // Conversation tree -> visible thread
    // ------------------------------------------------------------------

    function buildVisibleChain(conv) {
        const chain = [];
        let cur = conv.current_node;
        const seen = new Set();
        while (cur && !seen.has(cur)) {
            seen.add(cur);
            const node = conv.mapping[cur];
            if (!node) break;
            chain.push(node);
            cur = node.parent;
        }
        chain.reverse();
        return chain;
    }

    // ------------------------------------------------------------------
    // Node -> markdown
    // ------------------------------------------------------------------

    function joinParts(parts) {
        if (!Array.isArray(parts)) return '';
        const out = [];
        for (const p of parts) {
            if (typeof p === 'string') {
                out.push(p);
            } else if (p && typeof p === 'object') {
                if (p.content_type === 'image_asset_pointer') {
                    const name = p.metadata?.dalle?.gen_id || p.asset_pointer || 'image';
                    out.push(`\n\n*[image: ${name}]*\n\n`);
                } else if (p.content_type === 'audio_transcription' && typeof p.text === 'string') {
                    out.push(p.text);
                } else if (typeof p.text === 'string') {
                    out.push(p.text);
                }
            }
        }
        return out.join('');
    }

    function renderCitations(meta) {
        const refs = [];
        const cites = Array.isArray(meta?.citations) ? meta.citations : [];
        for (const c of cites) {
            const m = c?.metadata || {};
            const url = m.url || m.extra?.cited_message_idx_url;
            const title = m.title || m.text || url;
            if (url) refs.push({ title: title || url, url });
        }
        const contentRefs = Array.isArray(meta?.content_references) ? meta.content_references : [];
        for (const cr of contentRefs) {
            const items = cr?.items || cr?.refs || [];
            for (const item of items) {
                const url = item?.url || item?.snippet_url;
                const title = item?.title || item?.snippet || url;
                if (url) refs.push({ title: title || url, url });
            }
            if (cr?.url) refs.push({ title: cr.title || cr.url, url: cr.url });
        }
        const seen = new Set();
        const unique = refs.filter(r => {
            if (seen.has(r.url)) return false;
            seen.add(r.url);
            return true;
        });
        if (!unique.length) return '';
        const lines = unique.map((r, i) => `${i + 1}. [${r.title}](${r.url})`);
        return `\n\n**Sources**\n\n${lines.join('\n')}\n`;
    }

    function nodeToMarkdown(node) {
        const msg = node?.message;
        if (!msg) return null;
        const role = msg.author?.role;
        if (role === 'system' || role === 'tool') return null;
        if (role === 'assistant' && msg.channel && msg.channel !== 'final') return null;

        const content = msg.content;
        if (!content) return null;
        const ctype = content.content_type;

        let body = '';
        if (ctype === 'text' || ctype === 'multimodal_text') {
            body = joinParts(content.parts);
        } else if (ctype === 'user_editable_context') {
            return null;
        } else {
            return null;
        }
        body = body.trim();
        if (!body) return null;

        const heading = role === 'user'
            ? '## User'
            : `## ChatGPT${msg.metadata?.model_slug ? ` (${msg.metadata.model_slug})` : ''}`;

        const citations = role === 'assistant' ? renderCitations(msg.metadata) : '';
        return `${heading}\n\n${body}${citations}`;
    }

    // ------------------------------------------------------------------
    // Build full export
    // ------------------------------------------------------------------

    function isoDate(epochSec) {
        if (!epochSec) return '';
        try { return new Date(epochSec * 1000).toISOString(); } catch { return ''; }
    }

    function yamlEscape(s) {
        return String(s ?? '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
    }

    function buildExport(conv) {
        const chain = buildVisibleChain(conv);
        const turns = [];
        for (const node of chain) {
            const md = nodeToMarkdown(node);
            if (md) turns.push(md);
        }
        const frontmatter = [
            '---',
            `title: "${yamlEscape(conv.title || 'ChatGPT conversation')}"`,
            `conversation_id: ${conv.conversation_id || ''}`,
            `created: ${isoDate(conv.create_time)}`,
            `exported: ${new Date().toISOString()}`,
            `model: ${conv.default_model_slug || ''}`,
            `url: https://chatgpt.com/c/${conv.conversation_id || ''}`,
            '---',
            '',
        ].join('\n');
        const title = conv.title ? `# ${conv.title}\n\n` : '';
        return frontmatter + title + turns.join('\n\n');
    }

    function sanitizeFilename(s) {
        return String(s || 'chatgpt-conversation')
            .replace(/[\u0000-\u001f\u007f]/g, '')
            .replace(/[<>:"/\\|?*]+/g, '-')
            .replace(/\s+/g, ' ')
            .trim()
            .slice(0, 120) || 'chatgpt-conversation';
    }

    function dateStamp() {
        const d = new Date();
        const pad = n => String(n).padStart(2, '0');
        return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
    }

    // ------------------------------------------------------------------
    // Actions
    // ------------------------------------------------------------------

    async function withConversation(action) {
        const id = getCurrentConversationId();
        if (!id) {
            toast('Not in a chat — open a conversation first.', 'error');
            return;
        }
        try {
            const conv = await fetchConversation(id);
            const md = buildExport(conv);
            await action(md, conv);
        } catch (e) {
            console.error('[md-export]', e);
            toast(`Export failed: ${e.message || e}`, 'error');
        }
    }

    async function copyAsMarkdown() {
        await withConversation(async (md) => {
            if (typeof GM_setClipboard === 'function') {
                GM_setClipboard(md, { type: 'text', mimetype: 'text/plain' });
                toast('Copied conversation as Markdown.');
                return;
            }
            try {
                await navigator.clipboard.writeText(md);
                toast('Copied conversation as Markdown.');
            } catch (e) {
                const ta = document.createElement('textarea');
                ta.value = md;
                ta.style.position = 'fixed';
                ta.style.opacity = '0';
                document.body.appendChild(ta);
                ta.select();
                document.execCommand('copy');
                ta.remove();
                toast('Copied conversation as Markdown.');
            }
        });
    }

    async function downloadAsMarkdown() {
        await withConversation(async (md, conv) => {
            const filename = `${sanitizeFilename(conv.title)}-${dateStamp()}.md`;
            const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
            const url = URL.createObjectURL(blob);
            if (typeof GM_download === 'function') {
                GM_download({
                    url,
                    name: filename,
                    saveAs: false,
                    onload: () => { setTimeout(() => URL.revokeObjectURL(url), 1000); toast(`Saved ${filename}`); },
                    onerror: () => { fallbackAnchor(url, filename); },
                });
                return;
            }
            fallbackAnchor(url, filename);
            toast(`Saved ${filename}`);
        });

        function fallbackAnchor(url, filename) {
            const a = document.createElement('a');
            a.href = url;
            a.download = filename;
            document.body.appendChild(a);
            a.click();
            a.remove();
            setTimeout(() => URL.revokeObjectURL(url), 1000);
        }
    }

    // ------------------------------------------------------------------
    // Menu injection
    // ------------------------------------------------------------------

    function buildMenuItem(label, iconSVG, onActivate) {
        const item = document.createElement('div');
        item.setAttribute('role', 'menuitem');
        item.setAttribute('tabindex', '0');
        item.className = 'group __menu-item hoverable gap-1.5';
        item.setAttribute('data-orientation', 'vertical');

        const iconWrap = document.createElement('div');
        iconWrap.className = 'flex items-center justify-center [opacity:var(--menu-item-icon-opacity,1)] icon';
        iconWrap.innerHTML = iconSVG;
        item.appendChild(iconWrap);
        item.appendChild(document.createTextNode(label));

        const fire = (e) => {
            e.preventDefault();
            e.stopPropagation();
            // Close the Radix menu by sending Escape — keeps focus management consistent.
            document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
            // Defer the action so the menu can unmount cleanly first.
            setTimeout(() => { onActivate(); }, 0);
        };
        item.addEventListener('click', fire);
        item.addEventListener('keydown', (e) => {
            if (e.key === 'Enter' || e.key === ' ') fire(e);
        });
        return item;
    }

    function injectIntoMenu(menu) {
        if (menu.hasAttribute(INJECTED_MARK)) return;
        if (!menu.querySelector(CONV_MENU_PROBE)) return; // wrong menu
        if (!getCurrentConversationId()) return; // not in a chat
        menu.setAttribute(INJECTED_MARK, '1');

        const groups = menu.querySelectorAll(':scope > [role="group"]');
        if (!groups.length) return;
        const reference = groups[0].querySelector('[role="group"]') || groups[0]; // safety
        const groupClass = groups[groups.length - 1].className; // last group has the divider classes we want

        const group = document.createElement('div');
        group.setAttribute('role', 'group');
        group.className = groupClass;

        group.appendChild(buildMenuItem('Copy as Markdown', ICON_COPY, copyAsMarkdown));
        group.appendChild(buildMenuItem('Export as Markdown', ICON_DOWNLOAD, downloadAsMarkdown));

        // Insert before the destructive group (the one containing Delete).
        const deleteItem = menu.querySelector('[data-testid="delete-chat-menu-item"]');
        const destructiveGroup = deleteItem ? deleteItem.closest('[role="group"]') : null;
        if (destructiveGroup && destructiveGroup.parentNode === menu) {
            menu.insertBefore(group, destructiveGroup);
        } else {
            menu.appendChild(group);
        }
    }

    const menuObserver = new MutationObserver((records) => {
        for (const rec of records) {
            for (const n of rec.addedNodes) {
                if (!(n instanceof HTMLElement)) continue;
                if (n.matches?.(MENU_SELECTOR)) injectIntoMenu(n);
                const nested = n.querySelectorAll?.(MENU_SELECTOR);
                if (nested) nested.forEach(injectIntoMenu);
            }
        }
    });
    menuObserver.observe(document.body, { childList: true, subtree: true });

    // ------------------------------------------------------------------
    // Toast
    // ------------------------------------------------------------------

    let toastEl = null;
    let toastTimer = null;
    function toast(message, kind = 'info') {
        if (!toastEl) {
            toastEl = document.createElement('div');
            Object.assign(toastEl.style, {
                position: 'fixed',
                left: '50%',
                bottom: '32px',
                transform: 'translateX(-50%)',
                padding: '10px 16px',
                borderRadius: '12px',
                fontSize: '14px',
                fontFamily: 'inherit',
                color: '#fff',
                background: 'rgba(32,33,35,0.95)',
                boxShadow: '0 4px 20px rgba(0,0,0,0.3)',
                zIndex: '2147483647',
                opacity: '0',
                transition: 'opacity 180ms ease',
                pointerEvents: 'none',
            });
            document.body.appendChild(toastEl);
        }
        toastEl.textContent = message;
        toastEl.style.background = kind === 'error' ? 'rgba(180,40,40,0.95)' : 'rgba(32,33,35,0.95)';
        requestAnimationFrame(() => { toastEl.style.opacity = '1'; });
        clearTimeout(toastTimer);
        toastTimer = setTimeout(() => {
            toastEl.style.opacity = '0';
        }, 2200);
    }
})();