782 lines
25 KiB
JavaScript
782 lines
25 KiB
JavaScript
"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,
|
|
};
|