#!/usr/bin/env node /** * @file program script: does a cache hold everything in a diigo RSS * export? See {@link main}. * * node scripts/compare-export.ts */ import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { itemDescription, toRss } from "../src/rss.ts"; type Item = Record; /** one export item, as read */ type Exported = { title: string; link: string; description: string }; const FORBIDDEN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g; const clean = (s: string) => s.replace(FORBIDDEN, ""); /** diigo's RSS export, read leniently item by item (it isn't well-formed * XML: it carries characters XML forbids) */ export function parseExport(xml: string): Exported[] { const field = (it: string, name: string) => it.match(new RegExp(`<${name}>`))?.[1] ?? ""; return [...xml.matchAll(/([\s\S]*?)<\/item>/g)].map((m) => ({ title: clean(field(m[1], "title")), link: clean(field(m[1], "link")), description: clean(field(m[1], "description")), })); } const PARTS = /^Tags:([\s\S]*?);Description:([\s\S]*?);Annotations and Sticky notes:([\s\S]*)$/; /** what's wrong with `ours` as a rendering of `theirs` (empty: nothing) */ function problems(o: Item, t: Exported): string[] { const found: string[] = []; const title = clean(String(o.title ?? "")); // the export cuts titles at 255 if (title !== t.title && !(t.title.length === 255 && title.startsWith(t.title))) found.push("title differs"); const mine = clean(itemDescription(o)).match(PARTS); const yours = t.description.match(PARTS); if (!mine || !yours) return [...found, "description unparsed"]; if (mine[1] !== yours[1]) found.push("tags differ"); if (mine[2] !== yours[2]) found.push("description differs"); if (mine[3] !== yours[3]) found.push("annotations differ"); return found; } /** * compare cached items with an export, keyed on link: each export item * is complete, missing, or has problems by category; cached links absent * from the export are reported too. Links only, never contents. */ export function compare(ours: Item[], theirs: Exported[]): { complete: number; report: Record } { const byUrl = new Map(); for (const it of ours) { const url = String(it.url ?? ""); byUrl.set(url, [...(byUrl.get(url) ?? []), it]); } const report: Record = {}; const note = (category: string, link: string) => (report[category] ??= []).push(link); let complete = 0; for (const t of theirs) { const candidates = byUrl.get(t.link); if (!candidates) { note("missing from cache", t.link); continue; } // a link bookmarked twice: the best-matching candidate counts const best = candidates.map((o) => problems(o, t)).reduce((a, b) => (b.length < a.length ? b : a)); if (best.length === 0) complete += 1; for (const p of best) note(p, t.link); } const exportLinks = new Set(theirs.map((t) => t.link)); for (const url of byUrl.keys()) if (!exportLinks.has(url)) note("in cache, not in export", url); return { complete, report }; } /** * Generates RSS from a cache's items (src/rss.ts) and compares it with a * diigo RSS export: title, tags, description, highlights + sticky notes. * Prints counts only; writes /from-cache.rss and * /report.json (mismatched links, by category). * * The program's edge: every power it uses arrives as a default argument * (ocap discipline), so a test can pass others. * * Ends by reporting an exit code (2: usage) through `exit`. */ export function main( argv = process.argv.slice(2), { readText = (path: string) => readFileSync(path, "utf8"), writeText = (path: string, text: string) => writeFileSync(path, text), readItems = (dbPath: string): Item[] => (new DatabaseSync(dbPath, { readOnly: true }).prepare("select json from items order by updated desc").all() as { json: string; }[]).map((r) => JSON.parse(r.json) as Item), now = () => new Date(), log = (x: unknown) => console.log(x), warn = (x: unknown) => console.error(x), exit = (code: number) => { process.exitCode = code; }, } = {}, ): void { const [dbPath, exportPath, outDir] = argv; if (!dbPath || !exportPath || !outDir) { warn("usage: node scripts/compare-export.ts "); return exit(2); } const ours = readItems(dbPath); writeText(join(outDir, "from-cache.rss"), toRss(ours, { title: "from the cache", pubDate: now().toUTCString() })); const theirs = parseExport(readText(exportPath)); const { complete, report } = compare(ours, theirs); writeText(join(outDir, "report.json"), JSON.stringify(report, null, 1)); log({ export: theirs.length, cache: ours.length, complete, ...Object.fromEntries(Object.entries(report).map(([k, v]) => [k, v.length])), }); exit(0); } main();