#!/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 ''", " node scripts/task180-mindmap-ghost-candidate-audit.js --url 'http://127.0.0.1:3000/api/tree/projections/file?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, };