#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import { DatabaseSync } from "node:sqlite"; function arg(name, fallback) { const index = process.argv.indexOf(name); return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback; } const configPath = path.resolve(arg("--config", "../index-config.json")); const dbPath = path.resolve(arg("--db", "../index/notion-world-index.sqlite")); const reportPath = path.resolve(arg("--report", "../index/corpus-inventory.json")); const config = JSON.parse(fs.readFileSync(configPath, "utf8")); const sourceRoot = path.resolve(config.source_roots[0]); const previewBytes = Number(config.preview_bytes ?? 4096); const storedPreviewBytes = 512; const threshold = Number(config.candidate_score_threshold ?? 6); const tempDbPath = `${dbPath}.building`; fs.mkdirSync(path.dirname(dbPath), { recursive: true }); fs.mkdirSync(path.dirname(reportPath), { recursive: true }); if (fs.existsSync(tempDbPath)) fs.rmSync(tempDbPath); function normalizePageId(hex) { const value = hex.toLowerCase(); return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`; } function pageIdentity(filename) { const basename = filename.replace(/\.md$/i, ""); const match = basename.match(/(?:^| )([0-9a-f]{32})$/i); if (!match) return { title: basename, pageId: null }; return { title: basename.slice(0, match.index).trim(), pageId: normalizePageId(match[1]) }; } function logicalPath(relativePath, title) { return path.posix.join(path.posix.dirname(relativePath), title); } function readPreview(file) { const fd = fs.openSync(file, "r"); try { const buffer = Buffer.allocUnsafe(previewBytes); const length = fs.readSync(fd, buffer, 0, previewBytes, 0); return buffer.subarray(0, length).toString("utf8").replaceAll("\u0000", ""); } finally { fs.closeSync(fd); } } function classify(relativePath, title, preview) { const haystack = `${relativePath}\n${title}\n${preview}`.toLowerCase(); const titlePath = `${relativePath}\n${title}`.toLowerCase(); let score = 0; const positive = []; const negative = []; for (const term of config.positive_terms) { const normalized = term.toLowerCase(); if (titlePath.includes(normalized)) { score += 4; positive.push(term); } else if (haystack.includes(normalized)) { score += 1; positive.push(term); } } for (const term of config.negative_terms) { const normalized = term.toLowerCase(); if (titlePath.includes(normalized)) { score -= 5; negative.push(term); } } const alwaysInclude = config.always_include_path_fragments.some(fragment => relativePath.includes(fragment) ); if (alwaysInclude) score += 100; const domainHints = []; const hints = [ ["MAIN", ["光湖主域", "domain-main", "dom-main"]], ["SUB", ["光湖分域", "domain-sub", "dom-sub"]], ["ZERO", ["光湖零域", "domain-zero", "dom-zero"]], ["ZEROSENSE", ["光湖零感域", "零感域", "zero-sense", "zerosense"]], ["FIFTH", ["第五域", "fifth domain", "sys-5th", "domain-fifth"]] ]; for (const [domain, terms] of hints) { if (terms.some(term => haystack.includes(term))) domainHints.push(domain); } return { score, candidate: score >= threshold, alwaysInclude, positive, negative, domainHints }; } function* walk(root) { const stack = [root]; while (stack.length) { const directory = stack.pop(); const entries = fs.readdirSync(directory, { withFileTypes: true }); for (const entry of entries) { if (entry.name === ".DS_Store") continue; const fullPath = path.join(directory, entry.name); if (entry.isDirectory()) stack.push(fullPath); else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) yield fullPath; } } } const db = new DatabaseSync(tempDbPath); db.exec(` PRAGMA journal_mode = DELETE; PRAGMA synchronous = NORMAL; PRAGMA temp_store = MEMORY; CREATE TABLE pages ( file_path TEXT PRIMARY KEY, relative_path TEXT NOT NULL, export_batch TEXT NOT NULL, title TEXT NOT NULL, page_id TEXT, logical_path TEXT NOT NULL, parent_logical_path TEXT, parent_file_path TEXT, size_bytes INTEGER NOT NULL, mtime_ms INTEGER NOT NULL, first_heading TEXT, preview TEXT, relevance_score INTEGER NOT NULL, is_candidate INTEGER NOT NULL, always_include INTEGER NOT NULL, positive_terms TEXT NOT NULL, negative_terms TEXT NOT NULL, domain_hints TEXT NOT NULL ); CREATE INDEX pages_page_id_idx ON pages(page_id); CREATE INDEX pages_logical_path_idx ON pages(logical_path); CREATE INDEX pages_candidate_idx ON pages(is_candidate, relevance_score DESC); CREATE INDEX pages_title_idx ON pages(title); CREATE TABLE links ( source_file_path TEXT NOT NULL, target_raw TEXT NOT NULL, target_file_path TEXT, target_page_id TEXT, link_kind TEXT NOT NULL, resolved INTEGER NOT NULL, PRIMARY KEY(source_file_path, target_raw) ); CREATE INDEX links_source_idx ON links(source_file_path); CREATE INDEX links_target_path_idx ON links(target_file_path); CREATE INDEX links_target_page_idx ON links(target_page_id); CREATE TABLE identifiers ( source_file_path TEXT NOT NULL, identifier TEXT NOT NULL, PRIMARY KEY(source_file_path, identifier) ); CREATE INDEX identifiers_value_idx ON identifiers(identifier); CREATE VIRTUAL TABLE page_search USING fts5( file_path UNINDEXED, title, relative_path, tokenize='unicode61' ); `); const insertPage = db.prepare(` INSERT INTO pages ( file_path, relative_path, export_batch, title, page_id, logical_path, parent_logical_path, size_bytes, mtime_ms, first_heading, preview, relevance_score, is_candidate, always_include, positive_terms, negative_terms, domain_hints ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const insertSearch = db.prepare( "INSERT INTO page_search(file_path, title, relative_path) VALUES (?, ?, ?)" ); const pageFiles = [...walk(sourceRoot)]; console.log(`DISCOVERED ${pageFiles.length} markdown files`); db.exec("BEGIN"); let indexed = 0; for (const file of pageFiles) { const relativePath = path.relative(sourceRoot, file).split(path.sep).join("/"); const exportBatch = relativePath.split("/")[0]; const { title, pageId } = pageIdentity(path.basename(file)); const preview = readPreview(file); const headingMatch = preview.match(/^#\s+(.+)$/m); const classification = classify(relativePath, title, preview); const stat = fs.statSync(file); const logical = logicalPath(relativePath, title); const parentLogical = path.posix.dirname(relativePath) === "." ? null : path.posix.dirname(relativePath); insertPage.run( file, relativePath, exportBatch, title, pageId, logical, parentLogical, stat.size, stat.mtimeMs, headingMatch?.[1]?.trim() ?? null, preview.slice(0, storedPreviewBytes), classification.score, classification.candidate ? 1 : 0, classification.alwaysInclude ? 1 : 0, JSON.stringify(classification.positive), JSON.stringify(classification.negative), JSON.stringify(classification.domainHints) ); insertSearch.run(file, title, relativePath); indexed++; if (indexed % 10000 === 0) console.log(`INDEXED ${indexed}/${pageFiles.length}`); } db.exec("COMMIT"); db.exec(` UPDATE pages SET parent_file_path = ( SELECT parent.file_path FROM pages AS parent WHERE parent.logical_path = pages.parent_logical_path AND parent.export_batch = pages.export_batch ORDER BY parent.size_bytes DESC LIMIT 1 ) WHERE parent_logical_path IS NOT NULL; `); const candidateRows = db.prepare( "SELECT file_path, relative_path FROM pages WHERE is_candidate = 1" ).all(); console.log(`CANDIDATES ${candidateRows.length}`); const insertLink = db.prepare(` INSERT OR IGNORE INTO links( source_file_path, target_raw, target_file_path, target_page_id, link_kind, resolved ) VALUES (?, ?, ?, ?, ?, ?) `); const insertIdentifier = db.prepare( "INSERT OR IGNORE INTO identifiers(source_file_path, identifier) VALUES (?, ?)" ); const markdownLink = /\[[^\]]*\]\((?:<([^>]+)>|([^) \t\r\n]+))(?:\s+["'][^"']*["'])?\)/g; const notionId = /(?:notion\.(?:so|com)\/(?:p\/)?(?:[^/?#]*-)?|\/p\/)([0-9a-f]{32})(?:[?#/]|$)/i; const identifierPattern = /\b(?:SYS|HLDP-DOMAIN|DOM|GLS|TCS|ICE|GH|BC|CH|AGE|PER|5TH|DEV|AG|NAV|FD-MIG)-[A-Z0-9∞]+(?:-[A-Z0-9∞]+)*\b/gi; db.exec("BEGIN"); let relationCount = 0; for (const row of candidateRows) { const content = fs.readFileSync(row.file_path, "utf8"); const sourceDirectory = path.dirname(row.file_path); for (const match of content.matchAll(markdownLink)) { const targetRaw = match[1] ?? match[2]; if (!targetRaw || targetRaw.startsWith("#") || targetRaw.startsWith("mailto:")) continue; const notionMatch = targetRaw.match(notionId); if (/^https?:/i.test(targetRaw)) { insertLink.run( row.file_path, targetRaw, null, notionMatch ? normalizePageId(notionMatch[1]) : null, notionMatch ? "notion" : "external", notionMatch ? 1 : 0 ); continue; } const cleanTarget = targetRaw.split("#")[0]; let decoded = cleanTarget; try { decoded = decodeURIComponent(cleanTarget); } catch {} const resolvedPath = path.resolve(sourceDirectory, decoded); const exists = fs.existsSync(resolvedPath); const identity = pageIdentity(path.basename(resolvedPath)); insertLink.run( row.file_path, targetRaw, exists ? resolvedPath : null, identity.pageId, "local", exists ? 1 : 0 ); relationCount++; } const identifiers = new Set(content.match(identifierPattern) ?? []); for (const identifier of identifiers) { insertIdentifier.run(row.file_path, identifier.toUpperCase()); } } db.exec("COMMIT"); db.exec("PRAGMA optimize"); const stats = { schema: "guanghu.notion-reconstruction-inventory/v0.1", generated_at: new Date().toISOString(), source_root: sourceRoot, source_size_bytes: Number( db.prepare("SELECT COALESCE(SUM(size_bytes), 0) AS n FROM pages").get().n ), markdown_pages: Number(db.prepare("SELECT COUNT(*) AS n FROM pages").get().n), unique_notion_page_ids: Number( db.prepare("SELECT COUNT(DISTINCT page_id) AS n FROM pages WHERE page_id IS NOT NULL").get().n ), duplicate_page_id_groups: Number( db.prepare(` SELECT COUNT(*) AS n FROM ( SELECT page_id FROM pages WHERE page_id IS NOT NULL GROUP BY page_id HAVING COUNT(*) > 1 ) `).get().n ), candidate_pages: candidateRows.length, candidate_links: Number(db.prepare("SELECT COUNT(*) AS n FROM links").get().n), unresolved_candidate_links: Number( db.prepare("SELECT COUNT(*) AS n FROM links WHERE link_kind = 'local' AND resolved = 0").get().n ), identifiers: Number(db.prepare("SELECT COUNT(*) AS n FROM identifiers").get().n), database_sha256: null }; db.close(); if (fs.existsSync(dbPath)) fs.rmSync(dbPath); fs.renameSync(tempDbPath, dbPath); const databaseHash = crypto.createHash("sha256"); for await (const chunk of fs.createReadStream(dbPath)) databaseHash.update(chunk); stats.database_sha256 = databaseHash.digest("hex"); fs.writeFileSync(reportPath, `${JSON.stringify(stats, null, 2)}\n`); console.log(JSON.stringify(stats, null, 2));