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