#!/usr/bin/env node "use strict"; const fs = require("node:fs"); const path = require("node:path"); function parseArgs(argv) { const args = { fixture: "", out: "", dryRun: false, manifest: "", conflictReport: "", rollback: "", }; 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 === "--dry-run") { args.dryRun = true; continue; } if (arg === "--manifest") { args.manifest = argv[++index] || ""; continue; } if (arg === "--conflict-report") { args.conflictReport = argv[++index] || ""; continue; } if (arg === "--rollback") { args.rollback = argv[++index] || ""; continue; } if (arg === "--help" || arg === "-h") { console.log( [ "用法:node scripts/export-convex-workspace-to-local.js --fixture --out [--dry-run] [--manifest ] [--conflict-report ]", "回滚:node scripts/export-convex-workspace-to-local.js --rollback ", ].join("\n"), ); process.exit(0); } throw new Error(`未知参数:${arg}`); } if (args.rollback) return args; 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 writeJson(filePath, value) { writeUtf8(filePath, `${JSON.stringify(value, null, 2)}\n`); } function sanitizeName(name) { return String(name || "untitled") .trim() .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_") .replace(/\s+/g, " ") .replace(/\.+$/g, "") .trim() || "untitled"; } 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 fileUriForPath(localPath) { return `file://${path.resolve(localPath)}`; } function nowId() { return new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14); } function normalizeRelativePath(relativePath) { const normalized = String(relativePath || "").replace(/\\/g, "/").replace(/^\/+/, ""); if (!normalized || normalized.split("/").some((part) => part === "..")) { throw new Error(`非法迁移相对路径:${relativePath}`); } return normalized; } function pushFileOperation(operations, relativePath, content, contentEncoding = "utf8") { operations.push({ action: "create", relativePath: normalizeRelativePath(relativePath), content, contentEncoding, }); } function buildMigrationPlan(fixture, out) { const documents = Array.isArray(fixture.documents) ? fixture.documents : []; const mediaAssets = Array.isArray(fixture.mediaAssets) ? fixture.mediaAssets : []; const aiSessions = Array.isArray(fixture.aiSessions) ? fixture.aiSessions : []; 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, }; }); const operations = []; const indexedDocuments = []; const indexedResources = []; 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"); pushFileOperation(operations, markdownPath, `${frontmatter}${markdownBody}`); indexedDocuments.push({ documentId: `local-mdid:${doc.id}`, title: String(doc.title || doc.id || "untitled"), path: markdownPath, rawText: markdownBody, tags: [], backlinks: [], resourceRefs: [], updatedAt: Date.now(), }); operations.push({ action: "ensureDir", relativePath: normalizeRelativePath(assetRoot), }); }); mediaAssets.forEach((asset) => { const relativePath = assetPathById.get(String(asset.id)); if (!relativePath) return; const content = decodeAssetContent(asset).toString("base64"); pushFileOperation(operations, relativePath, content, "base64"); indexedResources.push({ resourceId: String(asset.id), resourceType: "media", title: sanitizeName(asset.fileName || asset.name || asset.id), path: relativePath, updatedAt: Date.now(), }); }); 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 relativePath = shareId || visibility === "shared" ? path.posix.join("ai-sessions", "shared", sanitizeName(shareId || "share"), `${sessionId}.jsonl`) : path.posix.join("ai-sessions", "private", `${sessionId}.jsonl`); const events = Array.isArray(session.events) ? session.events : []; const lines = events.map((event) => JSON.stringify(event)).join("\n"); pushFileOperation(operations, relativePath, lines ? `${lines}\n` : ""); }); const workspaceId = fixture.workspace?.id || "exported-workspace"; pushFileOperation(operations, ".mnote/page-ids.json", `${JSON.stringify({ version: 1, pages: pageIds }, null, 2)}\n`); pushFileOperation(operations, ".mnote/page-options.json", `${JSON.stringify({ version: 1, pages: pageOptions }, null, 2)}\n`); pushFileOperation(operations, ".mnote/resource-index.json", `${JSON.stringify(resourceIndex, null, 2)}\n`); pushFileOperation( operations, ".mnote/workspace.json", `${JSON.stringify( { workspaceId, ownerId: fixture.workspace?.ownerId || fixture.workspace?.owner_id || "unknown", createdAt: new Date().toISOString(), capabilities: ["local_files", "ai_sessions", "exported_from_convex"], }, null, 2, )}\n`, ); pushFileOperation( operations, ".mnote/index/search-index.json", `${JSON.stringify( { version: 1, builtAt: Date.now(), rootUri: fileUriForPath(out), workspaceId, documents: indexedDocuments, resources: indexedResources, }, null, 2, )}\n`, ); return { workspaceId, operations, documentCount: documents.length, resourceCount: mediaAssets.length, aiSessionCount: aiSessions.length, }; } function detectConflicts(out, operations) { return operations .filter((operation) => operation.action === "create") .filter((operation) => fs.existsSync(path.join(out, operation.relativePath))) .map((operation) => ({ code: "target_exists", relativePath: operation.relativePath, targetPath: path.join(out, operation.relativePath), message: "目标文件已存在,迁移不会覆盖", })); } function manifestForPlan(out, plan, dryRun, conflicts = []) { const migrationId = `convex-export-${nowId()}`; return { version: 1, migrationId, source: "convex_fixture", dryRun, out: path.resolve(out), createdAt: new Date().toISOString(), backupDir: path.join(path.resolve(out), ".mnote", "migration-backups", migrationId), workspaceId: plan.workspaceId, documentCount: plan.documentCount, resourceCount: plan.resourceCount, aiSessionCount: plan.aiSessionCount, operations: plan.operations.map((operation) => ({ action: operation.action, relativePath: operation.relativePath, contentEncoding: operation.contentEncoding || null, })), createdFiles: [], backupFiles: [], conflicts, indexRefresh: { status: dryRun ? "planned" : "pending", path: ".mnote/index/search-index.json", }, }; } function writeConflictReport(filePath, out, conflicts) { if (!filePath) return; writeJson(filePath, { version: 1, code: conflicts.length ? "migration_conflict" : "ok", out: path.resolve(out), generatedAt: new Date().toISOString(), conflicts, }); } function applyPlan(out, plan, manifest) { ensureDir(out); ensureDir(manifest.backupDir); for (const operation of plan.operations) { const target = path.join(out, operation.relativePath); if (operation.action === "ensureDir") { ensureDir(target); continue; } if (operation.action !== "create") continue; ensureDir(path.dirname(target)); const content = operation.contentEncoding === "base64" ? Buffer.from(operation.content, "base64") : operation.content; fs.writeFileSync(target, content, operation.contentEncoding === "base64" ? undefined : "utf8"); manifest.createdFiles.push(operation.relativePath); } manifest.indexRefresh.status = "written"; } function rollbackManifest(manifestPath) { const manifest = loadFixture(manifestPath); const out = manifest.out; if (!out) throw new Error("rollback manifest 缺少 out"); const createdFiles = Array.isArray(manifest.createdFiles) ? manifest.createdFiles : []; createdFiles .slice() .reverse() .forEach((relativePath) => { const target = path.join(out, normalizeRelativePath(relativePath)); if (fs.existsSync(target)) fs.rmSync(target, { force: true }); }); const backupFiles = Array.isArray(manifest.backupFiles) ? manifest.backupFiles : []; backupFiles.forEach((backup) => { if (!backup || !backup.relativePath || !backup.backupPath) return; const target = path.join(out, normalizeRelativePath(backup.relativePath)); ensureDir(path.dirname(target)); fs.copyFileSync(backup.backupPath, target); }); console.log(JSON.stringify({ ok: true, rollback: manifestPath, removed: createdFiles.length }, null, 2)); } function main() { const args = parseArgs(process.argv.slice(2)); if (args.rollback) { rollbackManifest(args.rollback); return; } const fixture = loadFixture(args.fixture); const plan = buildMigrationPlan(fixture, args.out); const conflicts = detectConflicts(args.out, plan.operations); const manifest = manifestForPlan(args.out, plan, args.dryRun, conflicts); const manifestPath = args.manifest || path.join(args.out, ".mnote", "migration-manifest.json"); writeConflictReport(args.conflictReport, args.out, conflicts); if (args.dryRun) { manifest.indexRefresh.status = "planned"; writeJson(manifestPath, manifest); console.log(JSON.stringify({ ok: true, dryRun: true, out: args.out, conflicts: conflicts.length }, null, 2)); return; } if (conflicts.length) { writeJson(manifestPath, manifest); console.error(JSON.stringify({ ok: false, code: "migration_conflict", conflicts }, null, 2)); process.exit(2); } applyPlan(args.out, plan, manifest); writeJson(manifestPath, manifest); if (path.resolve(manifestPath) !== path.resolve(path.join(args.out, ".mnote", "migration-manifest.json"))) { writeJson(path.join(args.out, ".mnote", "migration-manifest.json"), manifest); } console.log(JSON.stringify({ ok: true, out: args.out, manifest: manifestPath }, null, 2)); } if (require.main === module) { try { main(); } catch (error) { console.error(error instanceof Error ? error.stack || error.message : String(error)); process.exit(1); } }