- 补齐 design/10-review 执行清单、验收标准与相关设计治理记录 - 迁移已完成的 tree、mindmap、runtime fallback、AI kernel 等设计和缺陷条目 - 推进 Rust Web runtime、tree/sidebar、page aggregate、mindmap 与 OnlyOffice 路由侧验证支撑 - 增加 task177-task180 smoke/audit 脚本及前端相关测试覆盖
265 lines
8.9 KiB
JavaScript
265 lines
8.9 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const fs = require("node:fs/promises");
|
|
const path = require("node:path");
|
|
|
|
const DEFAULT_URL = "http://127.0.0.1:3000/api/sidebar";
|
|
const DEFAULT_OUTPUT = "tmp/task180-mindmap-ghost-candidate-audit/result.json";
|
|
|
|
function stringOrNull(value) {
|
|
if (typeof value !== "string") return null;
|
|
const trimmed = value.trim();
|
|
return trimmed || null;
|
|
}
|
|
|
|
function asArray(value) {
|
|
return Array.isArray(value) ? value : [];
|
|
}
|
|
|
|
function unwrapSidebarPayload(payload) {
|
|
let unwrapped = payload && typeof payload === "object" ? payload : {};
|
|
if (unwrapped.data && typeof unwrapped.data === "object") {
|
|
unwrapped = unwrapped.data;
|
|
}
|
|
const result = unwrapped.result && typeof unwrapped.result === "object" ? unwrapped.result : null;
|
|
if (Array.isArray(result?.items)) {
|
|
return {
|
|
...unwrapped,
|
|
...result,
|
|
kernelFileTreeProjection: { items: result.items },
|
|
};
|
|
}
|
|
if (Array.isArray(unwrapped.items)) {
|
|
return {
|
|
...unwrapped,
|
|
kernelFileTreeProjection: { items: unwrapped.items },
|
|
};
|
|
}
|
|
return unwrapped;
|
|
}
|
|
|
|
function readAssetId(asset) {
|
|
return stringOrNull(asset?.id) ?? stringOrNull(asset?.assetId) ?? stringOrNull(asset?.asset_id);
|
|
}
|
|
|
|
function readDocumentId(value) {
|
|
return stringOrNull(value?.document_id) ?? stringOrNull(value?.documentId) ?? stringOrNull(value?.docId);
|
|
}
|
|
|
|
function isActiveAsset(asset) {
|
|
return !stringOrNull(asset?.deleted_at) &&
|
|
!stringOrNull(asset?.deletedAt) &&
|
|
!stringOrNull(asset?.purged_at) &&
|
|
!stringOrNull(asset?.purgedAt);
|
|
}
|
|
|
|
function isMindmapAsset(asset) {
|
|
const type = stringOrNull(asset?.asset_type) ?? stringOrNull(asset?.assetType);
|
|
return type === "mindmap" || Boolean(readAssetId(asset)?.startsWith("mindmap"));
|
|
}
|
|
|
|
function normalizeMindmapAsset(asset) {
|
|
return {
|
|
assetId: readAssetId(asset),
|
|
documentId: readDocumentId(asset),
|
|
deletedAt: stringOrNull(asset?.deleted_at) ?? stringOrNull(asset?.deletedAt),
|
|
title: stringOrNull(asset?.file_name) ?? stringOrNull(asset?.fileName) ?? stringOrNull(asset?.title),
|
|
};
|
|
}
|
|
|
|
function readProjectionItems(sidebar) {
|
|
const projection = sidebar.kernelFileTreeProjection ?? sidebar.kernel_file_tree_projection ?? {};
|
|
return asArray(projection.items);
|
|
}
|
|
|
|
function readFileTreeMindmapRow(item) {
|
|
const resourceMeta = item?.resourceMeta ?? item?.resource_meta ?? {};
|
|
const assetId =
|
|
stringOrNull(resourceMeta.assetId) ??
|
|
stringOrNull(resourceMeta.asset_id) ??
|
|
stringOrNull(item?.assetId) ??
|
|
stringOrNull(item?.asset_id);
|
|
const documentId =
|
|
stringOrNull(resourceMeta.documentId) ??
|
|
stringOrNull(resourceMeta.document_id) ??
|
|
readDocumentId(item);
|
|
const resourceKind = stringOrNull(resourceMeta.resourceKind) ?? stringOrNull(resourceMeta.resource_kind);
|
|
const rowKind = stringOrNull(item?.rowKind) ?? stringOrNull(item?.row_kind);
|
|
const objectKind = stringOrNull(item?.objectKind) ?? stringOrNull(resourceMeta.objectKind);
|
|
const isMindmap =
|
|
resourceKind === "mindmap" ||
|
|
objectKind === "mindmap" ||
|
|
Boolean(assetId?.startsWith("mindmap"));
|
|
if (!isMindmap) return null;
|
|
return {
|
|
rowId: stringOrNull(item?.rowId) ?? stringOrNull(item?.row_id),
|
|
rowKind,
|
|
documentId,
|
|
assetId,
|
|
title: stringOrNull(item?.title),
|
|
};
|
|
}
|
|
|
|
function groupBy(items, keyFn) {
|
|
const map = new Map();
|
|
for (const item of items) {
|
|
const key = keyFn(item);
|
|
if (!key) continue;
|
|
const bucket = map.get(key) ?? [];
|
|
bucket.push(item);
|
|
map.set(key, bucket);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function auditSidebarMindmapGhostCandidates(payload) {
|
|
const sidebar = unwrapSidebarPayload(payload);
|
|
const documents = asArray(sidebar.documents);
|
|
const documentIds = new Set(documents.map((doc) => stringOrNull(doc?.id)).filter(Boolean));
|
|
const rawMindmapAssets = [
|
|
...asArray(sidebar.mindmapAssets),
|
|
...asArray(sidebar.mindmap_assets),
|
|
];
|
|
const activeMindmapAssets = rawMindmapAssets
|
|
.filter((asset) => isMindmapAsset(asset) && isActiveAsset(asset))
|
|
.map(normalizeMindmapAsset)
|
|
.filter((asset) => asset.assetId && asset.documentId);
|
|
|
|
const byDocument = groupBy(activeMindmapAssets, (asset) => asset.documentId);
|
|
const multipleActiveMindmapsByDocument = Array.from(byDocument.entries())
|
|
.filter(([, assets]) => assets.length > 1)
|
|
.map(([documentId, assets]) => ({
|
|
documentId,
|
|
mindmapIds: assets.map((asset) => asset.assetId),
|
|
count: assets.length,
|
|
}));
|
|
|
|
const byAssetId = groupBy(activeMindmapAssets, (asset) => asset.assetId);
|
|
const duplicateActiveAssetIds = Array.from(byAssetId.entries())
|
|
.filter(([, assets]) => assets.length > 1)
|
|
.map(([assetId, assets]) => ({
|
|
assetId,
|
|
documentIds: Array.from(new Set(assets.map((asset) => asset.documentId).filter(Boolean))),
|
|
count: assets.length,
|
|
}));
|
|
|
|
const activeAssetsWithMissingDocument = activeMindmapAssets
|
|
.filter((asset) => asset.documentId && !documentIds.has(asset.documentId))
|
|
.map((asset) => ({
|
|
assetId: asset.assetId,
|
|
documentId: asset.documentId,
|
|
}));
|
|
|
|
const fileTreeMindmapRows = readProjectionItems(sidebar)
|
|
.map(readFileTreeMindmapRow)
|
|
.filter(Boolean);
|
|
const byFileTreeObject = groupBy(fileTreeMindmapRows, (row) => `${row.documentId ?? ""}:${row.assetId ?? ""}`);
|
|
const duplicateFileTreeMindmapRows = Array.from(byFileTreeObject.entries())
|
|
.filter(([key, rows]) => !key.startsWith(":") && rows.length > 1)
|
|
.map(([, rows]) => ({
|
|
documentId: rows[0].documentId,
|
|
assetId: rows[0].assetId,
|
|
rowIds: rows.map((row) => row.rowId).filter(Boolean),
|
|
count: rows.length,
|
|
}));
|
|
|
|
const candidates = {
|
|
multipleActiveMindmapsByDocument,
|
|
duplicateActiveAssetIds,
|
|
activeAssetsWithMissingDocument,
|
|
duplicateFileTreeMindmapRows,
|
|
};
|
|
const candidateCount = Object.values(candidates).reduce((sum, items) => sum + items.length, 0);
|
|
return {
|
|
ok: true,
|
|
readOnly: true,
|
|
summary: {
|
|
documents: documents.length,
|
|
activeMindmapAssets: activeMindmapAssets.length,
|
|
fileTreeMindmapRows: fileTreeMindmapRows.length,
|
|
documentGroupsWithMultipleActiveMindmaps: multipleActiveMindmapsByDocument.length,
|
|
duplicateActiveAssetIds: duplicateActiveAssetIds.length,
|
|
activeAssetsWithMissingDocument: activeAssetsWithMissingDocument.length,
|
|
duplicateFileTreeMindmapRows: duplicateFileTreeMindmapRows.length,
|
|
candidateCount,
|
|
},
|
|
candidates,
|
|
};
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const args = {
|
|
input: null,
|
|
url: DEFAULT_URL,
|
|
output: DEFAULT_OUTPUT,
|
|
cookie: process.env.MNOTE_AUTH_COOKIE || "",
|
|
failOnCandidates: false,
|
|
help: false,
|
|
};
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "--input") args.input = argv[++index] || null;
|
|
else if (arg === "--url") args.url = argv[++index] || DEFAULT_URL;
|
|
else if (arg === "--output") args.output = argv[++index] || DEFAULT_OUTPUT;
|
|
else if (arg === "--cookie") args.cookie = argv[++index] || "";
|
|
else if (arg === "--fail-on-candidates") args.failOnCandidates = true;
|
|
else if (arg === "--help" || arg === "-h") args.help = true;
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function printHelp() {
|
|
console.log([
|
|
"Usage:",
|
|
" node scripts/task180-mindmap-ghost-candidate-audit.js --input sidebar.json",
|
|
" node scripts/task180-mindmap-ghost-candidate-audit.js --url http://127.0.0.1:3000/api/sidebar --cookie '<cookie>'",
|
|
" node scripts/task180-mindmap-ghost-candidate-audit.js --url 'http://127.0.0.1:3000/api/tree/projections/file?workspaceId=<workspaceId>'",
|
|
"",
|
|
"Notes:",
|
|
" This script is read-only. It only reports cleanup candidates and never deletes data.",
|
|
" Sidebar payloads can audit duplicate assets, orphan assets, and duplicate File Tree rows.",
|
|
" File projection payloads only include projection rows, so they can audit duplicate File Tree rows.",
|
|
].join("\n"));
|
|
}
|
|
|
|
async function loadPayload(args) {
|
|
if (args.input) {
|
|
return JSON.parse(await fs.readFile(args.input, "utf8"));
|
|
}
|
|
const headers = { accept: "application/json" };
|
|
if (args.cookie) headers.cookie = args.cookie;
|
|
const response = await fetch(args.url, { headers });
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
throw new Error(`sidebar_audit_fetch_failed:${response.status}:${text.slice(0, 300)}`);
|
|
}
|
|
return JSON.parse(text);
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
if (args.help) {
|
|
printHelp();
|
|
return;
|
|
}
|
|
const payload = await loadPayload(args);
|
|
const result = auditSidebarMindmapGhostCandidates(payload);
|
|
await fs.mkdir(path.dirname(args.output), { recursive: true });
|
|
await fs.writeFile(args.output, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
|
console.log(JSON.stringify(result.summary, null, 2));
|
|
if (args.failOnCandidates && result.summary.candidateCount > 0) {
|
|
process.exitCode = 2;
|
|
}
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
auditSidebarMindmapGhostCandidates,
|
|
};
|