Files

782 lines
25 KiB
JavaScript
Raw Permalink Normal View History

"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,
};