@milesnl/ptx-core 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 Nathan Miles
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,32 @@
1
- # Temporary Holding Version
1
+ # @milesnl/ptx-core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Library for reading data from Paratext project folders: scripture text (USFM),
4
+ AKG-style interlinear glosses, and the Biblical Terms list. It is the data layer
5
+ used by the [`@milesnl/ptx-mcp`](https://www.npmjs.com/package/@milesnl/ptx-mcp) MCP server.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @milesnl/ptx-core
11
+ ```
12
+
13
+ Requires Node.js 20.11 or later. ESM only.
14
+
15
+ ## Usage
16
+
17
+ ```ts
18
+ import { resolveProjectsRoot, listProjects, getScripture } from "@milesnl/ptx-core"
19
+
20
+ const root = resolveProjectsRoot("/path/to/My Paratext 9 Projects")
21
+ console.log(listProjects(root))
22
+ ```
23
+
24
+ Everything exported from the modules below is available from the package root:
25
+ `discovery` (project folders), `scripture`, `usfmBook`, `interlinear`, `books`, and `biblicalTerms`.
26
+
27
+ The Biblical Terms functions take the path to `BiblicalTerms.xml`; use
28
+ `resolveBiblicalTermsPath(paratextInstallDir?)` to locate it.
29
+
30
+ ## License
31
+
32
+ ISC
@@ -0,0 +1,38 @@
1
+ export declare class BiblicalTermsNotFoundError extends Error {
2
+ constructor(triedPaths: string[]);
3
+ }
4
+ export declare class TermNotFoundError extends Error {
5
+ constructor(id: string);
6
+ }
7
+ export interface TermSummary {
8
+ id: string;
9
+ gloss: string;
10
+ transliteration: string;
11
+ language: string;
12
+ category: string;
13
+ strong: string[];
14
+ refCount: number;
15
+ }
16
+ export interface TermRefsRequest {
17
+ termsPath: string;
18
+ id: string;
19
+ book?: string;
20
+ startChapter?: number;
21
+ startVerse?: number;
22
+ endChapter?: number;
23
+ endVerse?: number;
24
+ refs?: string;
25
+ }
26
+ export declare const DEFAULT_TERMS_PATHS: string[];
27
+ export declare function resolveBiblicalTermsPath(paratextInstallDir?: string, candidates?: string[]): string;
28
+ export declare class TermRenderingsNotFoundError extends Error {
29
+ constructor(projectId: string);
30
+ }
31
+ export interface TermRendering {
32
+ renderings: string;
33
+ denials: string;
34
+ notes: string;
35
+ }
36
+ export declare function searchTerms(termsPath: string, search: string, book?: string): TermSummary[];
37
+ export declare function getTermRefs(request: TermRefsRequest): string;
38
+ export declare function getTermRendering(projectsRoot: string, projectId: string, id: string): TermRendering;
@@ -0,0 +1,262 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { bookCodeFromNumber, bookNumberFromCode, isBookCode } from "./books.js";
4
+ import { getProjectDir } from "./discovery.js";
5
+ import { parseRefs } from "./interlinear.js";
6
+ import { InvalidReferenceError } from "./scripture.js";
7
+ // Biblical Terms list layout is documented in src/md/bt/bt_spec.md.
8
+ export class BiblicalTermsNotFoundError extends Error {
9
+ constructor(triedPaths) {
10
+ super(`Biblical Terms list not found. Tried: ${triedPaths.join("; ")}`);
11
+ this.name = "BiblicalTermsNotFoundError";
12
+ }
13
+ }
14
+ export class TermNotFoundError extends Error {
15
+ constructor(id) {
16
+ super(`Biblical term not found: ${id}`);
17
+ this.name = "TermNotFoundError";
18
+ }
19
+ }
20
+ const TERMS_FILE = "BiblicalTerms.xml";
21
+ // Default locations of the Biblical Terms list: Paratext install dirs, then this repo's copy.
22
+ export const DEFAULT_TERMS_PATHS = [
23
+ "C:\\Program Files (x86)\\Paratext 9\\Terms\\Lists\\" + TERMS_FILE,
24
+ "C:\\Program Files\\Paratext 9\\Terms\\Lists\\" + TERMS_FILE,
25
+ path.join(import.meta.dirname, "..", "myParatext", "Lists", TERMS_FILE)
26
+ ];
27
+ export function resolveBiblicalTermsPath(paratextInstallDir, candidates = DEFAULT_TERMS_PATHS) {
28
+ if (paratextInstallDir) {
29
+ const configured = path.join(paratextInstallDir, "Terms", "Lists", TERMS_FILE);
30
+ if (!fs.existsSync(configured))
31
+ throw new BiblicalTermsNotFoundError([configured]);
32
+ return configured;
33
+ }
34
+ const found = candidates.find((candidate) => fs.existsSync(candidate));
35
+ if (!found)
36
+ throw new BiblicalTermsNotFoundError(candidates);
37
+ return found;
38
+ }
39
+ // ---------- parsing ----------
40
+ function decodeXml(value) {
41
+ return value
42
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
43
+ .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
44
+ .replace(/&lt;/g, "<")
45
+ .replace(/&gt;/g, ">")
46
+ .replace(/&quot;/g, "\"")
47
+ .replace(/&apos;/g, "'")
48
+ .replace(/&amp;/g, "&");
49
+ }
50
+ // Lowercase and strip diacritics (incl. Greek accents and Hebrew points).
51
+ function normalizeSearch(value) {
52
+ return value.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
53
+ }
54
+ function element(body, name) {
55
+ const match = body.match(new RegExp(`<${name}>([^<]*)</${name}>`));
56
+ return match ? decodeXml(match[1]).trim() : "";
57
+ }
58
+ function parseVerseRefs(body) {
59
+ const unique = new Map();
60
+ for (const match of body.matchAll(/<Verse>\s*(\d{9})\d{5}[^<]*<\/Verse>/g)) {
61
+ // BBBCCCVVV + 5-digit word position (dropped); rare suffixes like "{N:001}" ignored.
62
+ const digits = match[1];
63
+ if (unique.has(digits))
64
+ continue;
65
+ unique.set(digits, {
66
+ bookNumber: parseInt(digits.slice(0, 3), 10),
67
+ chapter: parseInt(digits.slice(3, 6), 10),
68
+ verse: parseInt(digits.slice(6, 9), 10)
69
+ });
70
+ }
71
+ return [...unique.values()].sort(compareVerseRefs);
72
+ }
73
+ function compareVerseRefs(a, b) {
74
+ return a.bookNumber - b.bookNumber || a.chapter - b.chapter || a.verse - b.verse;
75
+ }
76
+ function parseTerms(xml) {
77
+ const terms = [];
78
+ for (const match of xml.matchAll(/<Term\s+Id="([^"]*)"\s*>([\s\S]*?)<\/Term>/g)) {
79
+ const id = decodeXml(match[1]);
80
+ const body = match[2];
81
+ const strong = element(body, "Strong")
82
+ .split(",")
83
+ .map((s) => s.trim())
84
+ .filter((s) => s.length > 0);
85
+ const transliteration = element(body, "Transliteration");
86
+ const definition = element(body, "Definition");
87
+ const gloss = element(body, "Gloss");
88
+ terms.push({
89
+ id,
90
+ strong,
91
+ transliteration,
92
+ category: element(body, "Category"),
93
+ language: element(body, "Language"),
94
+ definition,
95
+ gloss,
96
+ verses: parseVerseRefs(body),
97
+ searchText: [gloss, id, transliteration, definition].map(normalizeSearch)
98
+ });
99
+ }
100
+ return terms;
101
+ }
102
+ const termsCache = new Map();
103
+ function loadTerms(termsPath) {
104
+ const mtimeMs = fs.statSync(termsPath).mtimeMs;
105
+ const cached = termsCache.get(termsPath);
106
+ if (cached && cached.mtimeMs === mtimeMs)
107
+ return cached.value;
108
+ const terms = parseTerms(fs.readFileSync(termsPath, "utf8"));
109
+ // Ids in the file are not NFC-normalized; index by NFC so client-supplied Ids match.
110
+ const byId = new Map(terms.map((term) => [term.id.normalize("NFC"), term]));
111
+ const value = { terms, byId };
112
+ termsCache.set(termsPath, { mtimeMs, value });
113
+ return value;
114
+ }
115
+ // ---------- filtering ----------
116
+ function toBookNumber(book) {
117
+ const code = book.toUpperCase();
118
+ const bookNumber = isBookCode(code) ? bookNumberFromCode(code) : undefined;
119
+ if (bookNumber === undefined)
120
+ throw new InvalidReferenceError(`Not a valid USFM book code: ${book}`);
121
+ return bookNumber;
122
+ }
123
+ // A span with get-scripture defaults, but unbounded ends (no USFM needed).
124
+ function inSpan(ref, bookNumber, span) {
125
+ if (ref.bookNumber !== bookNumber)
126
+ return false;
127
+ if (span.startChapter === undefined)
128
+ return true;
129
+ const startChapter = span.startChapter;
130
+ const endChapter = span.endChapter ?? startChapter;
131
+ if (endChapter < startChapter) {
132
+ throw new InvalidReferenceError(`End chapter ${endChapter} is before start chapter ${startChapter}`);
133
+ }
134
+ const startVerse = span.startVerse ?? 1;
135
+ const endVerse = span.endVerse ?? Number.POSITIVE_INFINITY;
136
+ const afterStart = ref.chapter > startChapter || (ref.chapter === startChapter && ref.verse >= startVerse);
137
+ const beforeEnd = ref.chapter < endChapter || (ref.chapter === endChapter && ref.verse <= endVerse);
138
+ return afterStart && beforeEnd;
139
+ }
140
+ function formatRefs(verses) {
141
+ const parts = [];
142
+ let previousBook;
143
+ for (const ref of verses) {
144
+ const book = bookCodeFromNumber(ref.bookNumber);
145
+ if (!book)
146
+ continue;
147
+ const prefix = book === previousBook ? "" : `${book} `;
148
+ parts.push(`${prefix}${ref.chapter}:${ref.verse}`);
149
+ previousBook = book;
150
+ }
151
+ return parts.join("; ");
152
+ }
153
+ // ---------- project TermRenderings.xml ----------
154
+ export class TermRenderingsNotFoundError extends Error {
155
+ constructor(projectId) {
156
+ super(`No TermRenderings.xml in project ${projectId}`);
157
+ this.name = "TermRenderingsNotFoundError";
158
+ }
159
+ }
160
+ const renderingsCache = new Map();
161
+ // <Denial> is a BBBCCCVVV verse number with leading zeros dropped, e.g. 41001001 = MRK 1:1.
162
+ function parseDenials(body) {
163
+ const unique = new Map();
164
+ for (const match of body.matchAll(/<Denial>\s*(\d+)\s*<\/Denial>/g)) {
165
+ const value = parseInt(match[1], 10);
166
+ unique.set(value, {
167
+ bookNumber: Math.floor(value / 1_000_000),
168
+ chapter: Math.floor(value / 1000) % 1000,
169
+ verse: value % 1000
170
+ });
171
+ }
172
+ return [...unique.values()].sort(compareVerseRefs);
173
+ }
174
+ // NFC term Id -> renderings and denials.
175
+ function parseTermRenderings(xml) {
176
+ const renderings = new Map();
177
+ const termPattern = /<TermRendering\s+Id="([^"]*)"[^>]*?(?:\/>|>([\s\S]*?)<\/TermRendering>)/g;
178
+ for (const match of xml.matchAll(termPattern)) {
179
+ const id = decodeXml(match[1]).normalize("NFC");
180
+ const body = match[2] ?? "";
181
+ const value = body.match(/<Renderings>([^<]*)<\/Renderings>/);
182
+ const notes = body.match(/<Notes>([^<]*)<\/Notes>/);
183
+ renderings.set(id, {
184
+ renderings: value ? decodeXml(value[1]) : "",
185
+ denials: parseDenials(body),
186
+ notes: notes ? decodeXml(notes[1]).replace(/\r\n?/g, "\n") : ""
187
+ });
188
+ }
189
+ return renderings;
190
+ }
191
+ function loadTermRenderings(projectDir, projectId) {
192
+ const filePath = path.join(projectDir, "TermRenderings.xml");
193
+ if (!fs.existsSync(filePath))
194
+ throw new TermRenderingsNotFoundError(projectId);
195
+ const mtimeMs = fs.statSync(filePath).mtimeMs;
196
+ const cached = renderingsCache.get(filePath);
197
+ if (cached && cached.mtimeMs === mtimeMs)
198
+ return cached.value;
199
+ const value = parseTermRenderings(fs.readFileSync(filePath, "utf8"));
200
+ renderingsCache.set(filePath, { mtimeMs, value });
201
+ return value;
202
+ }
203
+ // ---------- public API ----------
204
+ export function searchTerms(termsPath, search, book) {
205
+ const query = normalizeSearch(search.trim());
206
+ if (query.length === 0)
207
+ throw new InvalidReferenceError("Search string must not be empty");
208
+ const bookNumber = book === undefined ? undefined : toBookNumber(book);
209
+ const strongQuery = search.trim().toUpperCase();
210
+ return loadTerms(termsPath).terms
211
+ .filter((term) => term.searchText.some((text) => text.includes(query))
212
+ || term.strong.some((s) => s.toUpperCase() === strongQuery))
213
+ .filter((term) => bookNumber === undefined || term.verses.some((v) => v.bookNumber === bookNumber))
214
+ .map((term) => ({
215
+ id: term.id,
216
+ gloss: term.gloss,
217
+ transliteration: term.transliteration,
218
+ language: term.language,
219
+ category: term.category,
220
+ strong: term.strong,
221
+ refCount: term.verses.length
222
+ }));
223
+ }
224
+ export function getTermRefs(request) {
225
+ const term = loadTerms(request.termsPath).byId.get(request.id.trim().normalize("NFC"));
226
+ if (!term)
227
+ throw new TermNotFoundError(request.id);
228
+ const hasStructured = request.book !== undefined
229
+ || request.startChapter !== undefined
230
+ || request.startVerse !== undefined
231
+ || request.endChapter !== undefined
232
+ || request.endVerse !== undefined;
233
+ const hasRefs = request.refs !== undefined;
234
+ if (hasStructured && hasRefs) {
235
+ throw new InvalidReferenceError("Specify either refs or book/chapter/verse, not both");
236
+ }
237
+ let spans;
238
+ if (hasRefs) {
239
+ spans = parseRefs(request.refs ?? "");
240
+ }
241
+ else if (hasStructured) {
242
+ if (request.book === undefined)
243
+ throw new InvalidReferenceError("book is required with chapter/verse");
244
+ const { startChapter, startVerse, endChapter, endVerse } = request;
245
+ spans = [{ book: request.book, span: { startChapter, startVerse, endChapter, endVerse } }];
246
+ }
247
+ const filters = spans?.map(({ book, span }) => ({ bookNumber: toBookNumber(book), span }));
248
+ const verses = filters
249
+ ? term.verses.filter((ref) => filters.some((f) => inSpan(ref, f.bookNumber, f.span)))
250
+ : term.verses;
251
+ return formatRefs(verses);
252
+ }
253
+ // The project's renderings of a term, exactly as stored in TermRenderings.xml
254
+ // ("*" is a wildcard, "||" separates alternatives), plus its denied verses as a refs string
255
+ // and its notes.
256
+ export function getTermRendering(projectsRoot, projectId, id) {
257
+ const projectDir = getProjectDir(projectsRoot, projectId);
258
+ const rendering = loadTermRenderings(projectDir, projectId).get(id.trim().normalize("NFC"));
259
+ if (rendering === undefined)
260
+ throw new TermNotFoundError(id);
261
+ return { renderings: rendering.renderings, denials: formatRefs(rendering.denials), notes: rendering.notes };
262
+ }
@@ -0,0 +1,5 @@
1
+ export declare const BOOK_CODES: readonly ["GEN", "EXO", "LEV", "NUM", "DEU", "JOS", "JDG", "RUT", "1SA", "2SA", "1KI", "2KI", "1CH", "2CH", "EZR", "NEH", "EST", "JOB", "PSA", "PRO", "ECC", "SNG", "ISA", "JER", "LAM", "EZK", "DAN", "HOS", "JOL", "AMO", "OBA", "JON", "MIC", "NAM", "HAB", "ZEP", "HAG", "ZEC", "MAL", "MAT", "MRK", "LUK", "JHN", "ACT", "ROM", "1CO", "2CO", "GAL", "EPH", "PHP", "COL", "1TH", "2TH", "1TI", "2TI", "TIT", "PHM", "HEB", "JAS", "1PE", "2PE", "1JN", "2JN", "3JN", "JUD", "REV", "TOB", "JDT", "ESG", "WIS", "SIR", "BAR", "LJE", "S3Y", "SUS", "BEL", "1MA", "2MA", "3MA", "4MA", "1ES", "2ES", "MAN", "PS2"];
2
+ export type BookCode = (typeof BOOK_CODES)[number];
3
+ export declare function isBookCode(value: string): value is BookCode;
4
+ export declare function bookCodeFromNumber(bookNumber: number): BookCode | undefined;
5
+ export declare function bookNumberFromCode(code: string): number | undefined;
package/dist/books.js ADDED
@@ -0,0 +1,24 @@
1
+ export const BOOK_CODES = [
2
+ "GEN", "EXO", "LEV", "NUM", "DEU", "JOS", "JDG", "RUT", "1SA", "2SA",
3
+ "1KI", "2KI", "1CH", "2CH", "EZR", "NEH", "EST", "JOB", "PSA", "PRO",
4
+ "ECC", "SNG", "ISA", "JER", "LAM", "EZK", "DAN", "HOS", "JOL", "AMO",
5
+ "OBA", "JON", "MIC", "NAM", "HAB", "ZEP", "HAG", "ZEC", "MAL",
6
+ "MAT", "MRK", "LUK", "JHN", "ACT", "ROM", "1CO", "2CO", "GAL", "EPH",
7
+ "PHP", "COL", "1TH", "2TH", "1TI", "2TI", "TIT", "PHM", "HEB", "JAS",
8
+ "1PE", "2PE", "1JN", "2JN", "3JN", "JUD", "REV",
9
+ "TOB", "JDT", "ESG", "WIS", "SIR", "BAR", "LJE", "S3Y", "SUS", "BEL",
10
+ "1MA", "2MA", "3MA", "4MA", "1ES", "2ES", "MAN", "PS2"
11
+ ];
12
+ const BOOK_CODE_SET = new Set(BOOK_CODES);
13
+ export function isBookCode(value) {
14
+ return BOOK_CODE_SET.has(value.toUpperCase());
15
+ }
16
+ // BOOK_CODES is in Paratext canon order, so a 1-based Paratext book number
17
+ // (e.g. 040 = MAT, 077 = 1MA) indexes it directly.
18
+ export function bookCodeFromNumber(bookNumber) {
19
+ return BOOK_CODES[bookNumber - 1];
20
+ }
21
+ export function bookNumberFromCode(code) {
22
+ const index = BOOK_CODES.indexOf(code.toUpperCase());
23
+ return index < 0 ? undefined : index + 1;
24
+ }
package/dist/core.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from "./biblicalTerms.js";
2
+ export * from "./books.js";
3
+ export * from "./discovery.js";
4
+ export * from "./interlinear.js";
5
+ export * from "./scripture.js";
6
+ export * from "./usfmBook.js";
package/dist/core.js ADDED
@@ -0,0 +1,7 @@
1
+ // Entry point of the @milesnl/ptx-core library (see packages/core).
2
+ export * from "./biblicalTerms.js";
3
+ export * from "./books.js";
4
+ export * from "./discovery.js";
5
+ export * from "./interlinear.js";
6
+ export * from "./scripture.js";
7
+ export * from "./usfmBook.js";
@@ -0,0 +1,9 @@
1
+ export declare class ProjectsRootNotFoundError extends Error {
2
+ constructor(root: string);
3
+ }
4
+ export declare class ProjectNotFoundError extends Error {
5
+ constructor(projectId: string);
6
+ }
7
+ export declare function resolveProjectsRoot(cliArg?: string): string;
8
+ export declare function listProjects(projectsRoot: string): string[];
9
+ export declare function getProjectDir(projectsRoot: string, projectId: string): string;
@@ -0,0 +1,37 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const WINDOWS_DEFAULT_PROJECTS_DIR = "C:\\My Paratext 9 Projects";
4
+ export class ProjectsRootNotFoundError extends Error {
5
+ constructor(root) {
6
+ super(`Paratext projects folder not found: ${root}`);
7
+ this.name = "ProjectsRootNotFoundError";
8
+ }
9
+ }
10
+ export class ProjectNotFoundError extends Error {
11
+ constructor(projectId) {
12
+ super(`Paratext project not found: ${projectId}`);
13
+ this.name = "ProjectNotFoundError";
14
+ }
15
+ }
16
+ export function resolveProjectsRoot(cliArg) {
17
+ const root = cliArg ?? WINDOWS_DEFAULT_PROJECTS_DIR;
18
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
19
+ throw new ProjectsRootNotFoundError(root);
20
+ }
21
+ return root;
22
+ }
23
+ export function listProjects(projectsRoot) {
24
+ return fs
25
+ .readdirSync(projectsRoot, { withFileTypes: true })
26
+ .filter((entry) => entry.isDirectory())
27
+ .filter((entry) => fs.existsSync(path.join(projectsRoot, entry.name, "Settings.xml")))
28
+ .map((entry) => entry.name)
29
+ .sort();
30
+ }
31
+ export function getProjectDir(projectsRoot, projectId) {
32
+ const dir = path.join(projectsRoot, projectId);
33
+ if (!fs.existsSync(dir) || !fs.existsSync(path.join(dir, "Settings.xml"))) {
34
+ throw new ProjectNotFoundError(projectId);
35
+ }
36
+ return dir;
37
+ }
@@ -0,0 +1,45 @@
1
+ export declare class InterlinearNotFoundError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ export type GlossSource = "a" | "g" | "l" | "-";
5
+ export type WordGloss = [string, string] | [string, string, GlossSource];
6
+ export interface InterlinearVerse {
7
+ ref: string;
8
+ words: WordGloss[];
9
+ }
10
+ export interface InterlinearRequest {
11
+ projectsRoot: string;
12
+ project: string;
13
+ language: string;
14
+ book?: string;
15
+ startChapter?: number;
16
+ startVerse?: number;
17
+ endChapter?: number;
18
+ endVerse?: number;
19
+ refs?: string;
20
+ allowPartial?: boolean;
21
+ showSource?: boolean;
22
+ }
23
+ export interface InterlinearResult {
24
+ verses: InterlinearVerse[];
25
+ missing: string[];
26
+ }
27
+ export interface Span {
28
+ startChapter?: number;
29
+ startVerse?: number;
30
+ endChapter?: number;
31
+ endVerse?: number;
32
+ }
33
+ export interface BookSpan {
34
+ book: string;
35
+ span: Span;
36
+ }
37
+ interface Token {
38
+ index: number;
39
+ text: string;
40
+ }
41
+ export declare function tokenize(segment: string): Token[];
42
+ export declare function parseRefs(refs: string): BookSpan[];
43
+ export declare function listInterlinearLanguages(projectsRoot: string, projectId: string, book?: string): string[];
44
+ export declare function getInterlinear(request: InterlinearRequest): InterlinearResult;
45
+ export {};
@@ -0,0 +1,485 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { bookNumberFromCode, isBookCode } from "./books.js";
4
+ import { getProjectDir } from "./discovery.js";
5
+ import { BookNotFoundError, InvalidReferenceError, loadBook, VerseNotFoundError } from "./scripture.js";
6
+ import { indexProjectBooks } from "./usfmBook.js";
7
+ // Interlinear data layout is documented in src/md/it/it_spec.md; best-effort glossing in it_update1.md.
8
+ export class InterlinearNotFoundError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = "InterlinearNotFoundError";
12
+ }
13
+ }
14
+ const NO_GLOSS = "_";
15
+ const LANGUAGE_PATTERN = /^[A-Za-z0-9_-]+$/;
16
+ const INTERLINEAR_DIR_PREFIX = "Interlinear_";
17
+ // ---------- small helpers ----------
18
+ function decodeXml(value) {
19
+ return value
20
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
21
+ .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
22
+ .replace(/&lt;/g, "<")
23
+ .replace(/&gt;/g, ">")
24
+ .replace(/&quot;/g, "\"")
25
+ .replace(/&apos;/g, "'")
26
+ .replace(/&amp;/g, "&");
27
+ }
28
+ function attr(tag, name) {
29
+ const match = tag.match(new RegExp(`\\b${name}="([^"]*)"`));
30
+ return match ? decodeXml(match[1]) : undefined;
31
+ }
32
+ function normalizeForm(value) {
33
+ return value.normalize("NFC").toLowerCase();
34
+ }
35
+ function parseVerseRange(label) {
36
+ const match = label.match(/^(\d+)(?:-(\d+))?$/);
37
+ if (!match)
38
+ return undefined;
39
+ const start = parseInt(match[1], 10);
40
+ const end = match[2] ? parseInt(match[2], 10) : start;
41
+ return { start, end };
42
+ }
43
+ // Cache parsed files, keyed by path; re-parse when the file changes on disk.
44
+ function cachedByMtime(cache, filePath, parse) {
45
+ const mtimeMs = fs.statSync(filePath).mtimeMs;
46
+ const cached = cache.get(filePath);
47
+ if (cached && cached.mtimeMs === mtimeMs)
48
+ return cached.value;
49
+ const value = parse();
50
+ cache.set(filePath, { mtimeMs, value });
51
+ return value;
52
+ }
53
+ // ---------- lexicon.xml ----------
54
+ const lexiconCache = new Map();
55
+ function parseLexicon(xml) {
56
+ const byLexemeSense = new Map();
57
+ const senseGloss = new Map();
58
+ const senseCount = new Map();
59
+ const wordForms = new Map();
60
+ const otherForms = new Map();
61
+ const itemPattern = /<Lexeme\b([^>]*)\/>([\s\S]*?)<\/item>/g;
62
+ const sensePattern = /<Sense\b([^>]*)>\s*<Gloss\b([^>]*?)(?:\/>|>([^<]*)<\/Gloss>)/g;
63
+ for (const item of xml.matchAll(itemPattern)) {
64
+ const type = attr(item[1], "Type");
65
+ const form = attr(item[1], "Form");
66
+ if (!type || form === undefined)
67
+ continue;
68
+ const forms = type === "Word" ? wordForms : otherForms;
69
+ const formKey = normalizeForm(form);
70
+ for (const sense of item[2].matchAll(sensePattern)) {
71
+ const senseId = attr(sense[1], "Id");
72
+ if (!senseId)
73
+ continue;
74
+ const gloss = decodeXml(sense[3] ?? "");
75
+ byLexemeSense.set(`${type}:${form}#${senseId}`, gloss);
76
+ senseGloss.set(senseId, gloss);
77
+ senseCount.set(senseId, (senseCount.get(senseId) ?? 0) + 1);
78
+ const language = attr(sense[2], "Language");
79
+ if (!language || !gloss.trim())
80
+ continue;
81
+ const firstByLanguage = forms.get(formKey) ?? new Map();
82
+ if (!firstByLanguage.has(language))
83
+ firstByLanguage.set(language, gloss);
84
+ forms.set(formKey, firstByLanguage);
85
+ }
86
+ }
87
+ const byForm = new Map(otherForms);
88
+ for (const [formKey, firstByLanguage] of wordForms) {
89
+ byForm.set(formKey, new Map([...(otherForms.get(formKey) ?? []), ...firstByLanguage]));
90
+ }
91
+ const bySense = new Map();
92
+ for (const [senseId, gloss] of senseGloss) {
93
+ if (senseCount.get(senseId) === 1)
94
+ bySense.set(senseId, gloss);
95
+ }
96
+ return { byLexemeSense, bySense, byForm };
97
+ }
98
+ function loadLexicon(projectDir) {
99
+ const filePath = path.join(projectDir, "lexicon.xml");
100
+ if (!fs.existsSync(filePath))
101
+ return { byLexemeSense: new Map(), bySense: new Map(), byForm: new Map() };
102
+ return cachedByMtime(lexiconCache, filePath, () => parseLexicon(fs.readFileSync(filePath, "utf8")));
103
+ }
104
+ function lookupGloss(lexicon, lexeme) {
105
+ if (!lexeme.glossId)
106
+ return null;
107
+ return lexicon.byLexemeSense.get(`${lexeme.type}:${lexeme.form}#${lexeme.glossId}`)
108
+ ?? lexicon.bySense.get(lexeme.glossId)
109
+ ?? null;
110
+ }
111
+ // ---------- Interlinear_<lang>_<BOOK>.xml ----------
112
+ const interlinearCache = new Map();
113
+ function parseInterlinear(xml) {
114
+ const items = new Map();
115
+ const itemPattern = /<item>\s*<string>([^<]*)<\/string>([\s\S]*?)<\/item>/g;
116
+ const clusterPattern = /<Cluster>([\s\S]*?)<\/Cluster>/g;
117
+ const lexemePattern = /<Lexeme\b([^>]*)\/>/g;
118
+ for (const item of xml.matchAll(itemPattern)) {
119
+ // "ROM 1:1" or "ROM 1:9-10"
120
+ const refMatch = decodeXml(item[1]).trim().match(/^\S+\s+(\d+):(\S+)$/);
121
+ if (!refMatch)
122
+ continue;
123
+ const chapter = parseInt(refMatch[1], 10);
124
+ const label = refMatch[2];
125
+ const range = parseVerseRange(label);
126
+ if (!range)
127
+ continue;
128
+ const clusters = [];
129
+ for (const cluster of item[2].matchAll(clusterPattern)) {
130
+ const body = cluster[1];
131
+ const rangeTag = body.match(/<Range\b([^>]*)\/>/);
132
+ if (!rangeTag)
133
+ continue;
134
+ const lexemes = [];
135
+ for (const lexeme of body.matchAll(lexemePattern)) {
136
+ const id = attr(lexeme[1], "Id") ?? "";
137
+ const colon = id.indexOf(":");
138
+ if (colon < 0)
139
+ continue;
140
+ lexemes.push({
141
+ type: id.slice(0, colon),
142
+ form: id.slice(colon + 1),
143
+ glossId: attr(lexeme[1], "GlossId")
144
+ });
145
+ }
146
+ clusters.push({
147
+ index: parseInt(attr(rangeTag[1], "Index") ?? "0", 10),
148
+ length: parseInt(attr(rangeTag[1], "Length") ?? "0", 10),
149
+ lexemes,
150
+ excluded: /<Excluded>\s*true\s*<\/Excluded>/.test(body)
151
+ });
152
+ }
153
+ const parsed = { chapter, label, ...range, clusters };
154
+ items.set(`${chapter}:${label}`, parsed);
155
+ }
156
+ return { items };
157
+ }
158
+ function interlinearDir(projectDir, language) {
159
+ return path.join(projectDir, `${INTERLINEAR_DIR_PREFIX}${language}`);
160
+ }
161
+ function interlinearFile(projectDir, language, book) {
162
+ return path.join(interlinearDir(projectDir, language), `${INTERLINEAR_DIR_PREFIX}${language}_${book}.xml`);
163
+ }
164
+ function loadInterlinearFile(filePath) {
165
+ return cachedByMtime(interlinearCache, filePath, () => parseInterlinear(fs.readFileSync(filePath, "utf8")));
166
+ }
167
+ // Interlinear data for a book, or undefined if the language has no file for it.
168
+ function loadInterlinear(projectDir, language, book) {
169
+ const filePath = interlinearFile(projectDir, language, book);
170
+ return fs.existsSync(filePath) ? loadInterlinearFile(filePath) : undefined;
171
+ }
172
+ const corpusCache = new Map();
173
+ function interlinearFiles(dir, language) {
174
+ const prefix = `${INTERLINEAR_DIR_PREFIX}${language}_`;
175
+ const bookOf = (file) => file.slice(prefix.length, -".xml".length).toUpperCase();
176
+ const order = (file) => bookNumberFromCode(bookOf(file)) ?? Number.MAX_SAFE_INTEGER;
177
+ return fs
178
+ .readdirSync(dir)
179
+ .filter((file) => file.startsWith(prefix) && file.toLowerCase().endsWith(".xml"))
180
+ // Canon order so ties resolve to the first gloss seen in canon order.
181
+ .sort((a, b) => order(a) - order(b) || a.localeCompare(b))
182
+ .map((file) => path.join(dir, file));
183
+ }
184
+ // Most common gloss for each word form across all books of a gloss language (it_update1.md).
185
+ function loadCorpus(dir, language, lexicon) {
186
+ const files = interlinearFiles(dir, language);
187
+ const signature = files.map((file) => `${file}:${fs.statSync(file).mtimeMs}`).join("|");
188
+ const cached = corpusCache.get(dir);
189
+ if (cached && cached.signature === signature && cached.lexicon === lexicon)
190
+ return cached.glosses;
191
+ // form -> gloss -> count; Map insertion order records first-seen order for ties.
192
+ const counts = new Map();
193
+ for (const file of files) {
194
+ for (const item of loadInterlinearFile(file).items.values()) {
195
+ for (const cluster of item.clusters) {
196
+ if (cluster.excluded || !isWordCluster(cluster))
197
+ continue;
198
+ const lexeme = cluster.lexemes[0];
199
+ const gloss = lookupGloss(lexicon, lexeme);
200
+ if (!gloss)
201
+ continue;
202
+ const form = normalizeForm(lexeme.form);
203
+ const formCounts = counts.get(form) ?? new Map();
204
+ formCounts.set(gloss, (formCounts.get(gloss) ?? 0) + 1);
205
+ counts.set(form, formCounts);
206
+ }
207
+ }
208
+ }
209
+ const glosses = new Map();
210
+ for (const [form, formCounts] of counts) {
211
+ let best;
212
+ let bestCount = 0;
213
+ for (const [gloss, count] of formCounts) {
214
+ if (count > bestCount) {
215
+ best = gloss;
216
+ bestCount = count;
217
+ }
218
+ }
219
+ if (best !== undefined)
220
+ glosses.set(form, best);
221
+ }
222
+ corpusCache.set(dir, { signature, lexicon, glosses });
223
+ return glosses;
224
+ }
225
+ // ---------- raw USFM verse segments ----------
226
+ const segmentCache = new Map();
227
+ // Split raw USFM (LF line endings) into segments keyed "C:label". Segment "C:V" starts at
228
+ // its \v marker; "C:0" starts at \c C (or file start for chapter 1). Segments end at the
229
+ // next \c or \v marker. Interlinear Range offsets are relative to these segments.
230
+ function splitSegments(usfm) {
231
+ const segments = new Map();
232
+ let chapter = 1;
233
+ let key = "1:0";
234
+ let segmentStart = 0;
235
+ for (const marker of usfm.matchAll(/\\(c|v)\s+(\d+(?:-\d+)?)/g)) {
236
+ const markerStart = marker.index ?? 0;
237
+ if (marker[1] === "c") {
238
+ const nextChapter = parseInt(marker[2], 10);
239
+ if (nextChapter === 1 && key === "1:0")
240
+ continue;
241
+ segments.set(key, usfm.slice(segmentStart, markerStart));
242
+ chapter = nextChapter;
243
+ key = `${chapter}:0`;
244
+ }
245
+ else {
246
+ segments.set(key, usfm.slice(segmentStart, markerStart));
247
+ key = `${chapter}:${marker[2]}`;
248
+ }
249
+ segmentStart = markerStart;
250
+ }
251
+ segments.set(key, usfm.slice(segmentStart));
252
+ return segments;
253
+ }
254
+ function loadSegments(projectDir, projectId, book) {
255
+ const filePath = indexProjectBooks(projectDir).get(book);
256
+ if (!filePath)
257
+ throw new BookNotFoundError(projectId, book);
258
+ return cachedByMtime(segmentCache, filePath, () => splitSegments(fs.readFileSync(filePath, "utf8").replace(/\r\n?/g, "\n")));
259
+ }
260
+ // ---------- verse -> [word, gloss] pairs (it_update1.md) ----------
261
+ function isWordCluster(cluster) {
262
+ return cluster.lexemes.length === 1 && cluster.lexemes[0].type === "Word";
263
+ }
264
+ // USFM content that is not verse text. Replaced by spaces so offsets stay aligned with the segment.
265
+ const NON_TEXT_PATTERNS = [
266
+ /\\(f|fe|x)\s[\s\S]*?\\\1\*/g, // footnotes, endnotes, cross-refs
267
+ /(^|\n)\\(s\d*|ms\d*|mr|r|sr|d)(?=\s)[^\n]*/g, // headings, Psalm titles
268
+ /\|[^\\\n]*/g, // word attributes: \w word|attrs\w*
269
+ /\\(c|v)\s+\S+/g, // chapter/verse numbers
270
+ /\\\+?[A-Za-z0-9]+\*?/g // other markers
271
+ ];
272
+ // Split a segment into words: runs of Unicode letters and combining marks in verse text.
273
+ export function tokenize(segment) {
274
+ let text = segment;
275
+ for (const pattern of NON_TEXT_PATTERNS) {
276
+ text = text.replace(pattern, (match) => " ".repeat(match.length));
277
+ }
278
+ return [...text.matchAll(/[\p{L}\p{M}]+/gu)].map((m) => ({ index: m.index ?? 0, text: m[0] }));
279
+ }
280
+ function verseWords(segment, item, lexicon, corpus, language, showSource) {
281
+ // Word clusters by exact range; others (stale, Stem/affix, Phrase) don't apply to a token.
282
+ const approved = new Map();
283
+ for (const cluster of item?.clusters ?? []) {
284
+ if (cluster.excluded || !isWordCluster(cluster))
285
+ continue;
286
+ const key = `${cluster.index}:${cluster.length}`;
287
+ approved.set(key, [...(approved.get(key) ?? []), cluster]);
288
+ }
289
+ return tokenize(segment).map((token) => {
290
+ const form = normalizeForm(token.text);
291
+ let gloss = null;
292
+ let source = "-";
293
+ for (const cluster of approved.get(`${token.index}:${token.text.length}`) ?? []) {
294
+ if (normalizeForm(cluster.lexemes[0].form) !== form)
295
+ continue;
296
+ gloss = lookupGloss(lexicon, cluster.lexemes[0]) || null;
297
+ if (gloss) {
298
+ source = "a";
299
+ break;
300
+ }
301
+ }
302
+ if (!gloss) {
303
+ gloss = corpus.get(form) ?? null;
304
+ if (gloss)
305
+ source = "g";
306
+ }
307
+ if (!gloss) {
308
+ gloss = lexicon.byForm.get(form)?.get(language) ?? null;
309
+ if (gloss)
310
+ source = "l";
311
+ }
312
+ const word = token.text;
313
+ return showSource ? [word, gloss ?? NO_GLOSS, source] : [word, gloss ?? NO_GLOSS];
314
+ });
315
+ }
316
+ // ---------- reference handling ----------
317
+ const REF_PATTERN = /^(?:([A-Za-z0-9]{3})\s+)?(\d+)\s*(?::\s*(\d+)\s*(?:-\s*(\d+)\s*(?::\s*(\d+))?)?)?$/;
318
+ export function parseRefs(refs) {
319
+ const parts = refs.split(";").map((p) => p.trim()).filter((p) => p.length > 0);
320
+ if (parts.length === 0)
321
+ throw new InvalidReferenceError("No verse references given");
322
+ const result = [];
323
+ let book;
324
+ for (const part of parts) {
325
+ const match = part.match(REF_PATTERN);
326
+ if (!match)
327
+ throw new InvalidReferenceError(`Unparseable reference: ${part}`);
328
+ const [, bookPart, c, v, x, y] = match;
329
+ if (bookPart) {
330
+ if (!isBookCode(bookPart))
331
+ throw new InvalidReferenceError(`Not a valid USFM book code: ${bookPart}`);
332
+ book = bookPart.toUpperCase();
333
+ }
334
+ if (!book)
335
+ throw new InvalidReferenceError(`First reference must include a book: ${part}`);
336
+ const chapter = parseInt(c, 10);
337
+ let span;
338
+ if (v === undefined) {
339
+ span = { startChapter: chapter };
340
+ }
341
+ else if (x === undefined) {
342
+ span = { startChapter: chapter, startVerse: parseInt(v, 10), endVerse: parseInt(v, 10) };
343
+ }
344
+ else if (y === undefined) {
345
+ span = { startChapter: chapter, startVerse: parseInt(v, 10), endVerse: parseInt(x, 10) };
346
+ }
347
+ else {
348
+ span = { startChapter: chapter, startVerse: parseInt(v, 10), endChapter: parseInt(x, 10), endVerse: parseInt(y, 10) };
349
+ }
350
+ result.push({ book, span });
351
+ }
352
+ return result;
353
+ }
354
+ function maxVerseNumber(verses) {
355
+ return verses.reduce((max, v) => Math.max(max, v.end), 0);
356
+ }
357
+ // Expand a span to verse targets with the same defaults as get-scripture.
358
+ function expandSpan(parsed, book, span, onMissing) {
359
+ const chapters = [...parsed.chapters.keys()].sort((a, b) => a - b);
360
+ const startChapter = span.startChapter ?? chapters[0] ?? 1;
361
+ const endChapter = span.endChapter ?? span.startChapter ?? chapters[chapters.length - 1] ?? startChapter;
362
+ if (endChapter < startChapter) {
363
+ throw new InvalidReferenceError(`End chapter ${endChapter} is before start chapter ${startChapter}`);
364
+ }
365
+ const targets = [];
366
+ for (let chapter = startChapter; chapter <= endChapter; chapter++) {
367
+ const verses = parsed.chapters.get(chapter);
368
+ if (!verses) {
369
+ onMissing(`${book} ${chapter}`);
370
+ continue;
371
+ }
372
+ const verseStart = chapter === startChapter && span.startVerse ? span.startVerse : 1;
373
+ const verseEnd = chapter === endChapter && span.endVerse ? span.endVerse : maxVerseNumber(verses);
374
+ for (let verse = verseStart; verse <= verseEnd; verse++) {
375
+ const entry = verses.find((v) => verse >= v.start && verse <= v.end);
376
+ if (!entry) {
377
+ onMissing(`${book} ${chapter}:${verse}`);
378
+ continue;
379
+ }
380
+ targets.push({ book, chapter, label: entry.label, start: entry.start, end: entry.end });
381
+ }
382
+ }
383
+ return targets;
384
+ }
385
+ // ---------- public API ----------
386
+ export function listInterlinearLanguages(projectsRoot, projectId, book) {
387
+ const projectDir = getProjectDir(projectsRoot, projectId);
388
+ const bookCode = book?.toUpperCase();
389
+ if (bookCode !== undefined && !isBookCode(bookCode)) {
390
+ throw new InvalidReferenceError(`Not a valid USFM book code: ${book}`);
391
+ }
392
+ return fs
393
+ .readdirSync(projectDir, { withFileTypes: true })
394
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith(INTERLINEAR_DIR_PREFIX))
395
+ .map((entry) => entry.name.slice(INTERLINEAR_DIR_PREFIX.length))
396
+ .filter((language) => language.length > 0)
397
+ .filter((language) => !bookCode || fs.existsSync(interlinearFile(projectDir, language, bookCode)))
398
+ .sort();
399
+ }
400
+ export function getInterlinear(request) {
401
+ const allowPartial = request.allowPartial ?? false;
402
+ const missing = [];
403
+ const onMissing = (ref) => {
404
+ if (!allowPartial)
405
+ throw new VerseNotFoundError(ref);
406
+ missing.push(ref);
407
+ };
408
+ if (!LANGUAGE_PATTERN.test(request.language)) {
409
+ throw new InvalidReferenceError(`Not a valid language code: ${request.language}`);
410
+ }
411
+ const hasStructured = request.book !== undefined
412
+ || request.startChapter !== undefined
413
+ || request.startVerse !== undefined
414
+ || request.endChapter !== undefined
415
+ || request.endVerse !== undefined;
416
+ const hasRefs = request.refs !== undefined;
417
+ if (hasStructured && hasRefs) {
418
+ throw new InvalidReferenceError("Specify either refs or book/chapter/verse, not both");
419
+ }
420
+ if (!hasStructured && !hasRefs) {
421
+ throw new InvalidReferenceError("Specify either refs or book");
422
+ }
423
+ let bookSpans;
424
+ if (hasRefs) {
425
+ bookSpans = parseRefs(request.refs ?? "");
426
+ }
427
+ else {
428
+ const book = (request.book ?? "").toUpperCase();
429
+ if (!isBookCode(book))
430
+ throw new InvalidReferenceError(`Not a valid USFM book code: ${request.book ?? ""}`);
431
+ const { startChapter, startVerse, endChapter, endVerse } = request;
432
+ bookSpans = [{ book, span: { startChapter, startVerse, endChapter, endVerse } }];
433
+ }
434
+ const projectDir = getProjectDir(request.projectsRoot, request.project);
435
+ const lexicon = loadLexicon(projectDir);
436
+ const showSource = request.showSource ?? false;
437
+ let corpus = new Map();
438
+ const languageDir = interlinearDir(projectDir, request.language);
439
+ const hasLanguage = fs.existsSync(languageDir);
440
+ if (hasLanguage) {
441
+ corpus = loadCorpus(languageDir, request.language, lexicon);
442
+ }
443
+ else {
444
+ const err = new InterlinearNotFoundError(`No interlinear data for language ${request.language} in project ${request.project}`);
445
+ if (!allowPartial)
446
+ throw err;
447
+ missing.push(err.message);
448
+ }
449
+ // Per-book text, loaded lazily. null = unavailable (only when allowPartial).
450
+ const books = new Map();
451
+ const verses = [];
452
+ const seen = new Set();
453
+ for (const { book, span } of bookSpans) {
454
+ if (!books.has(book)) {
455
+ try {
456
+ books.set(book, loadBook(projectDir, request.project, book));
457
+ }
458
+ catch (err) {
459
+ // Structured form: a missing book is always an error (spec §7).
460
+ if (!(err instanceof BookNotFoundError) || !allowPartial || !hasRefs)
461
+ throw err;
462
+ missing.push(err.message);
463
+ books.set(book, null);
464
+ }
465
+ }
466
+ const parsed = books.get(book);
467
+ if (!parsed)
468
+ continue;
469
+ // No interlinear file for this book is fine: all words are guessed from other books.
470
+ const interlinear = hasLanguage ? loadInterlinear(projectDir, request.language, book) : undefined;
471
+ const segments = loadSegments(projectDir, request.project, book);
472
+ for (const target of expandSpan(parsed, book, span, onMissing)) {
473
+ const ref = `${book} ${target.chapter}:${target.label}`;
474
+ if (seen.has(ref))
475
+ continue;
476
+ seen.add(ref);
477
+ // Range offsets are relative to the item's own segment, so only use an item whose
478
+ // verse label matches the text's; otherwise all words are guessed.
479
+ const item = interlinear?.items.get(`${target.chapter}:${target.label}`);
480
+ const segment = segments.get(`${target.chapter}:${target.label}`) ?? "";
481
+ verses.push({ ref, words: verseWords(segment, item, lexicon, corpus, request.language, showSource) });
482
+ }
483
+ }
484
+ return { verses, missing };
485
+ }
@@ -0,0 +1,28 @@
1
+ import { ParsedBook } from "./usfmBook.js";
2
+ export declare class InvalidReferenceError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ export declare class BookNotFoundError extends Error {
6
+ constructor(projectId: string, book: string);
7
+ }
8
+ export declare class VerseNotFoundError extends Error {
9
+ constructor(ref: string);
10
+ }
11
+ export interface ScriptureRequest {
12
+ projectsRoot: string;
13
+ projects: string[];
14
+ book: string;
15
+ startChapter?: number;
16
+ startVerse?: number;
17
+ endChapter?: number;
18
+ endVerse?: number;
19
+ allowPartial?: boolean;
20
+ }
21
+ export interface ScriptureResult {
22
+ text: string;
23
+ missing: string[];
24
+ }
25
+ export declare function loadBook(projectDir: string, projectId: string, book: string): ParsedBook;
26
+ export declare function getScripture(request: ScriptureRequest): ScriptureResult;
27
+ export declare function listAvailableProjects(projectsRoot: string): string[];
28
+ export declare function listAvailableBooks(projectsRoot: string, projectId: string): string[];
@@ -0,0 +1,126 @@
1
+ import { BOOK_CODES, isBookCode } from "./books.js";
2
+ import { getProjectDir, listProjects, ProjectNotFoundError } from "./discovery.js";
3
+ import { indexProjectBooks, parseBookFile } from "./usfmBook.js";
4
+ export class InvalidReferenceError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = "InvalidReferenceError";
8
+ }
9
+ }
10
+ export class BookNotFoundError extends Error {
11
+ constructor(projectId, book) {
12
+ super(`Book ${book} not found in project ${projectId}`);
13
+ this.name = "BookNotFoundError";
14
+ }
15
+ }
16
+ export class VerseNotFoundError extends Error {
17
+ constructor(ref) {
18
+ super(`Verse not found: ${ref}`);
19
+ this.name = "VerseNotFoundError";
20
+ }
21
+ }
22
+ const bookCache = new Map();
23
+ export function loadBook(projectDir, projectId, book) {
24
+ const cacheKey = `${projectDir}::${book}`;
25
+ const cached = bookCache.get(cacheKey);
26
+ if (cached)
27
+ return cached;
28
+ const index = indexProjectBooks(projectDir);
29
+ const filePath = index.get(book);
30
+ if (!filePath)
31
+ throw new BookNotFoundError(projectId, book);
32
+ const parsed = parseBookFile(filePath);
33
+ bookCache.set(cacheKey, parsed);
34
+ return parsed;
35
+ }
36
+ function maxVerseNumber(chapterVerses) {
37
+ if (!chapterVerses || chapterVerses.length === 0)
38
+ return 0;
39
+ return Math.max(...chapterVerses.map((v) => v.end));
40
+ }
41
+ export function getScripture(request) {
42
+ const book = request.book.toUpperCase();
43
+ if (!isBookCode(book)) {
44
+ throw new InvalidReferenceError(`Not a valid USFM book code: ${request.book}`);
45
+ }
46
+ if (request.projects.length === 0) {
47
+ throw new InvalidReferenceError("At least one project must be specified");
48
+ }
49
+ const allowPartial = request.allowPartial ?? false;
50
+ const missing = [];
51
+ const parsedByProject = new Map();
52
+ for (const projectId of request.projects) {
53
+ try {
54
+ const projectDir = getProjectDir(request.projectsRoot, projectId);
55
+ const parsed = loadBook(projectDir, projectId, book);
56
+ parsedByProject.set(projectId, parsed);
57
+ }
58
+ catch (err) {
59
+ if (err instanceof ProjectNotFoundError || err instanceof BookNotFoundError) {
60
+ if (!allowPartial)
61
+ throw err;
62
+ missing.push(err.message);
63
+ continue;
64
+ }
65
+ throw err;
66
+ }
67
+ }
68
+ if (parsedByProject.size === 0) {
69
+ throw new VerseNotFoundError(`${book} in project(s) ${request.projects.join(", ")}`);
70
+ }
71
+ // Union of chapter numbers across resolved projects, used when the caller
72
+ // doesn't pin down an explicit chapter/verse range.
73
+ const allChapterNumbers = new Set();
74
+ for (const parsed of parsedByProject.values()) {
75
+ for (const chapterNum of parsed.chapters.keys())
76
+ allChapterNumbers.add(chapterNum);
77
+ }
78
+ const sortedChapters = [...allChapterNumbers].sort((a, b) => a - b);
79
+ const startChapter = request.startChapter ?? sortedChapters[0] ?? 1;
80
+ const endChapter = request.endChapter ?? request.startChapter ?? sortedChapters[sortedChapters.length - 1] ?? startChapter;
81
+ const multiProject = request.projects.length > 1;
82
+ const blocks = [];
83
+ for (let chapter = startChapter; chapter <= endChapter; chapter++) {
84
+ const isFirstChapter = chapter === startChapter;
85
+ const isLastChapter = chapter === endChapter;
86
+ const chapterVerseUnion = maxVerseNumber([...parsedByProject.values()].flatMap((p) => p.chapters.get(chapter) ?? []));
87
+ const verseStart = isFirstChapter && request.startVerse ? request.startVerse : 1;
88
+ const verseEnd = isLastChapter && request.endVerse ? request.endVerse : chapterVerseUnion;
89
+ const lastLabelByProject = new Map();
90
+ for (let verse = verseStart; verse <= verseEnd; verse++) {
91
+ const groupLines = [];
92
+ for (const projectId of request.projects) {
93
+ const parsed = parsedByProject.get(projectId);
94
+ const ref = `${projectId} ${book} ${chapter}:${verse}`;
95
+ if (!parsed)
96
+ continue; // already recorded as missing above
97
+ const verses = parsed.chapters.get(chapter) ?? [];
98
+ const entry = verses.find((v) => verse >= v.start && verse <= v.end);
99
+ if (!entry) {
100
+ if (allowPartial) {
101
+ missing.push(ref);
102
+ continue;
103
+ }
104
+ throw new VerseNotFoundError(ref);
105
+ }
106
+ if (lastLabelByProject.get(projectId) === entry.label)
107
+ continue;
108
+ lastLabelByProject.set(projectId, entry.label);
109
+ const prefix = multiProject ? `${projectId} ` : "";
110
+ groupLines.push(`${prefix}${book} ${chapter}:${entry.label} ${entry.text}`);
111
+ }
112
+ if (groupLines.length > 0)
113
+ blocks.push(groupLines.join("\n"));
114
+ }
115
+ }
116
+ const separator = multiProject ? "\n\n" : "\n";
117
+ return { text: blocks.join(separator), missing };
118
+ }
119
+ export function listAvailableProjects(projectsRoot) {
120
+ return listProjects(projectsRoot);
121
+ }
122
+ export function listAvailableBooks(projectsRoot, projectId) {
123
+ const projectDir = getProjectDir(projectsRoot, projectId);
124
+ const present = indexProjectBooks(projectDir);
125
+ return BOOK_CODES.filter((code) => present.has(code));
126
+ }
@@ -0,0 +1,13 @@
1
+ export interface ParsedVerse {
2
+ start: number;
3
+ end: number;
4
+ label: string;
5
+ text: string;
6
+ }
7
+ export interface ParsedBook {
8
+ code: string;
9
+ chapters: Map<number, ParsedVerse[]>;
10
+ }
11
+ export declare function bookCodeFromFile(filePath: string): string | undefined;
12
+ export declare function indexProjectBooks(projectDir: string): Map<string, string>;
13
+ export declare function parseBookFile(filePath: string): ParsedBook;
@@ -0,0 +1,73 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ // usfm-js has no type definitions; consume it as untyped JS.
4
+ // @ts-expect-error - no types published for usfm-js
5
+ import usfm from "usfm-js";
6
+ function collectText(nodes) {
7
+ if (!nodes)
8
+ return "";
9
+ let out = "";
10
+ for (const node of nodes) {
11
+ if (!node)
12
+ continue;
13
+ if (node.type === "footnote" || node.tag === "f")
14
+ continue;
15
+ if (typeof node.text === "string") {
16
+ out += node.text;
17
+ }
18
+ if (Array.isArray(node.children)) {
19
+ out += collectText(node.children);
20
+ }
21
+ }
22
+ return out;
23
+ }
24
+ function normalizeText(raw) {
25
+ return raw.replace(/\s+/g, " ").trim();
26
+ }
27
+ function parseVerseLabel(label) {
28
+ const [startStr, endStr] = label.split("-");
29
+ const start = parseInt(startStr, 10);
30
+ const end = endStr ? parseInt(endStr, 10) : start;
31
+ return { start, end };
32
+ }
33
+ export function bookCodeFromFile(filePath) {
34
+ const contents = fs.readFileSync(filePath, "utf8");
35
+ const match = contents.match(/\\id\s+([A-Za-z0-9]{3})/);
36
+ return match ? match[1].toUpperCase() : undefined;
37
+ }
38
+ export function indexProjectBooks(projectDir) {
39
+ const index = new Map();
40
+ const files = fs.readdirSync(projectDir).filter((f) => f.toUpperCase().endsWith(".SFM"));
41
+ for (const file of files) {
42
+ const filePath = path.join(projectDir, file);
43
+ const code = bookCodeFromFile(filePath);
44
+ if (code)
45
+ index.set(code, filePath);
46
+ }
47
+ return index;
48
+ }
49
+ export function parseBookFile(filePath) {
50
+ const contents = fs.readFileSync(filePath, "utf8");
51
+ const json = usfm.toJSON(contents);
52
+ const idHeader = json.headers.find((h) => h.tag === "id");
53
+ const code = (idHeader?.content ?? "").trim().split(/\s+/)[0]?.toUpperCase() ?? "";
54
+ const chapters = new Map();
55
+ for (const [chapterKey, verses] of Object.entries(json.chapters)) {
56
+ if (chapterKey === "front")
57
+ continue;
58
+ const chapterNum = parseInt(chapterKey, 10);
59
+ const parsedVerses = [];
60
+ for (const [verseKey, verseData] of Object.entries(verses)) {
61
+ if (verseKey === "front")
62
+ continue;
63
+ const { start, end } = parseVerseLabel(verseKey);
64
+ const text = normalizeText(collectText(verseData.verseObjects));
65
+ if (!text)
66
+ continue;
67
+ parsedVerses.push({ start, end, label: verseKey, text });
68
+ }
69
+ parsedVerses.sort((a, b) => a.start - b.start);
70
+ chapters.set(chapterNum, parsedVerses);
71
+ }
72
+ return { code, chapters };
73
+ }
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "@milesnl/ptx-core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Library for reading Paratext project scripture, interlinear and Biblical Terms data",
5
+ "type": "module",
6
+ "main": "dist/core.js",
7
+ "types": "dist/core.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/core.d.ts",
11
+ "default": "./dist/core.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "engines": {
20
+ "node": ">=20.11"
21
+ },
22
+ "scripts": {
23
+ "build": "rm -rf dist && tsc -p .",
24
+ "prepublishOnly": "npm run build"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/Nathan22Miles/PtxMCP.git",
29
+ "directory": "packages/core"
30
+ },
31
+ "keywords": ["paratext", "bible", "usfm", "interlinear"],
32
+ "author": "Nathan Miles",
33
+ "license": "ISC",
34
+ "dependencies": {
35
+ "usfm-js": "^3.5.0"
36
+ }
37
+ }