Claude project exporter – markdown + attachments + outputs A developer published a browser-based exporter that saves Claude projects as Markdown files along with their attachments and generated outputs. The tool runs client-side against Claude's internal API, using the File System Access API to write conversation content, attachments, and outputs into a structured local directory with sanitized, de-duplicated filenames. | | async = { | | | 'use strict'; | | | const API = '/api'; | | | const sleep = ms = new Promise r = setTimeout r, ms ; | | | // ============================================================ | | | // Helpers | | | // ============================================================ | | | function asArray v { | | | if Array.isArray v return v; | | | if v && Array.isArray v.data return v.data; | | | if v && Array.isArray v.items return v.items; | | | if v && Array.isArray v.results return v.results; | | | return ; | | | } | | | async function fetchJSON url, optional = false { | | | const r = await fetch url, { | | | credentials: 'include', | | | headers: { Accept: 'application/json' }, | | | } ; | | | if r.ok { | | | if optional && r.status === 403 \|\| r.status === 404 return null; | | | const body = await r.text .catch = '' ; | | | throw new Error | | | ${r.status} ${r.statusText}: ${url} + | | | body ? \n${body.slice 0, 500 } : '' | | | ; | | | } | | | return r.json ; | | | } | | | async function fetchBlob url, optional = false { | | | const r = await fetch url, { credentials: 'include' } ; | | | if r.ok { | | | if optional && r.status === 403 \|\| r.status === 404 return null; | | | throw new Error ${r.status} ${r.statusText}: ${url} ; | | | } | | | return r.blob ; | | | } | | | function absUrl url { | | | if url return null; | | | try { | | | return new URL url, location.origin .href; | | | } catch { | | | return url; | | | } | | | } | | | function safeName value, fallback = 'Untitled', max = 180 { | | | let s = String value \|\| fallback | | | .normalize 'NFC' | | | .replace / \u0000-\u001f\u007f /g, '' | | | .replace / \/\\: ?"< \| /g, '–' | | | .trim ; | | | if s s = fallback; | | | if s.length max s = s.slice 0, max .trim ; | | | return s; | | | } | | | function makeUniqueNamer { | | | const used = new Map ; | | | return name = { | | | const clean = safeName name ; | | | const key = clean.toLocaleLowerCase ; | | | if used.has key { | | | used.set key, 1 ; | | | return clean; | | | } | | | const n = used.get key + 1; | | | used.set key, n ; | | | const dot = clean.lastIndexOf '.' ; | | | return dot 0 | | | ? ${clean.slice 0, dot } ${n} ${clean.slice dot } | | | : ${clean} ${n} ; | | | }; | | | } | | | async function getDir parent, name { | | | return parent.getDirectoryHandle safeName name , { create: true } ; | | | } | | | async function writeBlob dir, name, blob { | | | const handle = await dir.getFileHandle safeName name , { create: true } ; | | | const writer = await handle.createWritable ; | | | await writer.write blob ; | | | await writer.close ; | | | } | | | async function writeText dir, name, text { | | | await writeBlob | | | dir, | | | name, | | | new Blob text ?? '' , { type: 'text/plain;charset=utf-8' } | | | ; | | | } | | | function iso value { | | | if value return ''; | | | const d = new Date value ; | | | return Number.isNaN d.valueOf ? String value : d.toISOString ; | | | } | | | function firstString ...values { | | | for const v of values { | | | if typeof v === 'string' && v.trim return v; | | | } | | | return ''; | | | } | | | function memoryText memory { | | | if memory return ''; | | | if typeof memory === 'string' return memory; | | | return firstString | | | memory.memory, | | | memory.content, | | | memory.text, | | | memory.value, | | | memory.summary | | | ; | | | } | | | function longestBacktickRun text { | | | return String text \|\| '' .match / +/g \|\| | | | .reduce m, run = Math.max m, run.length , 0 ; | | | } | | | function fencedText text { | | | const fence = ' '.repeat Math.max 3, longestBacktickRun text + 1 ; | | | return ${fence}text\n${text}\n${fence} ; | | | } | | | function extensionFromMime type { | | | const t = String type \|\| '' .toLowerCase .split ';' 0 .trim ; | | | const map = { | | | 'image/png': '.png', | | | 'image/jpeg': '.jpg', | | | 'image/webp': '.webp', | | | 'image/gif': '.gif', | | | 'image/svg+xml': '.svg', | | | 'application/pdf': '.pdf', | | | 'application/json': '.json', | | | 'text/plain': '.txt', | | | 'text/markdown': '.md', | | | 'text/html': '.html', | | | 'text/csv': '.csv', | | | }; | | | return map t \|\| ''; | | | } | | | function inferArtifactExtension input, content { | | | const kind = String | | | input?.type \|\| | | | input?.language \|\| | | | input?.mime type \|\| | | | '' | | | .toLowerCase ; | | | const map = { | | | 'text/markdown': '.md', | | | markdown: '.md', | | | md: '.md', | | | 'text/plain': '.txt', | | | text: '.txt', | | | txt: '.txt', | | | 'text/html': '.html', | | | html: '.html', | | | 'image/svg+xml': '.svg', | | | svg: '.svg', | | | 'application/json': '.json', | | | json: '.json', | | | javascript: '.js', | | | js: '.js', | | | typescript: '.ts', | | | ts: '.ts', | | | jsx: '.jsx', | | | tsx: '.tsx', | | | python: '.py', | | | py: '.py', | | | css: '.css', | | | csv: '.csv', | | | xml: '.xml', | | | sql: '.sql', | | | bash: '.sh', | | | shell: '.sh', | | | }; | | | if map kind return map kind ; | | | const s = String content \|\| '' .trimStart .toLowerCase ; | | | if s.startsWith '