#!/usr/bin/env node "use strict"; const fs = require("node:fs"); const path = require("node:path"); function parseArgs(argv) { const args = { fixture: "", out: "" }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--fixture") { args.fixture = argv[++index] || ""; continue; } if (arg === "--out") { args.out = argv[++index] || ""; continue; } if (arg === "--help" || arg === "-h") { console.log("用法:node scripts/export-convex-workspace-to-local.js --fixture --out "); process.exit(0); } throw new Error(`未知参数:${arg}`); } if (!args.fixture) throw new Error("缺少 --fixture"); if (!args.out) throw new Error("缺少 --out"); return args; } function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); } function writeUtf8(filePath, content) { ensureDir(path.dirname(filePath)); fs.writeFileSync(filePath, content, "utf8"); } function sanitizeName(name) { return String(name || "untitled") .trim() .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_") .replace(/\s+/g, " ") .replace(/\.+$/g, "") .trim() || "untitled"; } function toMarkdownFilename(title) { return `${sanitizeName(title)}.md`; } function loadFixture(filePath) { return JSON.parse(fs.readFileSync(filePath, "utf8")); } function buildDocumentIndex(documents) { const byId = new Map(); documents.forEach((doc) => { byId.set(String(doc.id), doc); }); return byId; } function buildMarkdownPath(doc, documentsById) { const segments = []; let current = doc; while (current) { segments.unshift(sanitizeName(current.title || current.id || "untitled")); const parentId = current.parent_id || current.parentId || null; current = parentId ? documentsById.get(String(parentId)) : null; } return path.posix.join("pages", ...segments) + ".md"; } function buildAssetPath(markdownPath, fileName) { const base = markdownPath.replace(/\.md$/i, ".assets"); return path.posix.join(base, sanitizeName(fileName)); } function findDocumentForAsset(asset, documents) { const directId = String(asset.document_id || asset.documentId || asset.page_id || asset.pageId || "").trim(); if (directId) return directId; const assetId = String(asset.id || "").trim(); if (!assetId) return ""; const doc = documents.find((item) => { const content = typeof item.content === "string" ? item.content : ""; const rawText = typeof item.raw_text === "string" ? item.raw_text : ""; const editorText = typeof item.editor_document === "string" ? item.editor_document : ""; const tiptapText = typeof item.tiptap_document === "string" ? item.tiptap_document : ""; return [content, rawText, editorText, tiptapText].some((text) => text.includes(assetId)); }); return doc ? String(doc.id) : ""; } function decodeAssetContent(asset) { if (typeof asset.contentBase64 === "string" && asset.contentBase64) { return Buffer.from(asset.contentBase64, "base64"); } if (typeof asset.content === "string") { return Buffer.from(asset.content, "utf8"); } return Buffer.from("", "utf8"); } function rewriteAssetUrls(markdown, assetPathById) { return String(markdown || "").replace(/\/api\/media\/sign\?assetId=([^)\s"'&#]+)/g, (_, assetId) => { return assetPathById.get(String(assetId)) || _; }); } function serializePageOptions(doc) { return { wideLayout: doc.wide_layout ?? null, useSmallText: doc.use_small_text ?? null, showHeadingNumbers: doc.show_heading_numbers ?? null, showToc: doc.show_toc ?? null, showStructure: doc.show_structure ?? null, protectEditing: doc.protect_editing ?? null, showWordCount: doc.show_word_count ?? null, collapseBacklinks: doc.collapse_backlinks ?? null, pageFont: doc.page_font ?? null, layoutDensity: doc.layout_density ?? null, hideChildPages: doc.hide_child_pages ?? null, showBlockRefCount: doc.show_block_ref_count ?? null, embedDefaultBlockId: doc.embed_default_block_id ?? null, }; } function main() { const { fixture: fixturePath, out } = parseArgs(process.argv.slice(2)); const fixture = loadFixture(fixturePath); const documents = Array.isArray(fixture.documents) ? fixture.documents : []; const mediaAssets = Array.isArray(fixture.mediaAssets) ? fixture.mediaAssets : []; const aiSessions = Array.isArray(fixture.aiSessions) ? fixture.aiSessions : []; fs.rmSync(out, { recursive: true, force: true }); ensureDir(out); const docsById = buildDocumentIndex(documents); const markdownPathByDocId = new Map(); const assetPathById = new Map(); const pageIds = {}; const pageOptions = {}; const resourceIndex = { version: 1, assets: {} }; documents.forEach((doc) => { const markdownPath = buildMarkdownPath(doc, docsById); markdownPathByDocId.set(String(doc.id), markdownPath); pageIds[markdownPath] = `local-mdid:${doc.id}`; pageOptions[`local-mdid:${doc.id}`] = serializePageOptions(doc); }); mediaAssets.forEach((asset) => { const ownerDocId = findDocumentForAsset(asset, documents) || String(documents[0]?.id || ""); const ownerMarkdownPath = markdownPathByDocId.get(ownerDocId) || "pages/attachments.md"; const relativePath = buildAssetPath(ownerMarkdownPath, asset.fileName || asset.name || asset.id); assetPathById.set(String(asset.id), relativePath); resourceIndex.assets[String(asset.id)] = { fileName: sanitizeName(asset.fileName || asset.name || asset.id), relativePath, documentId: ownerDocId || null, }; }); documents.forEach((doc) => { const markdownPath = markdownPathByDocId.get(String(doc.id)); const assetRoot = markdownPath.replace(/\.md$/i, ".assets"); const markdownDir = path.posix.dirname(markdownPath); const markdownBody = rewriteAssetUrls( typeof doc.content === "string" ? doc.content : String(doc.raw_text || doc.editor_document || doc.tiptap_document || ""), new Map(Array.from(assetPathById.entries()).map(([assetId, absolutePath]) => [ assetId, path.posix.relative(markdownDir, absolutePath), ])), ); const frontmatter = [ "---", `title: ${String(doc.title || doc.id || "untitled")}`, `mnote_id: ${String(doc.id)}`, "---", "", ].join("\n"); writeUtf8(path.join(out, markdownPath), `${frontmatter}${markdownBody}`); ensureDir(path.join(out, assetRoot)); }); mediaAssets.forEach((asset) => { const relativePath = assetPathById.get(String(asset.id)); if (!relativePath) return; const target = path.join(out, relativePath); writeUtf8(target, decodeAssetContent(asset)); }); aiSessions.forEach((session) => { const sessionId = sanitizeName(session.sessionId || session.id || "session"); const shareId = String(session.shareId || session.share_id || "").trim(); const visibility = String(session.visibility || "").trim(); const dir = shareId || visibility === "shared" ? path.join(out, "ai-sessions", "shared", sanitizeName(shareId || "share")) : path.join(out, "ai-sessions", "private"); ensureDir(dir); const filePath = path.join(dir, `${sessionId}.jsonl`); const events = Array.isArray(session.events) ? session.events : []; const lines = events.map((event) => JSON.stringify(event)).join("\n"); writeUtf8(filePath, lines ? `${lines}\n` : ""); }); writeUtf8(path.join(out, ".mnote", "page-ids.json"), JSON.stringify({ version: 1, pages: pageIds }, null, 2)); writeUtf8(path.join(out, ".mnote", "page-options.json"), JSON.stringify({ version: 1, pages: pageOptions }, null, 2)); writeUtf8(path.join(out, ".mnote", "resource-index.json"), JSON.stringify(resourceIndex, null, 2)); writeUtf8( path.join(out, ".mnote", "workspace.json"), JSON.stringify( { workspaceId: fixture.workspace?.id || "exported-workspace", ownerId: fixture.workspace?.ownerId || fixture.workspace?.owner_id || "unknown", createdAt: new Date().toISOString(), capabilities: ["local_files", "ai_sessions", "exported_from_convex"], }, null, 2, ), ); console.log(JSON.stringify({ ok: true, out }, null, 2)); } if (require.main === module) { try { main(); } catch (error) { console.error(error instanceof Error ? error.stack || error.message : String(error)); process.exit(1); } }