Files
mnote/scripts/task180-mindmap-ghost-candidate-audit.js
T

265 lines
8.9 KiB
JavaScript
Raw Normal View History

#!/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,
};