'use strict'; const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); function newId(prefix) { const uuid = crypto.randomUUID(); return prefix ? `${prefix}-${uuid}` : uuid; } function nowIso() { return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); } /** * Crash-safe write: write to a temp file in the same directory, then rename * over the target so readers never observe a half-written file. */ function atomicWriteFileSync(file, data) { const dir = path.dirname(file); fs.mkdirSync(dir, { recursive: true }); const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`); const fd = fs.openSync(tmp, 'w'); try { fs.writeFileSync(fd, data); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } fs.renameSync(tmp, file); } function writeJsonSync(file, obj) { const json = JSON.stringify(obj, null, 2); if (json === undefined) throw new TypeError(`writeJsonSync: value for ${file} is not JSON-serializable`); atomicWriteFileSync(file, json + '\n'); } function readJsonSync(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); } function readJsonIfExists(file, fallback) { try { return readJsonSync(file); } catch (err) { if (err.code === 'ENOENT' || err instanceof SyntaxError) return fallback; throw err; } } const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', '#39': "'" }; function decodeEntities(text) { return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+|#39);/g, (m, name) => { if (name[0] === '#') { const code = name[1] === 'x' || name[1] === 'X' ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10); return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : m; } return Object.prototype.hasOwnProperty.call(ENTITIES, name) ? ENTITIES[name] : m; }); } /** Convert an HTML fragment to plain text (for search indexing and exports). */ function htmlToText(html) { if (!html) return ''; let text = String(html) .replace(/<(br|\/p|\/div|\/li|\/h[1-6]|\/tr)\s*\/?>/gi, '\n') .replace(/