{"slug": "claude-project-exporter-markdown-attachments-outputs", "title": "Claude project exporter – markdown + attachments + outputs", "summary": "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.", "body_md": "|  | (async () => { | \n|  | 'use strict'; | \n|  | const API = '/api'; | \n|  | const sleep = ms => new Promise(r => setTimeout(r, ms)); | \n|  | // ============================================================ | \n|  | // Helpers | \n|  | // ============================================================ | \n|  | function asArray(v) { | \n|  | if (Array.isArray(v)) return v; | \n|  | if (v && Array.isArray(v.data)) return v.data; | \n|  | if (v && Array.isArray(v.items)) return v.items; | \n|  | if (v && Array.isArray(v.results)) return v.results; | \n|  | return []; | \n|  | } | \n|  | async function fetchJSON(url, optional = false) { | \n|  | const r = await fetch(url, { | \n|  | credentials: 'include', | \n|  | headers: { Accept: 'application/json' }, | \n|  | }); | \n|  | if (!r.ok) { | \n|  | if (optional && (r.status === 403 \\|\\| r.status === 404)) return null; | \n|  | const body = await r.text().catch(() => ''); | \n|  | throw new Error( | \n|  | `${r.status} ${r.statusText}: ${url}` + | \n|  | (body ? `\\n${body.slice(0, 500)}` : '') | \n|  | ); | \n|  | } | \n|  | return r.json(); | \n|  | } | \n|  | async function fetchBlob(url, optional = false) { | \n|  | const r = await fetch(url, { credentials: 'include' }); | \n|  | if (!r.ok) { | \n|  | if (optional && (r.status === 403 \\|\\| r.status === 404)) return null; | \n|  | throw new Error(`${r.status} ${r.statusText}: ${url}`); | \n|  | } | \n|  | return r.blob(); | \n|  | } | \n|  | function absUrl(url) { | \n|  | if (!url) return null; | \n|  | try { | \n|  | return new URL(url, location.origin).href; | \n|  | } catch { | \n|  | return url; | \n|  | } | \n|  | } | \n|  | function safeName(value, fallback = 'Untitled', max = 180) { | \n|  | let s = String(value \\|\\| fallback) | \n|  | .normalize('NFC') | \n|  | .replace(/[\\u0000-\\u001f\\u007f]/g, '') | \n|  | .replace(/[\\/\\\\:*?\"<>\\|]/g, '–') | \n|  | .trim(); | \n|  | if (!s) s = fallback; | \n|  | if (s.length > max) s = s.slice(0, max).trim(); | \n|  | return s; | \n|  | } | \n|  | function makeUniqueNamer() { | \n|  | const used = new Map(); | \n|  | return name => { | \n|  | const clean = safeName(name); | \n|  | const key = clean.toLocaleLowerCase(); | \n|  | if (!used.has(key)) { | \n|  | used.set(key, 1); | \n|  | return clean; | \n|  | } | \n|  | const n = used.get(key) + 1; | \n|  | used.set(key, n); | \n|  | const dot = clean.lastIndexOf('.'); | \n|  | return dot > 0 | \n|  | ? `${clean.slice(0, dot)} (${n})${clean.slice(dot)}` | \n|  | : `${clean} (${n})`; | \n|  | }; | \n|  | } | \n|  | async function getDir(parent, name) { | \n|  | return parent.getDirectoryHandle(safeName(name), { create: true }); | \n|  | } | \n|  | async function writeBlob(dir, name, blob) { | \n|  | const handle = await dir.getFileHandle(safeName(name), { create: true }); | \n|  | const writer = await handle.createWritable(); | \n|  | await writer.write(blob); | \n|  | await writer.close(); | \n|  | } | \n|  | async function writeText(dir, name, text) { | \n|  | await writeBlob( | \n|  | dir, | \n|  | name, | \n|  | new Blob([text ?? ''], { type: 'text/plain;charset=utf-8' }) | \n|  | ); | \n|  | } | \n|  | function iso(value) { | \n|  | if (!value) return ''; | \n|  | const d = new Date(value); | \n|  | return Number.isNaN(d.valueOf()) ? String(value) : d.toISOString(); | \n|  | } | \n|  | function firstString(...values) { | \n|  | for (const v of values) { | \n|  | if (typeof v === 'string' && v.trim()) return v; | \n|  | } | \n|  | return ''; | \n|  | } | \n|  | function memoryText(memory) { | \n|  | if (!memory) return ''; | \n|  | if (typeof memory === 'string') return memory; | \n|  | return firstString( | \n|  | memory.memory, | \n|  | memory.content, | \n|  | memory.text, | \n|  | memory.value, | \n|  | memory.summary | \n|  | ); | \n|  | } | \n|  | function longestBacktickRun(text) { | \n|  | return (String(text \\|\\| '').match(/`+/g) \\|\\| []) | \n|  | .reduce((m, run) => Math.max(m, run.length), 0); | \n|  | } | \n|  | function fencedText(text) { | \n|  | const fence = '`'.repeat(Math.max(3, longestBacktickRun(text) + 1)); | \n|  | return `${fence}text\\n${text}\\n${fence}`; | \n|  | } | \n|  | function extensionFromMime(type) { | \n|  | const t = String(type \\|\\| '').toLowerCase().split(';')[0].trim(); | \n|  | const map = { | \n|  | 'image/png': '.png', | \n|  | 'image/jpeg': '.jpg', | \n|  | 'image/webp': '.webp', | \n|  | 'image/gif': '.gif', | \n|  | 'image/svg+xml': '.svg', | \n|  | 'application/pdf': '.pdf', | \n|  | 'application/json': '.json', | \n|  | 'text/plain': '.txt', | \n|  | 'text/markdown': '.md', | \n|  | 'text/html': '.html', | \n|  | 'text/csv': '.csv', | \n|  | }; | \n|  | return map[t] \\|\\| ''; | \n|  | } | \n|  | function inferArtifactExtension(input, content) { | \n|  | const kind = String( | \n|  | input?.type \\|\\| | \n|  | input?.language \\|\\| | \n|  | input?.mime_type \\|\\| | \n|  | '' | \n|  | ).toLowerCase(); | \n|  | const map = { | \n|  | 'text/markdown': '.md', | \n|  | markdown: '.md', | \n|  | md: '.md', | \n|  | 'text/plain': '.txt', | \n|  | text: '.txt', | \n|  | txt: '.txt', | \n|  | 'text/html': '.html', | \n|  | html: '.html', | \n|  | 'image/svg+xml': '.svg', | \n|  | svg: '.svg', | \n|  | 'application/json': '.json', | \n|  | json: '.json', | \n|  | javascript: '.js', | \n|  | js: '.js', | \n|  | typescript: '.ts', | \n|  | ts: '.ts', | \n|  | jsx: '.jsx', | \n|  | tsx: '.tsx', | \n|  | python: '.py', | \n|  | py: '.py', | \n|  | css: '.css', | \n|  | csv: '.csv', | \n|  | xml: '.xml', | \n|  | sql: '.sql', | \n|  | bash: '.sh', | \n|  | shell: '.sh', | \n|  | }; | \n|  | if (map[kind]) return map[kind]; | \n|  | const s = String(content \\|\\| '').trimStart().toLowerCase(); | \n|  | if (s.startsWith('<svg')) return '.svg'; | \n|  | if (s.startsWith('<!doctype html') \\|\\| s.startsWith('<html')) return '.html'; | \n|  | return '.md'; | \n|  | } | \n|  | // ============================================================ | \n|  | // Project discovery | \n|  | // ============================================================ | \n|  | async function discoverProjects() { | \n|  | const orgs = asArray(await fetchJSON(`${API}/organizations`)); | \n|  | const found = []; | \n|  | for (const org of orgs) { | \n|  | if (!org?.uuid) continue; | \n|  | try { | \n|  | const data = await fetchJSON( | \n|  | `${API}/organizations/${org.uuid}/projects` + | \n|  | `?include_harmony_projects=true&limit=100` | \n|  | ); | \n|  | for (const project of asArray(data)) { | \n|  | if (project?.uuid) found.push({ org, project }); | \n|  | } | \n|  | } catch (e) { | \n|  | console.warn('Could not list projects for org', org.uuid, e); | \n|  | } | \n|  | } | \n|  | return found; | \n|  | } | \n|  | function projectIdFromUrl() { | \n|  | return location.pathname.match(/\\/projects?\\/([0-9a-f-]{20,})/i)?.[1] \\|\\| null; | \n|  | } | \n|  | function chooseProject(projects) { | \n|  | if (!projects.length) throw new Error('No Claude projects found.'); | \n|  | const current = projectIdFromUrl(); | \n|  | if (current) { | \n|  | const match = projects.find(x => x.project.uuid === current); | \n|  | if (match) return match; | \n|  | } | \n|  | if (projects.length === 1) return projects[0]; | \n|  | const list = projects | \n|  | .map((x, i) => `${i + 1}. ${x.project.name \\|\\| '(unnamed project)'}`) | \n|  | .join('\\n'); | \n|  | const answer = prompt( | \n|  | `Which project should be exported?\\n\\n${list}\\n\\nEnter a number:`, | \n|  | '1' | \n|  | ); | \n|  | if (answer == null) return null; | \n|  | const idx = Number(answer) - 1; | \n|  | if (!Number.isInteger(idx) \\|\\| idx < 0 \\|\\| idx >= projects.length) { | \n|  | throw new Error('Invalid project number.'); | \n|  | } | \n|  | return projects[idx]; | \n|  | } | \n|  | // ============================================================ | \n|  | // Conversation tree | \n|  | // ============================================================ | \n|  | function activeBranch(messages, leafUuid) { | \n|  | if (!leafUuid \\|\\| !Array.isArray(messages)) return messages \\|\\| []; | \n|  | const byId = new Map( | \n|  | messages | \n|  | .filter(m => m?.uuid) | \n|  | .map(m => [m.uuid, m]) | \n|  | ); | \n|  | const branch = []; | \n|  | const seen = new Set(); | \n|  | let id = leafUuid; | \n|  | while (id && byId.has(id) && !seen.has(id)) { | \n|  | seen.add(id); | \n|  | const m = byId.get(id); | \n|  | branch.push(m); | \n|  | id = m.parent_message_uuid; | \n|  | } | \n|  | return branch.length ? branch.reverse() : messages; | \n|  | } | \n|  | function extractedTextItems(message) { | \n|  | const out = []; | \n|  | for (const obj of [ | \n|  | ...asArray(message?.attachments), | \n|  | ...asArray(message?.files), | \n|  | ]) { | \n|  | if ( | \n|  | typeof obj?.extracted_content === 'string' && | \n|  | obj.extracted_content.length | \n|  | ) { | \n|  | out.push({ | \n|  | name: obj.file_name \\|\\| obj.name \\|\\| 'Attached text', | \n|  | text: obj.extracted_content, | \n|  | }); | \n|  | } | \n|  | } | \n|  | return out; | \n|  | } | \n|  | // ============================================================ | \n|  | // Citations / sources | \n|  | // ============================================================ | \n|  | function citationLinesFromCitation(c) { | \n|  | const lines = []; | \n|  | if (!c \\|\\| typeof c !== 'object') return lines; | \n|  | if (c.url) { | \n|  | const title = firstString(c.title, c.name, c.url); | \n|  | const via = c.origin_tool_name ? ` — via ${c.origin_tool_name}` : ''; | \n|  | lines.push(`- [${title}](${c.url})${via}`); | \n|  | } | \n|  | for (const src of asArray(c.sources)) { | \n|  | if (typeof src === 'string') { | \n|  | lines.push(`- ${src}`); | \n|  | } else if (src && typeof src === 'object') { | \n|  | if (src.url) { | \n|  | const title = firstString(src.title, src.name, src.url); | \n|  | lines.push(`- [${title}](${src.url})`); | \n|  | } | \n|  | } | \n|  | } | \n|  | if (!c.url) { | \n|  | const docTitle = firstString( | \n|  | c.document_title, | \n|  | c.title, | \n|  | c.file_name, | \n|  | c.document_id, | \n|  | c.file_id | \n|  | ); | \n|  | if (docTitle) { | \n|  | let loc = ''; | \n|  | if (Number.isFinite(c.page_number)) { | \n|  | loc = ` — page ${c.page_number}`; | \n|  | } else if ( | \n|  | Number.isFinite(c.start_page_number) \\|\\| | \n|  | Number.isFinite(c.end_page_number) | \n|  | ) { | \n|  | const a = c.start_page_number ?? c.end_page_number; | \n|  | const b = c.end_page_number ?? c.start_page_number; | \n|  | loc = a === b ? ` — page ${a}` : ` — pages ${a}–${b}`; | \n|  | } else if ( | \n|  | Number.isFinite(c.start_index) \\|\\| | \n|  | Number.isFinite(c.end_index) | \n|  | ) { | \n|  | const a = c.start_index ?? '?'; | \n|  | const b = c.end_index ?? '?'; | \n|  | loc = ` — characters ${a}–${b}`; | \n|  | } else if ( | \n|  | Number.isFinite(c.start_char_index) \\|\\| | \n|  | Number.isFinite(c.end_char_index) | \n|  | ) { | \n|  | const a = c.start_char_index ?? '?'; | \n|  | const b = c.end_char_index ?? '?'; | \n|  | loc = ` — characters ${a}–${b}`; | \n|  | } | \n|  | lines.push(`- ${docTitle}${loc}`); | \n|  | } | \n|  | } | \n|  | return lines; | \n|  | } | \n|  | function collectMessageSourceLines(message) { | \n|  | const lines = []; | \n|  | for (const b of asArray(message?.content)) { | \n|  | if (b?.type !== 'text') continue; | \n|  | for (const c of asArray(b.citations)) { | \n|  | lines.push(...citationLinesFromCitation(c)); | \n|  | } | \n|  | } | \n|  | return [...new Set(lines)]; | \n|  | } | \n|  | // ============================================================ | \n|  | // Markdown chat | \n|  | // ============================================================ | \n|  | function renderChatMarkdown(conversation, messages, projectName) { | \n|  | const title = | \n|  | conversation.name \\|\\| | \n|  | conversation.title \\|\\| | \n|  | 'Untitled chat'; | \n|  | const created = | \n|  | conversation.created_at \\|\\| | \n|  | messages[0]?.created_at \\|\\| | \n|  | ''; | \n|  | const md = [ | \n|  | `# ${title}`, | \n|  | '', | \n|  | `Project: ${projectName}`, | \n|  | '', | \n|  | ]; | \n|  | if (created) md.push(`Created: ${iso(created)}`, ''); | \n|  | for (const m of messages) { | \n|  | const who = m.sender === 'human' ? 'User' : 'Claude'; | \n|  | const ts = iso(m.created_at); | \n|  | md.push(`## ${who}${ts ? ` — ${ts}` : ''}`, ''); | \n|  | const blocks = Array.isArray(m.content) ? m.content : []; | \n|  | let wroteText = false; | \n|  | for (const b of blocks) { | \n|  | if (!b \\|\\| typeof b !== 'object') continue; | \n|  | if ( | \n|  | b.type === 'text' && | \n|  | typeof b.text === 'string' && | \n|  | b.text.length | \n|  | ) { | \n|  | md.push(b.text, ''); | \n|  | wroteText = true; | \n|  | } | \n|  | } | \n|  | if ( | \n|  | !wroteText && | \n|  | typeof m.text === 'string' && | \n|  | m.text.length | \n|  | ) { | \n|  | md.push(m.text, ''); | \n|  | } | \n|  | const extracted = extractedTextItems(m); | \n|  | const extractedNames = new Set(extracted.map(x => x.name)); | \n|  | for (const item of extracted) { | \n|  | md.push( | \n|  | `### Attached text — ${item.name}`, | \n|  | '', | \n|  | fencedText(item.text), | \n|  | '' | \n|  | ); | \n|  | } | \n|  | const names = new Set(); | \n|  | for (const a of asArray(m.attachments)) { | \n|  | if (a?.file_name) names.add(a.file_name); | \n|  | } | \n|  | for (const f of asArray(m.files)) { | \n|  | if (f?.file_name) names.add(f.file_name); | \n|  | } | \n|  | for (const name of names) { | \n|  | if (!extractedNames.has(name)) { | \n|  | md.push(`> Attachment: ${name}`, ''); | \n|  | } | \n|  | } | \n|  | if (m.sender !== 'human') { | \n|  | const sources = collectMessageSourceLines(m); | \n|  | if (sources.length) { | \n|  | md.push( | \n|  | '### Sources', | \n|  | '', | \n|  | ...sources, | \n|  | '' | \n|  | ); | \n|  | } | \n|  | } | \n|  | if (m.compaction_summary) { | \n|  | const summary = | \n|  | typeof m.compaction_summary === 'string' | \n|  | ? m.compaction_summary | \n|  | : firstString( | \n|  | m.compaction_summary.summary, | \n|  | m.compaction_summary.text, | \n|  | m.compaction_summary.content | \n|  | ); | \n|  | if (summary) { | \n|  | md.push( | \n|  | '### Claude compaction summary', | \n|  | '', | \n|  | summary, | \n|  | '' | \n|  | ); | \n|  | } | \n|  | } | \n|  | } | \n|  | return md | \n|  | .join('\\n') | \n|  | .replace(/\\n{4,}/g, '\\n\\n\\n') | \n|  | .trim() + '\\n'; | \n|  | } | \n|  | // ============================================================ | \n|  | // Reconstruct final Claude artifacts | \n|  | // ============================================================ | \n|  | function replaceExactlyOnce(text, oldStr, newStr) { | \n|  | const first = text.indexOf(oldStr); | \n|  | if (first < 0) return { ok: false, text, reason: 'old_str not found' }; | \n|  | const second = text.indexOf(oldStr, first + oldStr.length); | \n|  | if (second >= 0) { | \n|  | return { | \n|  | ok: false, | \n|  | text, | \n|  | reason: 'old_str appears more than once', | \n|  | }; | \n|  | } | \n|  | return { | \n|  | ok: true, | \n|  | text: | \n|  | text.slice(0, first) + | \n|  | newStr + | \n|  | text.slice(first + oldStr.length), | \n|  | }; | \n|  | } | \n|  | function replayArtifacts(messages) { | \n|  | const artifacts = new Map(); | \n|  | let anonymousCounter = 0; | \n|  | for (const m of messages) { | \n|  | for (const block of asArray(m?.content)) { | \n|  | if ( | \n|  | block?.type !== 'tool_use' \\|\\| | \n|  | block?.name !== 'artifacts' \\|\\| | \n|  | !block.input \\|\\| | \n|  | typeof block.input !== 'object' | \n|  | ) { | \n|  | continue; | \n|  | } | \n|  | const input = block.input; | \n|  | const command = String(input.command \\|\\| 'create').toLowerCase(); | \n|  | let id = firstString( | \n|  | input.id, | \n|  | input.artifact_id, | \n|  | input.uuid, | \n|  | input.version_uuid | \n|  | ); | \n|  | if (!id) { | \n|  | id = `anonymous-artifact-${++anonymousCounter}`; | \n|  | } | \n|  | let state = artifacts.get(id); | \n|  | if (!state) { | \n|  | state = { | \n|  | id, | \n|  | title: firstString(input.title, id), | \n|  | type: firstString(input.type, input.mime_type), | \n|  | language: firstString(input.language), | \n|  | content: '', | \n|  | warnings: [], | \n|  | }; | \n|  | artifacts.set(id, state); | \n|  | } | \n|  | if (input.title) state.title = input.title; | \n|  | if (input.type) state.type = input.type; | \n|  | if (input.mime_type) state.type = input.mime_type; | \n|  | if (input.language) state.language = input.language; | \n|  | if (command === 'create') { | \n|  | if (typeof input.content === 'string') { | \n|  | state.content = input.content; | \n|  | } else if (typeof input.new_str === 'string') { | \n|  | state.content = input.new_str; | \n|  | } | \n|  | continue; | \n|  | } | \n|  | if (command === 'rewrite') { | \n|  | if (typeof input.content === 'string') { | \n|  | state.content = input.content; | \n|  | } else if (typeof input.new_str === 'string') { | \n|  | state.content = input.new_str; | \n|  | } else { | \n|  | state.warnings.push( | \n|  | 'rewrite command had no content/new_str' | \n|  | ); | \n|  | } | \n|  | continue; | \n|  | } | \n|  | if (command === 'update') { | \n|  | if ( | \n|  | typeof input.old_str === 'string' && | \n|  | typeof input.new_str === 'string' | \n|  | ) { | \n|  | const replaced = replaceExactlyOnce( | \n|  | state.content, | \n|  | input.old_str, | \n|  | input.new_str | \n|  | ); | \n|  | if (replaced.ok) { | \n|  | state.content = replaced.text; | \n|  | } else if (typeof input.content === 'string') { | \n|  | state.content = input.content; | \n|  | state.warnings.push( | \n|  | `update patch could not be applied (${replaced.reason}); used full content fallback` | \n|  | ); | \n|  | } else { | \n|  | state.warnings.push( | \n|  | `update patch could not be applied: ${replaced.reason}` | \n|  | ); | \n|  | } | \n|  | } else if (typeof input.content === 'string') { | \n|  | state.content = input.content; | \n|  | } else { | \n|  | state.warnings.push( | \n|  | 'update command had no usable patch/content' | \n|  | ); | \n|  | } | \n|  | continue; | \n|  | } | \n|  | if (typeof input.content === 'string') { | \n|  | state.content = input.content; | \n|  | state.warnings.push( | \n|  | `unknown command \"${command}\"; used supplied content` | \n|  | ); | \n|  | } else { | \n|  | state.warnings.push( | \n|  | `unknown command \"${command}\"` | \n|  | ); | \n|  | } | \n|  | } | \n|  | } | \n|  | return [...artifacts.values()] | \n|  | .filter(a => a.content.length); | \n|  | } | \n|  | async function saveReconstructedArtifacts( | \n|  | messages, | \n|  | outputsDir, | \n|  | outputUnique | \n|  | ) { | \n|  | const artifacts = replayArtifacts(messages); | \n|  | for (const artifact of artifacts) { | \n|  | const stem = safeName( | \n|  | artifact.title \\|\\| | \n|  | artifact.id \\|\\| | \n|  | 'artifact' | \n|  | ); | \n|  | const ext = inferArtifactExtension( | \n|  | { | \n|  | type: artifact.type, | \n|  | language: artifact.language, | \n|  | }, | \n|  | artifact.content | \n|  | ); | \n|  | const filename = | \n|  | /\\.[A-Za-z0-9]{1,10}$/.test(stem) | \n|  | ? stem | \n|  | : `${stem}${ext}`; | \n|  | await writeText( | \n|  | outputsDir, | \n|  | outputUnique(filename), | \n|  | artifact.content | \n|  | ); | \n|  | if (artifact.warnings.length) { | \n|  | console.warn( | \n|  | `Artifact \"${artifact.title}\" reconstructed with warnings:`, | \n|  | artifact.warnings | \n|  | ); | \n|  | } | \n|  | } | \n|  | } | \n|  | // ============================================================ | \n|  | // Project-level context and files | \n|  | // ============================================================ | \n|  | async function exportProjectContextAndFiles( | \n|  | orgId, | \n|  | projectId, | \n|  | projectRoot, | \n|  | filesDir | \n|  | ) { | \n|  | const base = `${API}/organizations/${orgId}`; | \n|  | const [ | \n|  | project, | \n|  | memory, | \n|  | docsRaw, | \n|  | filesRaw, | \n|  | ] = await Promise.all([ | \n|  | fetchJSON(`${base}/projects/${projectId}`), | \n|  | fetchJSON( | \n|  | `${base}/memory?project_uuid=${encodeURIComponent(projectId)}`, | \n|  | true | \n|  | ), | \n|  | fetchJSON( | \n|  | `${base}/projects/${projectId}/docs`, | \n|  | true | \n|  | ), | \n|  | fetchJSON( | \n|  | `${base}/projects/${projectId}/files`, | \n|  | true | \n|  | ), | \n|  | ]); | \n|  | const instructions = firstString( | \n|  | project?.prompt_template, | \n|  | project?.instructions, | \n|  | project?.project_instructions | \n|  | ); | \n|  | const description = firstString( | \n|  | project?.description, | \n|  | project?.summary, | \n|  | project?.project_description | \n|  | ); | \n|  | await writeText( | \n|  | projectRoot, | \n|  | 'Project Instructions.md', | \n|  | instructions \\|\\| | \n|  | '(No project instructions were returned by Claude.)\\n' | \n|  | ); | \n|  | await writeText( | \n|  | projectRoot, | \n|  | 'Project Memory.md', | \n|  | memoryText(memory) \\|\\| | \n|  | '(No project memory was returned by Claude.)\\n' | \n|  | ); | \n|  | await writeText( | \n|  | projectRoot, | \n|  | 'Project Description.md', | \n|  | description \\|\\| | \n|  | '(No project description was returned by Claude.)\\n' | \n|  | ); | \n|  | const docs = asArray(docsRaw); | \n|  | const files = asArray(filesRaw); | \n|  | const unique = makeUniqueNamer(); | \n|  | const originalsRecovered = new Set(); | \n|  | for (const f of files) { | \n|  | const sourceName = | \n|  | f.file_name \\|\\| | \n|  | f.name \\|\\| | \n|  | `file-${f.file_uuid \\|\\| f.uuid \\|\\| 'unknown'}`; | \n|  | const candidates = [ | \n|  | { | \n|  | url: f.download_url, | \n|  | label: '', | \n|  | original: true, | \n|  | }, | \n|  | { | \n|  | url: f.original_url, | \n|  | label: '', | \n|  | original: true, | \n|  | }, | \n|  | { | \n|  | url: f.original_asset?.url, | \n|  | label: '', | \n|  | original: true, | \n|  | }, | \n|  | { | \n|  | url: f.document_asset?.url, | \n|  | label: ' [Claude representation]', | \n|  | original: false, | \n|  | }, | \n|  | { | \n|  | url: f.preview_asset?.url, | \n|  | label: ' [Claude preview]', | \n|  | original: false, | \n|  | }, | \n|  | { | \n|  | url: f.preview_url, | \n|  | label: ' [Claude preview]', | \n|  | original: false, | \n|  | }, | \n|  | { | \n|  | url: f.thumbnail_asset?.url, | \n|  | label: ' [Claude thumbnail]', | \n|  | original: false, | \n|  | }, | \n|  | ].filter(x => x.url); | \n|  | for (const candidate of candidates) { | \n|  | try { | \n|  | const blob = await fetchBlob( | \n|  | absUrl(candidate.url), | \n|  | true | \n|  | ); | \n|  | if (!blob) continue; | \n|  | let target = sourceName; | \n|  | if (candidate.label) { | \n|  | const dot = target.lastIndexOf('.'); | \n|  | target = | \n|  | dot > 0 | \n|  | ? `${target.slice(0, dot)}${candidate.label}${target.slice(dot)}` | \n|  | : `${target}${candidate.label}${extensionFromMime(blob.type)}`; | \n|  | } | \n|  | await writeBlob( | \n|  | filesDir, | \n|  | unique(target), | \n|  | blob | \n|  | ); | \n|  | if (candidate.original) { | \n|  | originalsRecovered.add( | \n|  | sourceName.toLocaleLowerCase() | \n|  | ); | \n|  | } | \n|  | break; | \n|  | } catch (e) { | \n|  | console.warn( | \n|  | `Could not download project file: ${sourceName}`, | \n|  | e | \n|  | ); | \n|  | } | \n|  | } | \n|  | } | \n|  | for (const doc of docs) { | \n|  | const sourceName = | \n|  | doc.file_name \\|\\| | \n|  | doc.name \\|\\| | \n|  | `project-doc-${doc.uuid \\|\\| doc.id \\|\\| 'unknown'}`; | \n|  | if ( | \n|  | originalsRecovered.has( | \n|  | sourceName.toLocaleLowerCase() | \n|  | ) | \n|  | ) { | \n|  | continue; | \n|  | } | \n|  | const content = | \n|  | typeof doc.content === 'string' | \n|  | ? doc.content | \n|  | : JSON.stringify( | \n|  | doc.content ?? '', | \n|  | null, | \n|  | 2 | \n|  | ); | \n|  | if (!content) continue; | \n|  | const textLike = | \n|  | /\\.(md\\|markdown\\|txt\\|csv\\|json\\|html\\|xml\\|yaml\\|yml)$/i | \n|  | .test(sourceName); | \n|  | const target = | \n|  | textLike | \n|  | ? sourceName | \n|  | : `${sourceName}.extracted.md`; | \n|  | await writeText( | \n|  | filesDir, | \n|  | unique(target), | \n|  | content | \n|  | ); | \n|  | } | \n|  | } | \n|  | // ============================================================ | \n|  | // Per-chat inputs / attachments / outputs | \n|  | // ============================================================ | \n|  | async function exportChatFiles( | \n|  | orgId, | \n|  | chatId, | \n|  | allMessages, | \n|  | activeMessages, | \n|  | attachmentsDir, | \n|  | outputsDir | \n|  | ) { | \n|  | const base = | \n|  | `${API}/organizations/${orgId}/conversations/${chatId}/wiggle`; | \n|  | const attachmentUnique = makeUniqueNamer(); | \n|  | const outputUnique = makeUniqueNamer(); | \n|  | const rawUploads = new Set(); | \n|  | const rawOutputs = new Set(); | \n|  | try { | \n|  | const listing = await fetchJSON( | \n|  | `${base}/list-files?prefix=`, | \n|  | true | \n|  | ); | \n|  | const metadata = | \n|  | asArray(listing?.files_metadata); | \n|  | let paths = metadata | \n|  | .map(x => x?.path) | \n|  | .filter(Boolean); | \n|  | if (!paths.length) { | \n|  | paths = asArray( | \n|  | listing?.files \\|\\| | \n|  | listing | \n|  | ); | \n|  | } | \n|  | for (const path of paths) { | \n|  | if (typeof path !== 'string') continue; | \n|  | const isUpload = | \n|  | path.startsWith( | \n|  | '/mnt/user-data/uploads/' | \n|  | ); | \n|  | const isOutput = | \n|  | path.startsWith( | \n|  | '/mnt/user-data/outputs/' | \n|  | ); | \n|  | if (!isUpload && !isOutput) continue; | \n|  | const name = | \n|  | path.split('/').pop() \\|\\| | \n|  | 'file'; | \n|  | try { | \n|  | const blob = await fetchBlob( | \n|  | `${base}/download-file?path=${encodeURIComponent(path)}`, | \n|  | true | \n|  | ); | \n|  | if (!blob) continue; | \n|  | if (isUpload) { | \n|  | await writeBlob( | \n|  | attachmentsDir, | \n|  | attachmentUnique(name), | \n|  | blob | \n|  | ); | \n|  | rawUploads.add( | \n|  | name.toLocaleLowerCase() | \n|  | ); | \n|  | } else { | \n|  | await writeBlob( | \n|  | outputsDir, | \n|  | outputUnique(name), | \n|  | blob | \n|  | ); | \n|  | rawOutputs.add( | \n|  | name.toLocaleLowerCase() | \n|  | ); | \n|  | } | \n|  | } catch (e) { | \n|  | console.warn( | \n|  | `Could not download workspace file: ${path}`, | \n|  | e | \n|  | ); | \n|  | } | \n|  | } | \n|  | } catch (e) { | \n|  | console.warn( | \n|  | `Could not list conversation workspace files for ${chatId}`, | \n|  | e | \n|  | ); | \n|  | } | \n|  | let pasteCounter = 0; | \n|  | for (const m of allMessages) { | \n|  | for (const obj of [ | \n|  | ...asArray(m?.attachments), | \n|  | ...asArray(m?.files), | \n|  | ]) { | \n|  | const text = | \n|  | typeof obj?.extracted_content === 'string' | \n|  | ? obj.extracted_content | \n|  | : ''; | \n|  | if (!text) continue; | \n|  | const sourceName = | \n|  | obj.file_name \\|\\| | \n|  | obj.name \\|\\| | \n|  | `Pasted text ${++pasteCounter}.txt`; | \n|  | if ( | \n|  | rawUploads.has( | \n|  | sourceName.toLocaleLowerCase() | \n|  | ) | \n|  | ) { | \n|  | continue; | \n|  | } | \n|  | const saveName = | \n|  | /\\.[A-Za-z0-9]{1,10}$/.test(sourceName) | \n|  | ? sourceName | \n|  | : `${sourceName}.txt`; | \n|  | await writeText( | \n|  | attachmentsDir, | \n|  | attachmentUnique(saveName), | \n|  | text | \n|  | ); | \n|  | rawUploads.add( | \n|  | sourceName.toLocaleLowerCase() | \n|  | ); | \n|  | } | \n|  | } | \n|  | const recovered = | \n|  | new Set(rawUploads); | \n|  | for (const m of allMessages) { | \n|  | for (const obj of [ | \n|  | ...asArray(m?.files), | \n|  | ...asArray(m?.attachments), | \n|  | ]) { | \n|  | const sourceName = | \n|  | obj?.file_name \\|\\| | \n|  | obj?.name \\|\\| | \n|  | ''; | \n|  | const key = | \n|  | sourceName.toLocaleLowerCase(); | \n|  | if ( | \n|  | key && | \n|  | recovered.has(key) | \n|  | ) { | \n|  | continue; | \n|  | } | \n|  | const candidates = [ | \n|  | { | \n|  | url: obj?.download_url, | \n|  | label: '', | \n|  | }, | \n|  | { | \n|  | url: obj?.original_url, | \n|  | label: '', | \n|  | }, | \n|  | { | \n|  | url: obj?.original_asset?.url, | \n|  | label: '', | \n|  | }, | \n|  | { | \n|  | url: obj?.document_asset?.url, | \n|  | label: ' [Claude representation]', | \n|  | }, | \n|  | { | \n|  | url: obj?.preview_asset?.url, | \n|  | label: ' [Claude preview]', | \n|  | }, | \n|  | { | \n|  | url: obj?.preview_url, | \n|  | label: ' [Claude preview]', | \n|  | }, | \n|  | { | \n|  | url: obj?.thumbnail_asset?.url, | \n|  | label: ' [Claude thumbnail]', | \n|  | }, | \n|  | { | \n|  | url: obj?.thumbnail_url, | \n|  | label: ' [Claude thumbnail]', | \n|  | }, | \n|  | ].filter(x => x.url); | \n|  | if (!candidates.length) continue; | \n|  | for (const candidate of candidates) { | \n|  | try { | \n|  | const blob = await fetchBlob( | \n|  | absUrl(candidate.url), | \n|  | true | \n|  | ); | \n|  | if (!blob) continue; | \n|  | let name = | \n|  | sourceName \\|\\| | \n|  | `attachment-${obj?.file_uuid \\|\\| obj?.id \\|\\| 'unknown'}`; | \n|  | if (candidate.label) { | \n|  | const dot = | \n|  | name.lastIndexOf('.'); | \n|  | name = | \n|  | dot > 0 | \n|  | ? `${name.slice(0, dot)}${candidate.label}${name.slice(dot)}` | \n|  | : `${name}${candidate.label}`; | \n|  | } | \n|  | if ( | \n|  | !/\\.[A-Za-z0-9]{1,10}$/.test(name) | \n|  | ) { | \n|  | name += | \n|  | extensionFromMime(blob.type); | \n|  | } | \n|  | await writeBlob( | \n|  | attachmentsDir, | \n|  | attachmentUnique(name), | \n|  | blob | \n|  | ); | \n|  | if (key) { | \n|  | recovered.add(key); | \n|  | } | \n|  | break; | \n|  | } catch (e) { | \n|  | console.warn( | \n|  | `Could not recover message attachment: ${sourceName \\|\\| '(unnamed)'}`, | \n|  | e | \n|  | ); | \n|  | } | \n|  | } | \n|  | } | \n|  | } | \n|  | await saveReconstructedArtifacts( | \n|  | activeMessages, | \n|  | outputsDir, | \n|  | outputUnique | \n|  | ); | \n|  | for (const m of activeMessages) { | \n|  | for (const block of asArray(m?.content)) { | \n|  | if ( | \n|  | block?.type !== 'tool_use' \\|\\| | \n|  | block?.name !== 'create_file' \\|\\| | \n|  | !block.input | \n|  | ) { | \n|  | continue; | \n|  | } | \n|  | const input = block.input; | \n|  | const path = firstString( | \n|  | input.path, | \n|  | input.file_path, | \n|  | input.filename, | \n|  | input.name | \n|  | ); | \n|  | const content = firstString( | \n|  | input.content, | \n|  | input.text, | \n|  | input.file_content | \n|  | ); | \n|  | if (!path \\|\\| !content) continue; | \n|  | const filename = | \n|  | path.split('/').pop() \\|\\| | \n|  | 'generated-file.txt'; | \n|  | if ( | \n|  | rawOutputs.has( | \n|  | filename.toLocaleLowerCase() | \n|  | ) | \n|  | ) { | \n|  | continue; | \n|  | } | \n|  | await writeText( | \n|  | outputsDir, | \n|  | outputUnique(filename), | \n|  | content | \n|  | ); | \n|  | rawOutputs.add( | \n|  | filename.toLocaleLowerCase() | \n|  | ); | \n|  | } | \n|  | } | \n|  | } | \n|  | // ============================================================ | \n|  | // UI | \n|  | // ============================================================ | \n|  | let statusNode = null; | \n|  | function setStatus(text) { | \n|  | console.log( | \n|  | 'Claude exporter:', | \n|  | text | \n|  | ); | \n|  | if (statusNode) { | \n|  | statusNode.textContent = | \n|  | text; | \n|  | } | \n|  | } | \n|  | function buildButton() { | \n|  | document | \n|  | .getElementById( | \n|  | 'claude-simple-exporter' | \n|  | ) | \n|  | ?.remove(); | \n|  | const wrap = | \n|  | document.createElement('div'); | \n|  | wrap.id = | \n|  | 'claude-simple-exporter'; | \n|  | Object.assign( | \n|  | wrap.style, | \n|  | { | \n|  | position: 'fixed', | \n|  | right: '18px', | \n|  | bottom: '18px', | \n|  | zIndex: '2147483647', | \n|  | display: 'flex', | \n|  | flexDirection: 'column', | \n|  | alignItems: 'flex-end', | \n|  | gap: '8px', | \n|  | fontFamily: | \n|  | 'system-ui, sans-serif', | \n|  | } | \n|  | ); | \n|  | statusNode = | \n|  | document.createElement('div'); | \n|  | Object.assign( | \n|  | statusNode.style, | \n|  | { | \n|  | display: 'none', | \n|  | maxWidth: '460px', | \n|  | padding: '8px 10px', | \n|  | borderRadius: '8px', | \n|  | background: | \n|  | 'rgba(20,20,20,.92)', | \n|  | color: '#fff', | \n|  | fontSize: '12px', | \n|  | } | \n|  | ); | \n|  | const btn = | \n|  | document.createElement('button'); | \n|  | btn.textContent = | \n|  | 'Export Claude Project'; | \n|  | Object.assign( | \n|  | btn.style, | \n|  | { | \n|  | padding: '10px 14px', | \n|  | borderRadius: '10px', | \n|  | cursor: 'pointer', | \n|  | border: | \n|  | '1px solid rgba(127,127,127,.35)', | \n|  | background: '#f0eee6', | \n|  | color: '#222', | \n|  | fontWeight: '650', | \n|  | } | \n|  | ); | \n|  | wrap.append( | \n|  | statusNode, | \n|  | btn | \n|  | ); | \n|  | document.body.appendChild( | \n|  | wrap | \n|  | ); | \n|  | return btn; | \n|  | } | \n|  | // ============================================================ | \n|  | // Run | \n|  | // ============================================================ | \n|  | const projects = | \n|  | await discoverProjects(); | \n|  | const btn = | \n|  | buildButton(); | \n|  | console.log( | \n|  | `Claude exporter ready. Found ${projects.length} project(s).` | \n|  | ); | \n|  | btn.addEventListener( | \n|  | 'click', | \n|  | async () => { | \n|  | try { | \n|  | const selected = | \n|  | chooseProject(projects); | \n|  | if (!selected) return; | \n|  | if ( | \n|  | !window.showDirectoryPicker | \n|  | ) { | \n|  | throw new Error( | \n|  | 'Use Chrome or Edge: this browser cannot choose an export folder.' | \n|  | ); | \n|  | } | \n|  | const destination = | \n|  | await window.showDirectoryPicker({ | \n|  | mode: 'readwrite', | \n|  | }); | \n|  | btn.disabled = true; | \n|  | statusNode.style.display = | \n|  | 'block'; | \n|  | const orgId = | \n|  | selected.org.uuid; | \n|  | const projectId = | \n|  | selected.project.uuid; | \n|  | const projectName = | \n|  | selected.project.name \\|\\| | \n|  | 'Untitled Claude Project'; | \n|  | const projectRoot = | \n|  | await getDir( | \n|  | destination, | \n|  | projectName | \n|  | ); | \n|  | const filesDir = | \n|  | await getDir( | \n|  | projectRoot, | \n|  | 'Files' | \n|  | ); | \n|  | const chatsRoot = | \n|  | await getDir( | \n|  | projectRoot, | \n|  | 'Chats' | \n|  | ); | \n|  | setStatus( | \n|  | 'Exporting project instructions, memory, description and files…' | \n|  | ); | \n|  | await exportProjectContextAndFiles( | \n|  | orgId, | \n|  | projectId, | \n|  | projectRoot, | \n|  | filesDir | \n|  | ); | \n|  | const conversations = | \n|  | asArray( | \n|  | await fetchJSON( | \n|  | `${API}/organizations/${orgId}/projects/${projectId}/conversations` | \n|  | ) | \n|  | ); | \n|  | const uniqueChatFolder = | \n|  | makeUniqueNamer(); | \n|  | for ( | \n|  | let i = 0; | \n|  | i < conversations.length; | \n|  | i++ | \n|  | ) { | \n|  | const c = | \n|  | conversations[i]; | \n|  | const chatId = | \n|  | c.uuid \\|\\| | \n|  | c.id; | \n|  | const title = | \n|  | c.name \\|\\| | \n|  | c.title \\|\\| | \n|  | 'Untitled chat'; | \n|  | setStatus( | \n|  | `Exporting ${i + 1}/${conversations.length}: ${title}` | \n|  | ); | \n|  | const raw = | \n|  | await fetchJSON( | \n|  | `${API}/organizations/${orgId}/chat_conversations/${chatId}` + | \n|  | `?tree=True&rendering_mode=messages&render_all_tools=true&consistency=eventual` | \n|  | ); | \n|  | const allMessages = | \n|  | asArray( | \n|  | raw.chat_messages | \n|  | ); | \n|  | const activeMessages = | \n|  | activeBranch( | \n|  | allMessages, | \n|  | raw.current_leaf_message_uuid | \n|  | ); | \n|  | const chatFolderName = | \n|  | uniqueChatFolder(title); | \n|  | const chatDir = | \n|  | await getDir( | \n|  | chatsRoot, | \n|  | chatFolderName | \n|  | ); | \n|  | const attachmentsDir = | \n|  | await getDir( | \n|  | chatDir, | \n|  | 'Attachments' | \n|  | ); | \n|  | const outputsDir = | \n|  | await getDir( | \n|  | chatDir, | \n|  | 'Outputs' | \n|  | ); | \n|  | await writeText( | \n|  | chatDir, | \n|  | `${safeName(title)}.md`, | \n|  | renderChatMarkdown( | \n|  | { ...c, ...raw }, | \n|  | activeMessages, | \n|  | projectName | \n|  | ) | \n|  | ); | \n|  | await exportChatFiles( | \n|  | orgId, | \n|  | chatId, | \n|  | allMessages, | \n|  | activeMessages, | \n|  | attachmentsDir, | \n|  | outputsDir | \n|  | ); | \n|  | await sleep(100); | \n|  | } | \n|  | setStatus( | \n|  | `Done — ${conversations.length} chats exported.` | \n|  | ); | \n|  | btn.textContent = | \n|  | 'Export complete'; | \n|  | } catch (e) { | \n|  | console.error( | \n|  | 'Claude exporter failed:', | \n|  | e | \n|  | ); | \n|  | statusNode.style.display = | \n|  | 'block'; | \n|  | statusNode.textContent = | \n|  | `Export failed: ${e.message \\|\\| e}`; | \n|  | btn.disabled = false; | \n|  | } | \n|  | } | \n|  | ); | \n|  | })(); |", "url": "https://wpnews.pro/news/claude-project-exporter-markdown-attachments-outputs", "canonical_source": "https://gist.github.com/Gregory-Schwing-MD-PhD/44f17acd65c7ec49d4a46d0f01ce73a5", "published_at": "2026-10-05 01:55:02+00:00", "updated_at": "2026-10-05 02:12:16.880917+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "large-language-models", "ai-products"], "entities": ["Claude", "Anthropic"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/claude-project-exporter-markdown-attachments-outputs", "markdown": "https://wpnews.pro/news/claude-project-exporter-markdown-attachments-outputs.md", "text": "https://wpnews.pro/news/claude-project-exporter-markdown-attachments-outputs.txt", "jsonld": "https://wpnews.pro/news/claude-project-exporter-markdown-attachments-outputs.jsonld"}}