"use strict"; const crypto = require("node:crypto"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { spawnSync } = require("node:child_process"); const ASSET_DIRS = ["image", "file", "video", "audio", "resources"]; const SYNTHETIC_ORPHAN_REL = "__mnote_synthetic__/unfiled.md"; function toPosix(value) { return String(value || "").replace(/\\/g, "/"); } function stripOuterSlashes(value) { return toPosix(value).replace(/^\/+/, "").replace(/\/+$/, ""); } function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function readUtf8(filePath) { return fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""); } function writeUtf8(filePath, content) { ensureDir(path.dirname(filePath)); fs.writeFileSync(filePath, content, "utf8"); } function sha1Short(value) { return crypto.createHash("sha1").update(String(value)).digest("hex").slice(0, 8); } function safeDecodeUri(value) { try { return decodeURIComponent(value); } catch { return value; } } function unescapeMarkdownDestination(value) { return String(value || "").replace(/\\([!"#$%&'()*+,./:;<=>?@[\\\]^_`{|}~-])/g, "$1"); } function trimAsciiStart(value) { return String(value || "").replace(/^[ \t\r\n]+/, ""); } function isAsciiWhitespace(value) { return value === " " || value === "\t" || value === "\r" || value === "\n"; } function isExternalHref(href) { return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("#") || href.startsWith("//"); } function normalizeSourceHref(baseRel, href) { const trimmed = String(href || "").trim(); if (!trimmed || isExternalHref(trimmed)) return null; const decoded = unescapeMarkdownDestination(safeDecodeUri(trimmed)); const baseDir = path.posix.dirname(toPosix(baseRel)); const joined = baseDir === "." ? decoded : path.posix.join(baseDir, decoded); const normalized = path.posix.normalize(joined); if (!normalized || normalized === ".") return null; return stripOuterSlashes(normalized); } function findClosingSquare(text, start) { let escaped = false; let depth = 0; for (let i = start; i < text.length; i += 1) { const ch = text[i]; if (escaped) { escaped = false; continue; } if (ch === "\\") { escaped = true; continue; } if (ch === "[") depth += 1; if (ch === "]") { depth -= 1; if (depth === 0) return i; } } return -1; } function findClosingParen(text, start) { let escaped = false; let inAngle = false; let quote = ""; let nested = 0; for (let i = start; i < text.length; i += 1) { const ch = text[i]; if (escaped) { escaped = false; continue; } if (ch === "\\") { escaped = true; continue; } if (quote) { if (ch === quote) quote = ""; continue; } if (inAngle) { if (ch === ">") inAngle = false; continue; } if (ch === "<") { inAngle = true; continue; } if (ch === "\"" || ch === "'") { quote = ch; continue; } if (ch === "(") { nested += 1; continue; } if (ch === ")") { if (nested > 0) { nested -= 1; continue; } return i; } } return -1; } function splitDestinationAndTitle(rawInside) { const raw = String(rawInside || "").trim(); if (!raw) return null; if (raw.startsWith("<")) { const end = raw.indexOf(">"); if (end > 0) { return { destination: raw.slice(1, end), suffix: trimAsciiStart(raw.slice(end + 1)), }; } } let escaped = false; let nested = 0; for (let i = 0; i < raw.length; i += 1) { const ch = raw[i]; if (escaped) { escaped = false; continue; } if (ch === "\\") { escaped = true; continue; } if (ch === "(") nested += 1; if (ch === ")" && nested > 0) nested -= 1; if (isAsciiWhitespace(ch) && nested === 0) { const suffix = trimAsciiStart(raw.slice(i)); if (!suffix || (!suffix.startsWith("\"") && !suffix.startsWith("'") && !suffix.startsWith("("))) { continue; } return { destination: raw.slice(0, i), suffix, }; } } return { destination: raw, suffix: "" }; } function walkMarkdownLinks(markdown, visitor) { let output = ""; let cursor = 0; for (let i = 0; i < markdown.length; i += 1) { const isImage = markdown[i] === "!" && markdown[i + 1] === "["; const linkStart = isImage ? i + 1 : i; if (markdown[linkStart] !== "[") continue; const closeSquare = findClosingSquare(markdown, linkStart); if (closeSquare < 0 || markdown[closeSquare + 1] !== "(") continue; const openParen = closeSquare + 1; const closeParen = findClosingParen(markdown, openParen + 1); if (closeParen < 0) continue; const rawInside = markdown.slice(openParen + 1, closeParen); const parsed = splitDestinationAndTitle(rawInside); if (!parsed) continue; const replacement = visitor({ destination: parsed.destination, suffix: parsed.suffix, isImage, rawInside, label: markdown.slice(linkStart + 1, closeSquare), }); if (typeof replacement === "string") { output += markdown.slice(cursor, openParen + 1); output += replacement; cursor = closeParen; } else if (replacement && typeof replacement.destination === "string") { output += markdown.slice(cursor, linkStart + 1); output += typeof replacement.label === "string" ? replacement.label : markdown.slice(linkStart + 1, closeSquare); output += markdown.slice(closeSquare, openParen + 1); output += replacement.destination; cursor = closeParen; } i = closeParen; } if (!output) return markdown; return output + markdown.slice(cursor); } function collectLinks(markdown) { const links = []; walkMarkdownLinks(markdown, (link) => { links.push(link); return null; }); return links; } function parseTitle(markdown, fallbackFileName) { for (const line of markdown.split(/\r?\n/)) { const trimmed = line.trim(); if (trimmed.startsWith("# ")) { const title = trimmed.slice(2).trim(); if (title) return title; } } return path.posix .basename(toPosix(fallbackFileName), path.posix.extname(toPosix(fallbackFileName))) .replace(/_[A-Za-z0-9-]{8,}$/, "") .trim() || "未命名页面"; } function safeSegment(title, oldRel) { const chars = Array.from(String(title || "未命名页面") .replace(/[\/\\\0-\x1F\x7F]/g, "-") .replace(/[<>:"|?*]/g, "-") .replace(/\s+/g, " ") .trim() .replace(/[. ]+$/g, "")); const base = chars.slice(0, 80).join("").trim() || `未命名页面-${sha1Short(oldRel)}`; if (base === "." || base === "..") return `页面-${sha1Short(oldRel)}`; return base; } function uniqueSegment(parentKey, title, oldRel, usedByParent) { const used = usedByParent.get(parentKey) || new Set(); usedByParent.set(parentKey, used); const base = safeSegment(title, oldRel); if (!used.has(base)) { used.add(base); return base; } const withHash = `${base}-${sha1Short(oldRel)}`; if (!used.has(withHash)) { used.add(withHash); return withHash; } let counter = 2; while (used.has(`${withHash}-${counter}`)) counter += 1; const value = `${withHash}-${counter}`; used.add(value); return value; } function listFilesRecursive(root) { const files = []; function walk(current) { for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const fullPath = path.join(current, entry.name); if (entry.isDirectory()) { walk(fullPath); } else if (entry.isFile()) { files.push(toPosix(path.relative(root, fullPath))); } } } if (fs.existsSync(root)) walk(root); files.sort(); return files; } function findExportRoot(sourcePath) { const resolved = path.resolve(sourcePath); if (fs.existsSync(path.join(resolved, "pages")) && fs.statSync(path.join(resolved, "pages")).isDirectory()) { return resolved; } const children = fs .readdirSync(resolved, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => path.join(resolved, entry.name)) .filter((candidate) => fs.existsSync(path.join(candidate, "pages"))); if (children.length === 1) return children[0]; throw new Error(`无法识别 Wolai 导出根目录: ${resolved}`); } function findRootMarkdown(sourceRoot, rootMd) { if (rootMd) { const target = path.join(sourceRoot, rootMd); if (!fs.existsSync(target)) throw new Error(`rootMd 不存在: ${target}`); return stripOuterSlashes(rootMd); } const candidates = fs .readdirSync(sourceRoot, { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md")) .map((entry) => entry.name) .sort((a, b) => { const aPersonal = a.includes("个人空间") ? 0 : 1; const bPersonal = b.includes("个人空间") ? 0 : 1; return aPersonal - bPersonal || a.length - b.length || a.localeCompare(b); }); if (!candidates.length) throw new Error(`导出根目录缺少顶层 Markdown: ${sourceRoot}`); return candidates[0]; } function buildPageModel(sourceRoot, rootRel) { const sourceFiles = new Set(listFilesRecursive(sourceRoot)); const pageRels = listFilesRecursive(path.join(sourceRoot, "pages")) .filter((rel) => rel.toLowerCase().endsWith(".md")) .map((rel) => `pages/${rel}`); const allPageRels = [rootRel, ...pageRels]; const pages = new Map(); const children = new Map(); for (const oldRel of allPageRels) { const absolute = path.join(sourceRoot, oldRel); const markdown = readUtf8(absolute); pages.set(oldRel, { oldRel, title: parseTitle(markdown, oldRel), markdown, sourcePath: absolute, synthetic: false, }); } for (const page of pages.values()) { const childRels = []; for (const link of collectLinks(page.markdown)) { if (link.isImage) continue; const normalized = normalizeSourceHref(page.oldRel, link.destination); if (normalized && pages.has(normalized)) childRels.push(normalized); } children.set(page.oldRel, childRels); } const assignedParent = new Map([[rootRel, null]]); const orderedChildren = new Map(); const queue = [rootRel]; for (let index = 0; index < queue.length; index += 1) { const current = queue[index]; const nextChildren = []; for (const child of children.get(current) || []) { if (child === current || child === rootRel) continue; if (!assignedParent.has(child)) { assignedParent.set(child, current); nextChildren.push(child); queue.push(child); } } orderedChildren.set(current, nextChildren); } const orphans = allPageRels.filter((oldRel) => !assignedParent.has(oldRel)); if (orphans.length) { const syntheticDir = path.posix.dirname(SYNTHETIC_ORPHAN_REL); const orphanMarkdown = [ "# 未归档", "", "以下页面在 Wolai 导出中存在,但不在根页面链接树下。", "", ...orphans.map((oldRel) => { const href = path.posix.relative(syntheticDir, oldRel); return `- [${pages.get(oldRel).title}](${href})`; }), "", ].join("\n"); pages.set(SYNTHETIC_ORPHAN_REL, { oldRel: SYNTHETIC_ORPHAN_REL, title: "未归档", markdown: orphanMarkdown, sourcePath: null, synthetic: true, }); assignedParent.set(SYNTHETIC_ORPHAN_REL, rootRel); orderedChildren.set(rootRel, [...(orderedChildren.get(rootRel) || []), SYNTHETIC_ORPHAN_REL]); orderedChildren.set(SYNTHETIC_ORPHAN_REL, orphans); for (const oldRel of orphans) assignedParent.set(oldRel, SYNTHETIC_ORPHAN_REL); } return { sourceFiles, pages, orderedChildren, assignedParent, rootRel, originalPageCount: allPageRels.length }; } function assignTargetPaths(model, targetRoot) { const usedByParent = new Map(); const targetByOldRel = new Map(); const ordered = []; function assign(oldRel, parentDirRel) { const page = model.pages.get(oldRel); const parentKey = parentDirRel || "."; const segment = uniqueSegment(parentKey, page.title, oldRel, usedByParent); const dirRel = parentDirRel ? path.posix.join(parentDirRel, segment) : segment; const markdownRel = path.posix.join(dirRel, `${segment}.md`); const target = { oldRel, title: page.title, dirRel, markdownRel, markdownPath: path.join(targetRoot, markdownRel), parentOldRel: model.assignedParent.get(oldRel), synthetic: page.synthetic, }; targetByOldRel.set(oldRel, target); ordered.push(oldRel); for (const child of model.orderedChildren.get(oldRel) || []) { assign(child, dirRel); } } assign(model.rootRel, ""); return { targetByOldRel, ordered }; } function copyOrLinkFile(sourcePath, targetPath, copyMode) { ensureDir(path.dirname(targetPath)); if (fs.existsSync(targetPath)) return "existing"; if (copyMode === "link") { try { fs.linkSync(sourcePath, targetPath); return "linked"; } catch { fs.copyFileSync(sourcePath, targetPath); return "copied"; } } fs.copyFileSync(sourcePath, targetPath); return "copied"; } function assetBucketForSourceRel(sourceRel) { const top = stripOuterSlashes(sourceRel).split("/")[0]; if (top === "image" || top === "audio" || top === "video") return top; if (top === "file" || top === "resources") return "file"; return ""; } function sourceAssetRelForNormalized(normalized, sourceFiles) { const candidates = [ stripOuterSlashes(normalized), stripOuterSlashes(String(normalized || "").replace(/^(\.\.\/)+/, "")), ]; for (const candidate of candidates) { if (candidate && sourceFiles.has(candidate) && assetBucketForSourceRel(candidate)) { return candidate; } } return ""; } function encodeLocalIdSegment(value) { const bytes = Buffer.from(String(value || ""), "utf8"); let encoded = ""; for (const byte of bytes) { const ch = String.fromCharCode(byte); if ((byte >= 48 && byte <= 57) || (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || ch === "." || ch === "_" || ch === "-") { encoded += ch; } else { encoded += `~${byte.toString(16).toUpperCase().padStart(2, "0")}`; } } return encoded; } function localMarkdownDocumentId(markdownRel) { return `local-md:${encodeLocalIdSegment(markdownRel)}`; } function uniquePageAssetRel(targetRoot, targetDirRel, bucket, fileName, usedAssetNames) { const assetDirRel = path.posix.join(targetDirRel, ".assets", bucket); const used = usedAssetNames.get(assetDirRel) || new Set(); usedAssetNames.set(assetDirRel, used); const parsed = path.posix.parse(fileName); const baseName = parsed.base || `asset-${sha1Short(fileName)}`; const stem = parsed.name || baseName; const ext = parsed.ext || ""; for (let index = 0; index < 10_000; index += 1) { const candidateName = index === 0 ? baseName : `${stem}-${index}${ext}`; const candidateRel = path.posix.join(assetDirRel, candidateName); if (!used.has(candidateName) && !fs.existsSync(path.join(targetRoot, candidateRel))) { used.add(candidateName); return candidateRel; } } const fallbackName = `${stem}-${sha1Short(`${targetDirRel}/${fileName}`)}${ext}`; used.add(fallbackName); return path.posix.join(assetDirRel, fallbackName); } function createAssetPlacementState(sourceFiles) { return { counts: { copied: 0, linked: 0, existing: 0, total: 0, sourceTotal: 0, unused: 0 }, sourceFiles, placements: new Map(), usedAssetNames: new Map(), usedSourceRels: new Set(), uploadedAssets: {}, sourceAssetRels: [...sourceFiles].filter((rel) => assetBucketForSourceRel(rel)), }; } function placeAssetForPage(sourceRoot, targetRoot, copyMode, sourceRel, target, state) { const bucket = assetBucketForSourceRel(sourceRel); if (!bucket) return null; const placementKey = `${target.markdownRel}\0${sourceRel}`; if (state.placements.has(placementKey)) return state.placements.get(placementKey); const fileName = path.posix.basename(sourceRel) || `asset-${sha1Short(sourceRel)}`; const targetRel = uniquePageAssetRel(targetRoot, target.dirRel, bucket, fileName, state.usedAssetNames); const status = copyOrLinkFile( path.join(sourceRoot, sourceRel), path.join(targetRoot, targetRel), copyMode, ); state.counts[status] += 1; state.counts.total += 1; state.usedSourceRels.add(sourceRel); state.placements.set(placementKey, targetRel); state.uploadedAssets[targetRel] = { documentId: localMarkdownDocumentId(target.markdownRel), relativePath: targetRel, fileName: path.posix.basename(targetRel), createdAtMs: Date.now(), }; return targetRel; } function markdownRelativePath(fromMarkdownRel, toRel) { const fromDir = path.posix.dirname(fromMarkdownRel); const relative = path.posix.relative(fromDir, toRel) || path.posix.basename(toRel); return relative.startsWith(".") ? relative : `./${relative}`.replace(/^\.\.\//, "../"); } function addMissingSample(stats, page, href, normalized) { if (!stats.missingSamples) stats.missingSamples = []; if (stats.missingSamples.length >= 200) return; stats.missingSamples.push({ page: page.oldRel, href, normalized, }); } function shouldRewriteMissingHref(href, normalized) { const decoded = unescapeMarkdownDestination(safeDecodeUri(String(href || "").trim())); if (!decoded || decoded.toUpperCase() === "NULL") return true; if (decoded.startsWith("/")) return true; const localRel = String(normalized || "").replace(/^(\.\.\/)+/, ""); return ASSET_DIRS.some((assetDir) => localRel === assetDir || localRel.startsWith(`${assetDir}/`)); } function missingLinkLabel(href, normalized) { const decoded = unescapeMarkdownDestination(safeDecodeUri(String(href || "").trim())); if (!decoded || decoded.toUpperCase() === "NULL") return "缺失资源"; const basename = path.posix.basename(toPosix(decoded)) || path.posix.basename(toPosix(normalized)); return basename ? `缺失资源: ${basename}` : "缺失资源"; } function rewriteMarkdown(page, target, targetByOldRel, sourceRoot, targetRoot, copyMode, assetState, stats) { return walkMarkdownLinks(page.markdown, (link) => { const normalized = normalizeSourceHref(page.oldRel, link.destination); if (!normalized) return null; let targetRel = null; if (targetByOldRel.has(normalized)) { targetRel = targetByOldRel.get(normalized).markdownRel; } else { const sourceAssetRel = sourceAssetRelForNormalized(normalized, assetState.sourceFiles); if (sourceAssetRel) { targetRel = placeAssetForPage(sourceRoot, targetRoot, copyMode, sourceAssetRel, target, assetState); } } if (!targetRel) { stats.missing += 1; addMissingSample(stats, page, link.destination, normalized); if (!shouldRewriteMissingHref(link.destination, normalized)) return null; stats.rewritten += 1; return { label: missingLinkLabel(link.destination, normalized), destination: `<#wolai-missing-resource>${link.suffix ? ` ${link.suffix}` : ""}`, }; } stats.rewritten += 1; const relative = markdownRelativePath(target.markdownRel, targetRel).replace(/^\.\//, ""); return `<${relative}>${link.suffix ? ` ${link.suffix}` : ""}`; }); } function validateClearTarget(targetRoot, ownerId, allowUnsafeClear) { if (allowUnsafeClear) return; const normalized = path.resolve(targetRoot); const marker = `${path.sep}Mnote_data${path.sep}users${path.sep}${ownerId}${path.sep}workspaces${path.sep}`; if (!normalized.includes(marker)) { throw new Error(`拒绝清空非用户工作区目录: ${normalized}`); } } function clearTarget(targetRoot, ownerId, allowUnsafeClear) { validateClearTarget(targetRoot, ownerId, allowUnsafeClear); ensureDir(targetRoot); for (const entry of fs.readdirSync(targetRoot)) { fs.rmSync(path.join(targetRoot, entry), { recursive: true, force: true }); } } function writeWorkspaceMetadata(targetRoot, ownerId, workspaceId, result, fileOrderParents, uploadedAssets) { const metadataDir = path.join(targetRoot, ".mnote"); const indexDir = path.join(metadataDir, "index"); ensureDir(metadataDir); ensureDir(indexDir); writeUtf8( path.join(metadataDir, "workspace.json"), `${JSON.stringify({ workspaceId, ownerId, createdAt: new Date().toISOString(), capabilities: ["local_files", "tree_commands", "markdown_edit", "asset_upload"], }, null, 2)}\n`, ); writeUtf8( path.join(metadataDir, "file-order.json"), `${JSON.stringify({ version: 1, parents: fileOrderParents }, null, 2)}\n`, ); writeUtf8( path.join(metadataDir, "wolai-import.json"), `${JSON.stringify(result, null, 2)}\n`, ); writeUtf8( path.join(metadataDir, "uploaded-assets.json"), `${JSON.stringify({ version: 1, entries: uploadedAssets }, null, 2)}\n`, ); writeUtf8( path.join(indexDir, "local-index-settings.json"), `${JSON.stringify({ schema: "mnote.local_index.settings.v1", includePaths: ["."], scheduleMode: "daily", scheduleTime: "02:00", scheduleDate: null, runOnChange: false, updatedAt: Date.now(), }, null, 2)}\n`, ); } function buildFileOrder(model, targetByOldRel) { const parents = { ".": [] }; const rootTarget = targetByOldRel.get(model.rootRel); parents["."].push(rootTarget.dirRel); for (const [oldRel, children] of model.orderedChildren.entries()) { const parentTarget = targetByOldRel.get(oldRel); if (!parentTarget) continue; parents[parentTarget.dirRel] = children .map((child) => targetByOldRel.get(child)) .filter(Boolean) .map((target) => target.dirRel); } return parents; } function importWolaiExport(options) { const sourceRoot = findExportRoot(options.sourceRoot || options.sourceDir || options.source); const targetRoot = path.resolve(options.targetRoot); const ownerId = String(options.ownerId || "").trim(); if (!ownerId) throw new Error("缺少 ownerId"); const workspaceId = String(options.workspaceId || `local-ws:${ownerId}:my-space`).trim(); const copyMode = options.copyMode === "copy" ? "copy" : "link"; const rootRel = findRootMarkdown(sourceRoot, options.rootMd); if (options.clearTarget) clearTarget(targetRoot, ownerId, Boolean(options.allowUnsafeClear)); ensureDir(targetRoot); const model = buildPageModel(sourceRoot, rootRel); const { targetByOldRel, ordered } = assignTargetPaths(model, targetRoot); const assetState = createAssetPlacementState(model.sourceFiles); const linkStats = { rewritten: 0, missing: 0 }; for (const oldRel of ordered) { const page = model.pages.get(oldRel); const target = targetByOldRel.get(oldRel); const rewritten = rewriteMarkdown( page, target, targetByOldRel, sourceRoot, targetRoot, copyMode, assetState, linkStats, ); writeUtf8(target.markdownPath, rewritten.endsWith("\n") ? rewritten : `${rewritten}\n`); } const pageMap = ordered.map((oldRel) => { const target = targetByOldRel.get(oldRel); return { oldRelativePath: oldRel, newRelativePath: target.markdownRel, title: target.title, parentOldRelativePath: target.parentOldRel, synthetic: target.synthetic, }; }); const syntheticCount = pageMap.filter((entry) => entry.synthetic).length; assetState.counts.sourceTotal = assetState.sourceAssetRels.length; assetState.counts.unused = Math.max(0, assetState.sourceAssetRels.length - assetState.usedSourceRels.size); const result = { ok: true, sourceRoot, targetRoot, ownerId, workspaceId, root: { oldRelativePath: rootRel, newRelativePath: targetByOldRel.get(rootRel).markdownRel, }, pages: { imported: model.originalPageCount, synthetic: syntheticCount, written: pageMap.length, }, assets: assetState.counts, links: linkStats, pageMap, }; writeWorkspaceMetadata( targetRoot, ownerId, workspaceId, result, buildFileOrder(model, targetByOldRel), assetState.uploadedAssets, ); return result; } function assertSafeZipEntries(zipPath) { const result = spawnSync("zipinfo", ["-1", zipPath], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); if (result.status !== 0) { throw new Error(`zipinfo 读取失败: ${result.stderr || result.stdout}`); } for (const entry of result.stdout.split(/\r?\n/).filter(Boolean)) { if (entry.startsWith("/") || entry.includes("\\") || entry.split("/").includes("..")) { throw new Error(`ZIP 包含不安全路径: ${entry}`); } } } function extractZipToTemp(zipPath) { assertSafeZipEntries(zipPath); const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-wolai-zip-")); const result = spawnSync("unzip", ["-q", zipPath, "-d", tempRoot], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }); if (result.status !== 0) { throw new Error(`unzip 解包失败: ${result.stderr || result.stdout}`); } return tempRoot; } module.exports = { importWolaiExport, extractZipToTemp, };