From bbef01eb1c6a69484d8c47c4198a139924ce67d6 Mon Sep 17 00:00:00 2001 From: Nathan Buesgens Date: Thu, 9 Jul 2026 19:17:10 -0400 Subject: [PATCH 01/11] sync-reader Unit 1: package scaffold, config loader, curriculum reader New workspace package @commons-systems/sync-reader with: - config.ts: loads dispatch.config/sync-reader.json (config/no-config/throw), SYNC_READER_CONFIG_DIR override, project root via git --git-common-dir. - curriculum.ts: readActiveChunks filters tactic-reading-chunk-* nodes with attributes.curriculum and phase !== done, validates shape. - index.ts public surface; vitest project auto-registered via root workspaces. Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 38 ++++--- package.json | 1 + packages/sync-reader/package.json | 20 ++++ packages/sync-reader/src/config.ts | 85 ++++++++++++++++ packages/sync-reader/src/curriculum.ts | 85 ++++++++++++++++ packages/sync-reader/src/index.ts | 7 ++ packages/sync-reader/test/config.test.ts | 59 +++++++++++ packages/sync-reader/test/curriculum.test.ts | 102 +++++++++++++++++++ packages/sync-reader/tsconfig.json | 4 + packages/sync-reader/vite.config.ts | 3 + 10 files changed, 391 insertions(+), 13 deletions(-) create mode 100644 packages/sync-reader/package.json create mode 100644 packages/sync-reader/src/config.ts create mode 100644 packages/sync-reader/src/curriculum.ts create mode 100644 packages/sync-reader/src/index.ts create mode 100644 packages/sync-reader/test/config.test.ts create mode 100644 packages/sync-reader/test/curriculum.test.ts create mode 100644 packages/sync-reader/tsconfig.json create mode 100644 packages/sync-reader/vite.config.ts diff --git a/package-lock.json b/package-lock.json index 357d38985..023674b97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "packages/htmlutil", "packages/idbutil", "packages/intentionsutil", + "packages/sync-reader", "landing", "packages/local-first", "packages/mediautil", @@ -101,7 +102,6 @@ "@commons-systems/analyticsutil": "*", "@commons-systems/authutil": "*", "@commons-systems/components": "*", - "@commons-systems/crypto-core": "*", "@commons-systems/crypto": "*", "@commons-systems/ds": "*", "@commons-systems/errorutil": "*", @@ -557,14 +557,14 @@ "resolved": "packages/criticalcssutil", "link": true }, - "node_modules/@commons-systems/crypto-core": { - "resolved": "packages/crypto-core", - "link": true - }, "node_modules/@commons-systems/crypto": { "resolved": "packages/crypto", "link": true }, + "node_modules/@commons-systems/crypto-core": { + "resolved": "packages/crypto-core", + "link": true + }, "node_modules/@commons-systems/ds": { "resolved": "packages/ds", "link": true @@ -621,6 +621,10 @@ "resolved": "packages/style", "link": true }, + "node_modules/@commons-systems/sync-reader": { + "resolved": "packages/sync-reader", + "link": true + }, "node_modules/@dabh/diagnostics": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", @@ -18916,11 +18920,8 @@ "version": "0.0.0", "dependencies": { "@commons-systems/crypto-core": "*", - "@commons-systems/firestoreutil": "*", - "@commons-systems/functions": "*", "@google-cloud/secret-manager": "^6.1.3", - "firebase-admin": "^13.0.0", - "office-hours": "*" + "firebase-admin": "^13.0.0" } }, "packages/analyticsutil": { @@ -18984,10 +18985,6 @@ "name": "@commons-systems/criticalcssutil", "version": "0.0.0" }, - "packages/crypto-core": { - "name": "@commons-systems/crypto-core", - "version": "0.0.0" - }, "packages/crypto": { "name": "@commons-systems/crypto", "version": "0.0.0", @@ -18995,6 +18992,10 @@ "@commons-systems/errorutil": "*" } }, + "packages/crypto-core": { + "name": "@commons-systems/crypto-core", + "version": "0.0.0" + }, "packages/ds": { "name": "@commons-systems/ds", "version": "0.0.0", @@ -19070,6 +19071,7 @@ "name": "@commons-systems/mediautil", "version": "0.0.0", "dependencies": { + "@commons-systems/firestoreutil": "*", "@commons-systems/idbutil": "*", "firebase": "^12.9.0" }, @@ -19120,6 +19122,16 @@ "@commons-systems/ds": "*" } }, + "packages/sync-reader": { + "name": "@commons-systems/sync-reader", + "version": "0.0.0", + "dependencies": { + "@commons-systems/intentionsutil": "*", + "@xmldom/xmldom": "^0.8.13", + "jszip": "^3.10.1", + "unzipit": "^1.4.3" + } + }, "print": { "version": "0.0.0", "dependencies": { diff --git a/package.json b/package.json index aebba6eff..1b29c585e 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "packages/htmlutil", "packages/idbutil", "packages/intentionsutil", + "packages/sync-reader", "landing", "packages/local-first", "packages/mediautil", diff --git a/packages/sync-reader/package.json b/packages/sync-reader/package.json new file mode 100644 index 000000000..606f1bd98 --- /dev/null +++ b/packages/sync-reader/package.json @@ -0,0 +1,20 @@ +{ + "name": "@commons-systems/sync-reader", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint src/" + }, + "dependencies": { + "@commons-systems/intentionsutil": "*", + "@xmldom/xmldom": "^0.8.13", + "jszip": "^3.10.1", + "unzipit": "^1.4.3" + } +} diff --git a/packages/sync-reader/src/config.ts b/packages/sync-reader/src/config.ts new file mode 100644 index 000000000..32158cfcb --- /dev/null +++ b/packages/sync-reader/src/config.ts @@ -0,0 +1,85 @@ +// Loader for the non-version-controlled sync-reader config file. +// +// The config lives at `/dispatch.config/sync-reader.json` — +// outside every git worktree, shared across them. The project root is resolved +// as `dirname(git --git-common-dir)` (the same rule as resolve_project_root in +// `.claude/skills/dispatch-propagate/scripts/lib.sh`, correct across worktrees). +// A `SYNC_READER_CONFIG_DIR` env override points the loader at an explicit +// directory (used by tests and analogous to DISPATCH_CONFIG_DIR); when set, no +// git repo is required. + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** Normalized result of loading `sync-reader.json`. */ +export type SyncReaderConfig = + | { kind: "config"; reader_dir: string; share_dir: string } + | { kind: "no-config" }; + +const CONFIG_FILENAME = "sync-reader.json"; + +/** + * Resolve the shared project root: `dirname(git --git-common-dir)`. The common + * dir is shared by every worktree, so this is stable regardless of which + * worktree the process runs in. Throws a clear error (no fallback) when not in + * a git repo — the config dir cannot be located otherwise. + */ +function resolveProjectRoot(): string { + let commonDir: string; + try { + commonDir = execFileSync( + "git", + ["rev-parse", "--path-format=absolute", "--git-common-dir"], + { encoding: "utf8" }, + ).trim(); + } catch { + throw new Error( + "sync-reader: not in a git repository and SYNC_READER_CONFIG_DIR is unset — " + + "cannot locate the dispatch.config directory", + ); + } + return dirname(commonDir); +} + +/** The directory that holds `sync-reader.json`. Honors the env override. */ +function configDir(): string { + const override = process.env.SYNC_READER_CONFIG_DIR; + if (override !== undefined && override.length > 0) return override; + return join(resolveProjectRoot(), "dispatch.config"); +} + +/** + * Load and normalize the sync-reader config. + * + * - file absent → `{kind: "no-config"}` (the CLI prints setup help). + * - present, valid → `{kind: "config", reader_dir, share_dir}`. + * - present, invalid → throws a clear error naming the file and the bad + * field (clear errors over defensive fallbacks). + */ +export function loadConfig(): SyncReaderConfig { + const path = join(configDir(), CONFIG_FILENAME); + if (!existsSync(path)) return { kind: "no-config" }; + + const raw = readFileSync(path, "utf8"); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error(`${path}: invalid JSON — ${(err as Error).message}`); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`${path}: expected a JSON object`); + } + + const obj = parsed as Record; + const reader_dir = obj.reader_dir; + const share_dir = obj.share_dir; + if (typeof reader_dir !== "string" || reader_dir.length === 0) { + throw new Error(`${path}: "reader_dir" must be a non-empty string`); + } + if (typeof share_dir !== "string" || share_dir.length === 0) { + throw new Error(`${path}: "share_dir" must be a non-empty string`); + } + return { kind: "config", reader_dir, share_dir }; +} diff --git a/packages/sync-reader/src/curriculum.ts b/packages/sync-reader/src/curriculum.ts new file mode 100644 index 000000000..003bf8291 --- /dev/null +++ b/packages/sync-reader/src/curriculum.ts @@ -0,0 +1,85 @@ +// Read the active reading curriculum out of the intention graph. +// +// The curriculum is carried on `tactic-reading-chunk-*` nodes as +// `attributes.curriculum = {priority, passages: [{work, range}], ...}`. A chunk +// is ACTIVE when its node file still exists (a deleted/retired chunk is simply +// absent from `listNodes`) and its `phase` is not `"done"`. This module reads +// and validates those chunks; a malformed curriculum shape is a clear per-node +// error, never a silent skip. + +import { listNodes } from "@commons-systems/intentionsutil"; + +/** One cited passage: a work reference and a citation range within it. */ +export interface Passage { + work: string; + range: string; +} + +/** An active curriculum chunk with its scheduling priority and passages. */ +export interface ActiveChunk { + id: string; + priority: number; + passages: Passage[]; +} + +const CHUNK_PREFIX = "tactic-reading-chunk-"; + +/** + * Return every active curriculum chunk in the store, id-sorted (inherited from + * `listNodes`). Filters to `tactic-reading-chunk-*` nodes carrying + * `attributes.curriculum` whose `phase !== "done"`; validates each curriculum + * shape and throws on a malformed one. + */ +export function readActiveChunks(intentionsDir: string): ActiveChunk[] { + const chunks: ActiveChunk[] = []; + for (const node of listNodes(intentionsDir)) { + if (!node.id.startsWith(CHUNK_PREFIX)) continue; + const curriculum = node.attributes.curriculum; + if (curriculum === undefined) continue; + if (node.phase === "done") continue; + chunks.push(parseCurriculum(node.id, curriculum)); + } + return chunks; +} + +function parseCurriculum(id: string, raw: unknown): ActiveChunk { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + throw new Error(`${id}: attributes.curriculum must be an object`); + } + const c = raw as Record; + + const priority = c.priority; + if (typeof priority !== "number" || !Number.isFinite(priority)) { + throw new Error( + `${id}: attributes.curriculum.priority must be a finite number`, + ); + } + + const passagesRaw = c.passages; + if (!Array.isArray(passagesRaw) || passagesRaw.length === 0) { + throw new Error( + `${id}: attributes.curriculum.passages must be a non-empty array`, + ); + } + const passages = passagesRaw.map((p, i) => parsePassage(id, i, p)); + + return { id, priority, passages }; +} + +function parsePassage(id: string, index: number, raw: unknown): Passage { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + throw new Error(`${id}: curriculum.passages[${index}] must be an object`); + } + const p = raw as Record; + if (typeof p.work !== "string" || p.work.length === 0) { + throw new Error( + `${id}: curriculum.passages[${index}].work must be a non-empty string`, + ); + } + if (typeof p.range !== "string" || p.range.length === 0) { + throw new Error( + `${id}: curriculum.passages[${index}].range must be a non-empty string`, + ); + } + return { work: p.work, range: p.range }; +} diff --git a/packages/sync-reader/src/index.ts b/packages/sync-reader/src/index.ts new file mode 100644 index 000000000..258d3d8b3 --- /dev/null +++ b/packages/sync-reader/src/index.ts @@ -0,0 +1,7 @@ +// Public surface of @commons-systems/sync-reader, consumed by the CLI at +// .claude/skills/sync-reader/scripts/sync-reader.ts. + +export { loadConfig } from "./config.js"; +export type { SyncReaderConfig } from "./config.js"; +export { readActiveChunks } from "./curriculum.js"; +export type { ActiveChunk, Passage } from "./curriculum.js"; diff --git a/packages/sync-reader/test/config.test.ts b/packages/sync-reader/test/config.test.ts new file mode 100644 index 000000000..bd0fbcfbe --- /dev/null +++ b/packages/sync-reader/test/config.test.ts @@ -0,0 +1,59 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { loadConfig } from "../src/config.js"; + +describe("loadConfig", () => { + let dir: string; + let prev: string | undefined; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "sync-reader-cfg-")); + prev = process.env.SYNC_READER_CONFIG_DIR; + process.env.SYNC_READER_CONFIG_DIR = dir; + }); + + afterEach(() => { + if (prev === undefined) delete process.env.SYNC_READER_CONFIG_DIR; + else process.env.SYNC_READER_CONFIG_DIR = prev; + rmSync(dir, { recursive: true, force: true }); + }); + + function writeConfig(contents: string): void { + writeFileSync(join(dir, "sync-reader.json"), contents); + } + + it("returns no-config when the file is absent", () => { + expect(loadConfig()).toEqual({ kind: "no-config" }); + }); + + it("returns normalized config when present and valid", () => { + writeConfig(JSON.stringify({ reader_dir: "/media/x", share_dir: "/mnt/share" })); + expect(loadConfig()).toEqual({ + kind: "config", + reader_dir: "/media/x", + share_dir: "/mnt/share", + }); + }); + + it("throws naming reader_dir when it is missing", () => { + writeConfig(JSON.stringify({ share_dir: "/mnt/share" })); + expect(() => loadConfig()).toThrow(/reader_dir/); + }); + + it("throws naming share_dir when it is empty", () => { + writeConfig(JSON.stringify({ reader_dir: "/media/x", share_dir: "" })); + expect(() => loadConfig()).toThrow(/share_dir/); + }); + + it("throws when a non-object is stored", () => { + writeConfig(JSON.stringify(["not", "an", "object"])); + expect(() => loadConfig()).toThrow(/expected a JSON object/); + }); + + it("throws on invalid JSON", () => { + writeConfig("{ not valid json"); + expect(() => loadConfig()).toThrow(/invalid JSON/); + }); +}); diff --git a/packages/sync-reader/test/curriculum.test.ts b/packages/sync-reader/test/curriculum.test.ts new file mode 100644 index 000000000..df75375ca --- /dev/null +++ b/packages/sync-reader/test/curriculum.test.ts @@ -0,0 +1,102 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { writeNode } from "@commons-systems/intentionsutil"; +import type { IntentionNodeInput } from "@commons-systems/intentionsutil/schema"; +import { readActiveChunks } from "../src/curriculum.js"; + +describe("readActiveChunks", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "sync-reader-cur-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function write(node: Partial & { id: string }): void { + writeNode(dir, { + kind: "tactic", + statement: "test chunk", + owner: "human", + status: "codified", + ...node, + }); + } + + it("returns active chunks with normalized priority and passages", () => { + write({ + id: "tactic-reading-chunk-1-plato-cave", + attributes: { + curriculum: { + priority: 1, + passages: [{ work: "Plato, Republic", range: "VII 514a-521b" }], + }, + }, + }); + expect(readActiveChunks(dir)).toEqual([ + { + id: "tactic-reading-chunk-1-plato-cave", + priority: 1, + passages: [{ work: "Plato, Republic", range: "VII 514a-521b" }], + }, + ]); + }); + + it("excludes nodes without the chunk prefix", () => { + write({ + id: "tactic-something-else", + attributes: { + curriculum: { priority: 1, passages: [{ work: "W", range: "R" }] }, + }, + }); + expect(readActiveChunks(dir)).toEqual([]); + }); + + it("excludes chunks without an attributes.curriculum", () => { + write({ id: "tactic-reading-chunk-9-mill-justice" }); + expect(readActiveChunks(dir)).toEqual([]); + }); + + it("excludes chunks whose phase is done", () => { + write({ + id: "tactic-reading-chunk-2-aristotle-hexis", + phase: "done", + attributes: { + curriculum: { priority: 2, passages: [{ work: "W", range: "R" }] }, + }, + }); + expect(readActiveChunks(dir)).toEqual([]); + }); + + it("throws on a malformed curriculum priority", () => { + write({ + id: "tactic-reading-chunk-3-kant-humanity-servility", + attributes: { + curriculum: { priority: "high", passages: [{ work: "W", range: "R" }] }, + }, + }); + expect(() => readActiveChunks(dir)).toThrow(/priority/); + }); + + it("throws on an empty passages array", () => { + write({ + id: "tactic-reading-chunk-4-sophrosyne-ordered-soul", + attributes: { curriculum: { priority: 4, passages: [] } }, + }); + expect(() => readActiveChunks(dir)).toThrow(/passages/); + }); + + it("throws when a passage is missing work", () => { + write({ + id: "tactic-reading-chunk-5-aristotle-phronesis", + attributes: { + curriculum: { priority: 5, passages: [{ range: "R" }] }, + }, + }); + expect(() => readActiveChunks(dir)).toThrow(/work/); + }); +}); diff --git a/packages/sync-reader/tsconfig.json b/packages/sync-reader/tsconfig.json new file mode 100644 index 000000000..3999faa06 --- /dev/null +++ b/packages/sync-reader/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@commons-systems/config/tsconfig.lib.json", + "include": ["src", "test", "scripts"] +} diff --git a/packages/sync-reader/vite.config.ts b/packages/sync-reader/vite.config.ts new file mode 100644 index 000000000..f366354a4 --- /dev/null +++ b/packages/sync-reader/vite.config.ts @@ -0,0 +1,3 @@ +import { createLibConfig } from "@commons-systems/config/vite"; + +export default createLibConfig(); From e63864b6e60ff7b91feb44bb6d647f8e3c949ee4 Mon Sep 17 00:00:00 2001 From: Nathan Buesgens Date: Thu, 9 Jul 2026 19:24:31 -0400 Subject: [PATCH 02/11] sync-reader Unit 2: epub reading, work matching, citation mapping - epub-read.ts: openEpub reads container.xml -> OPF via unzipit + @xmldom/xmldom (Node DOMParser), exposing title, creators, manifest, spine, and a flattened TOC (EPUB3 nav preferred, EPUB2 NCX fallback) with resolved spine indices. - matching.ts: matchWork resolves an "Author, Title" work against candidate epubs by normalized author + significant title tokens; match/missing/ambiguous. - citation.ts: mapRangeToSections resolves Stephanus, volume:page, book.chapter, and chapter/book citations to a contiguous spine span at section granularity; unmapped-with-reason on any parse/match failure. - test/fixture-epub.ts builds in-memory EPUBs (jszip) so no binary is committed. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/sync-reader/src/citation.ts | 166 +++++++++++++ packages/sync-reader/src/epub-read.ts | 260 ++++++++++++++++++++ packages/sync-reader/src/matching.ts | 58 +++++ packages/sync-reader/test/citation.test.ts | 82 ++++++ packages/sync-reader/test/epub-read.test.ts | 60 +++++ packages/sync-reader/test/fixture-epub.ts | 166 +++++++++++++ packages/sync-reader/test/matching.test.ts | 64 +++++ 7 files changed, 856 insertions(+) create mode 100644 packages/sync-reader/src/citation.ts create mode 100644 packages/sync-reader/src/epub-read.ts create mode 100644 packages/sync-reader/src/matching.ts create mode 100644 packages/sync-reader/test/citation.test.ts create mode 100644 packages/sync-reader/test/epub-read.test.ts create mode 100644 packages/sync-reader/test/fixture-epub.ts create mode 100644 packages/sync-reader/test/matching.test.ts diff --git a/packages/sync-reader/src/citation.ts b/packages/sync-reader/src/citation.ts new file mode 100644 index 000000000..3646cb7bf --- /dev/null +++ b/packages/sync-reader/src/citation.ts @@ -0,0 +1,166 @@ +// Map a citation range to a contiguous span of spine sections. +// +// Section-granularity only: a page-level span within one book still selects the +// whole containing section(s), never a sub-section slice. The citation families +// seen in the curriculum are: +// - Stephanus: "VII 514a-521b" (roman book + page-letter span) +// - Bekker/Academy: "4:429", "6:434-437" (volume:page), "II.5-6" (book.chapter) +// - plain chapter/book: "ch. 2", "IV" +// A parse or match failure returns `unmapped` with a human-readable reason +// quoting the range and the nearest TOC labels considered — no fallback. + +import type { TocEntry } from "./epub-read.js"; +import { tokenize } from "./matching.js"; + +export type MapResult = + | { kind: "sections"; spineIndices: number[] } + | { kind: "unmapped"; reason: string }; + +const ROMAN = { i: 1, v: 5, x: 10, l: 50, c: 100, d: 500, m: 1000 } as const; + +/** Parse a lowercase roman numeral to a number, or null if not one. */ +function romanToNumber(s: string): number | null { + if (!/^[ivxlcdm]+$/.test(s)) return null; + let total = 0; + let prev = 0; + for (let i = s.length - 1; i >= 0; i--) { + const val = ROMAN[s[i] as keyof typeof ROMAN]; + if (val < prev) total -= val; + else { + total += val; + prev = val; + } + } + return total > 0 ? total : null; +} + +type ParsedRange = + | { kind: "designator"; forms: Set } // book/chapter label forms to match + | { kind: "page"; page: number } // volume:page → select by page bracket + | { kind: "fail"; reason: string }; + +/** Build the set of label-token forms (arabic + roman) for a designator number. */ +function designatorForms(n: number): Set { + return new Set([String(n), numberToRoman(n)]); +} + +function numberToRoman(n: number): string { + const table: [number, string][] = [ + [1000, "m"], [900, "cm"], [500, "d"], [400, "cd"], [100, "c"], [90, "xc"], + [50, "l"], [40, "xl"], [10, "x"], [9, "ix"], [5, "v"], [4, "iv"], [1, "i"], + ]; + let out = ""; + let rem = n; + for (const [v, s] of table) { + while (rem >= v) { + out += s; + rem -= v; + } + } + return out; +} + +function parseRange(range: string): ParsedRange { + const r = range.trim().toLowerCase(); + + // volume:page (or volume:page-page) — take the page (second number). + const vp = r.match(/(\d+)\s*:\s*(\d+)/); + if (vp) return { kind: "page", page: Number(vp[2]) }; + + // chapter reference: "ch. 2", "chapter 2". + const ch = r.match(/\bch(?:apter|\.)?\s*(\d+)/); + if (ch) return { kind: "designator", forms: designatorForms(Number(ch[1])) }; + + // leading roman book/part (optionally prefixed): "VII 514a", "II.5-6", "IV". + const rb = r.match(/^(?:book\s+|bk\.?\s+|part\s+)?([ivxlcdm]+)\b/); + if (rb) { + const n = romanToNumber(rb[1]); + if (n !== null) return { kind: "designator", forms: designatorForms(n) }; + } + + // leading arabic book: "4 ...", "book 4". + const ab = r.match(/^(?:book\s+|bk\.?\s+|part\s+)?(\d+)\b/); + if (ab) return { kind: "designator", forms: designatorForms(Number(ab[1])) }; + + return { kind: "fail", reason: "no recognizable book/chapter/page designator" }; +} + +/** Spine span from a matched entry through the next same-or-shallower entry. */ +function spanFrom(entry: TocEntry, ordered: TocEntry[], spineLength: number): MapResult { + if (entry.spineIndex < 0) { + return { kind: "unmapped", reason: `TOC entry "${entry.label}" has no spine mapping` }; + } + const next = ordered.find((e) => e.spineIndex > entry.spineIndex); + const end = next ? next.spineIndex - 1 : spineLength - 1; + const indices: number[] = []; + for (let i = entry.spineIndex; i <= end && i < spineLength; i++) indices.push(i); + return { kind: "sections", spineIndices: indices }; +} + +function nearestLabels(toc: TocEntry[]): string { + return toc + .slice(0, 6) + .map((e) => `"${e.label}"`) + .join(", "); +} + +/** + * Resolve `range` to spine indices against `toc`. Returns the containing + * section span, or `unmapped` with a reason naming the range and the TOC + * labels considered. + */ +export function mapRangeToSections( + range: string, + toc: TocEntry[], + spineLength: number, +): MapResult { + const parsed = parseRange(range); + if (parsed.kind === "fail") { + return { kind: "unmapped", reason: `range "${range}": ${parsed.reason}` }; + } + + const ordered = [...toc] + .filter((e) => e.spineIndex >= 0) + .sort((a, b) => a.spineIndex - b.spineIndex); + if (ordered.length === 0) { + return { kind: "unmapped", reason: `range "${range}": no spine-mapped TOC entries` }; + } + + if (parsed.kind === "designator") { + const hits = ordered.filter((e) => + tokenize(e.label).some((tok) => parsed.forms.has(tok)), + ); + if (hits.length === 1) return spanFrom(hits[0], ordered, spineLength); + if (hits.length === 0) { + return { + kind: "unmapped", + reason: `range "${range}": no TOC entry matches designator {${[...parsed.forms].join(", ")}}; nearest: ${nearestLabels(toc)}`, + }; + } + return { + kind: "unmapped", + reason: `range "${range}": designator matches multiple TOC entries (${hits.map((h) => `"${h.label}"`).join(", ")})`, + }; + } + + // page bracket: choose the section whose start page is the greatest value + // <= the cited page. A section label may carry both a volume and a page + // (e.g. "Second Section 4:406"); the page is the larger number, so bracket + // on the maximum numeric token in the label. + let chosen: { entry: TocEntry; start: number } | null = null; + for (const entry of ordered) { + const nums = tokenize(entry.label) + .map((t) => Number(t)) + .filter((n) => Number.isFinite(n)); + const start = nums.length > 0 ? Math.max(...nums) : null; + if (start === null || start > parsed.page) continue; + if (chosen === null || start > chosen.start) chosen = { entry, start }; + } + if (chosen === null) { + return { + kind: "unmapped", + reason: `range "${range}": page ${parsed.page} precedes every numbered TOC entry; nearest: ${nearestLabels(toc)}`, + }; + } + return spanFrom(chosen.entry, ordered, spineLength); +} diff --git a/packages/sync-reader/src/epub-read.ts b/packages/sync-reader/src/epub-read.ts new file mode 100644 index 000000000..7f2ce56de --- /dev/null +++ b/packages/sync-reader/src/epub-read.ts @@ -0,0 +1,260 @@ +// Read an EPUB's structure with Node primitives. +// +// Ports the container.xml → OPF pattern from print/src/local-metadata.ts to a +// Node context: bytes come from `node:fs`, the zip is opened with `unzipit` +// (which cannot write — Unit 3 uses jszip for that), and the browser-only +// `DOMParser` is replaced by `@xmldom/xmldom`. Exposes the metadata, manifest, +// spine, and a flattened table of contents (EPUB3 nav doc, EPUB2 NCX fallback) +// that the citation mapper and excerpt builder consume. + +import { readFileSync } from "node:fs"; +import { posix } from "node:path"; +import { DOMParser } from "@xmldom/xmldom"; +import { unzip } from "unzipit"; + +const DC_NS = "http://purl.org/dc/elements/1.1/"; + +/** One manifest item, its href resolved to a zip-absolute path (no fragment). */ +export interface ManifestItem { + id: string; + href: string; + mediaType: string; + properties: string; +} + +/** One flattened TOC entry, resolved to a spine index (-1 when unresolvable). */ +export interface TocEntry { + label: string; + href: string; + fragment: string; + spineIndex: number; +} + +/** The bibliographic surface used for work→epub matching. */ +export interface EpubMeta { + title: string; + creators: string[]; +} + +/** A parsed EPUB, read-only, with lazy entry access for excerpt building. */ +export interface EpubSource extends EpubMeta { + path: string; + opfDir: string; + manifest: Map; + spineItems: ManifestItem[]; + toc: TocEntry[]; + entryNames: string[]; + readText(zipPath: string): Promise; + readBytes(zipPath: string): Promise; +} + +function parseXml(xml: string): Document { + // @xmldom/xmldom does not throw on malformed XML — a missing element simply + // isn't found by the getters below (same tolerance as the browser DOMParser). + return new DOMParser().parseFromString(xml, "text/xml") as unknown as Document; +} + +/** Resolve an href against the OPF directory, splitting off any fragment. */ +function resolveHref( + opfDir: string, + href: string, +): { path: string; fragment: string } { + const hashIdx = href.indexOf("#"); + const fragment = hashIdx === -1 ? "" : href.slice(hashIdx + 1); + const rawRel = hashIdx === -1 ? href : href.slice(0, hashIdx); + let rel: string; + try { + rel = decodeURIComponent(rawRel); + } catch { + rel = rawRel; + } + const joined = opfDir === "" ? rel : posix.join(opfDir, rel); + return { path: posix.normalize(joined), fragment }; +} + +function textOf(node: { textContent?: string | null } | undefined): string { + return (node?.textContent ?? "").trim(); +} + +/** All dc:creator values (namespaced and unprefixed), trimmed and non-empty. */ +function readCreators(opf: Document): string[] { + const creators: string[] = []; + const ns = opf.getElementsByTagNameNS(DC_NS, "creator"); + for (let i = 0; i < ns.length; i++) { + const t = textOf(ns[i]); + if (t.length > 0) creators.push(t); + } + if (creators.length === 0) { + const plain = opf.getElementsByTagName("creator"); + for (let i = 0; i < plain.length; i++) { + const t = textOf(plain[i]); + if (t.length > 0) creators.push(t); + } + } + return creators; +} + +function readTitle(opf: Document): string { + const ns = opf.getElementsByTagNameNS(DC_NS, "title")[0]; + const fromNs = textOf(ns); + if (fromNs.length > 0) return fromNs; + return textOf(opf.getElementsByTagName("title")[0]); +} + +function readManifest( + opf: Document, + opfDir: string, +): Map { + const manifest = new Map(); + const items = opf.getElementsByTagName("item"); + for (let i = 0; i < items.length; i++) { + const el = items[i]; + const id = el.getAttribute("id"); + const href = el.getAttribute("href"); + if (!id || !href) continue; + manifest.set(id, { + id, + href: resolveHref(opfDir, href).path, + mediaType: el.getAttribute("media-type") ?? "", + properties: el.getAttribute("properties") ?? "", + }); + } + return manifest; +} + +function readSpine( + opf: Document, + manifest: Map, +): { spineItems: ManifestItem[]; tocNcxId: string | null } { + const spineEl = opf.getElementsByTagName("spine")[0]; + const spineItems: ManifestItem[] = []; + const tocNcxId = spineEl?.getAttribute("toc") ?? null; + const itemrefs = opf.getElementsByTagName("itemref"); + for (let i = 0; i < itemrefs.length; i++) { + const idref = itemrefs[i].getAttribute("idref"); + if (!idref) continue; + const item = manifest.get(idref); + if (item) spineItems.push(item); + } + return { spineItems, tocNcxId }; +} + +/** Map a resolved zip path to its spine index, else -1. */ +function spineIndexOf(spineItems: ManifestItem[], path: string): number { + return spineItems.findIndex((it) => it.href === path); +} + +/** Parse the EPUB3 nav doc's toc