From 31ee9aeaa084331aab72bf37093a316e66f6d04c Mon Sep 17 00:00:00 2001 From: Scott Tomaszewski Date: Tue, 2 Jun 2026 22:11:01 -0400 Subject: [PATCH] feat: VerseSelection value object (runs + label) --- src/core/VerseSelection.ts | 107 ++++++++++++++++++++++++++++++++++++ test/VerseSelection.test.ts | 58 +++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 src/core/VerseSelection.ts create mode 100644 test/VerseSelection.test.ts diff --git a/src/core/VerseSelection.ts b/src/core/VerseSelection.ts new file mode 100644 index 0000000..ca236cb --- /dev/null +++ b/src/core/VerseSelection.ts @@ -0,0 +1,107 @@ +import { BibleReference } from "./BibleReference"; +import { VerseRef } from "../utils/VerseId"; + +/** + * A mutable, ordered, de-duplicated set of verses within one book. Produces + * contiguous BibleReference runs and a human display label. Pure (no DOM / Obsidian). + */ +export class VerseSelection { + readonly book: string; + private verses: VerseRef[] = []; + + constructor(book: string) { + this.book = book; + } + + private indexOf(v: VerseRef): number { + return this.verses.findIndex((x) => x.chapter === v.chapter && x.verse === v.verse); + } + + has(v: VerseRef): boolean { + return this.indexOf(v) !== -1; + } + + isEmpty(): boolean { + return this.verses.length === 0; + } + + count(): number { + return this.verses.length; + } + + clear(): void { + this.verses = []; + } + + add(v: VerseRef): void { + if (!this.has(v)) { + this.verses.push({ chapter: v.chapter, verse: v.verse }); + this.sort(); + } + } + + toggle(v: VerseRef): void { + const i = this.indexOf(v); + if (i === -1) this.add(v); + else this.verses.splice(i, 1); + } + + /** Add every verse from anchor..focus when they share a chapter; else just add focus. */ + selectRange(anchor: VerseRef, focus: VerseRef): void { + if (anchor.chapter !== focus.chapter) { + this.add(focus); + return; + } + const lo = Math.min(anchor.verse, focus.verse); + const hi = Math.max(anchor.verse, focus.verse); + for (let v = lo; v <= hi; v++) this.add({ chapter: anchor.chapter, verse: v }); + } + + /** Sorted copy of the selected verses. */ + verseList(): VerseRef[] { + return this.verses.map((v) => ({ chapter: v.chapter, verse: v.verse })); + } + + private sort(): void { + this.verses.sort((a, b) => a.chapter - b.chapter || a.verse - b.verse); + } + + /** Collapse consecutive verses within a chapter into BibleReference ranges. */ + runs(): BibleReference[] { + const out: BibleReference[] = []; + let start: VerseRef | null = null; + let prev: VerseRef | null = null; + + const flush = () => { + if (!start || !prev) return; + const endVerse = prev.verse === start.verse ? undefined : prev.verse; + out.push(new BibleReference(this.book, start.chapter, start.verse, endVerse)); + }; + + for (const v of this.verses) { + if (prev && v.chapter === prev.chapter && v.verse === prev.verse + 1) { + prev = v; + continue; + } + flush(); + start = v; + prev = v; + } + flush(); + return out; + } + + /** YouVersion-style label, e.g. "Genesis 1:2-3, 5" or "Genesis 1:31, 2:1". */ + label(): string { + const runs = this.runs(); + if (runs.length === 0) return this.book; + let lastChapter: number | null = null; + const parts: string[] = []; + for (const r of runs) { + const versePart = r.endVerse !== undefined ? `${r.verse}-${r.endVerse}` : `${r.verse}`; + parts.push(r.chapter === lastChapter ? versePart : `${r.chapter}:${versePart}`); + lastChapter = r.chapter; + } + return `${this.book} ${parts.join(", ")}`; + } +} diff --git a/test/VerseSelection.test.ts b/test/VerseSelection.test.ts new file mode 100644 index 0000000..baef9cf --- /dev/null +++ b/test/VerseSelection.test.ts @@ -0,0 +1,58 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { VerseSelection } from "../src/core/VerseSelection"; + +function sel(book: string, ...vs: [number, number][]): VerseSelection { + const s = new VerseSelection(book); + for (const [c, v] of vs) s.toggle({ chapter: c, verse: v }); + return s; +} + +test("VerseSelection", async (t) => { + await t.test("toggle adds then removes", () => { + const s = sel("Genesis"); + s.toggle({ chapter: 1, verse: 2 }); + assert.equal(s.has({ chapter: 1, verse: 2 }), true); + s.toggle({ chapter: 1, verse: 2 }); + assert.equal(s.has({ chapter: 1, verse: 2 }), false); + assert.equal(s.isEmpty(), true); + }); + + await t.test("contiguous run collapses to a range label", () => { + const s = sel("Genesis", [1, 2], [1, 3], [1, 4]); + assert.equal(s.label(), "Genesis 1:2-4"); + const runs = s.runs(); + assert.equal(runs.length, 1); + assert.equal(runs[0].toString(), "Genesis 1:2-4"); + }); + + await t.test("non-contiguous verses join with commas (out-of-order input)", () => { + const s = sel("Genesis", [1, 5], [1, 2], [1, 3]); + assert.equal(s.label(), "Genesis 1:2-3, 5"); + assert.deepEqual(s.runs().map((r) => r.toString()), ["Genesis 1:2-3", "Genesis 1:5"]); + }); + + await t.test("single verse", () => { + assert.equal(sel("John", [3, 16]).label(), "John 3:16"); + }); + + await t.test("multiple chapters repeat the chapter, no cross-chapter merge", () => { + const s = sel("Genesis", [1, 31], [2, 1]); + assert.equal(s.label(), "Genesis 1:31, 2:1"); + assert.equal(s.runs().length, 2); + }); + + await t.test("selectRange fills verses within a chapter", () => { + const s = new VerseSelection("Genesis"); + s.selectRange({ chapter: 1, verse: 2 }, { chapter: 1, verse: 5 }); + assert.equal(s.label(), "Genesis 1:2-5"); + }); + + await t.test("verseList is sorted", () => { + const s = sel("Genesis", [1, 5], [1, 2]); + assert.deepEqual(s.verseList(), [ + { chapter: 1, verse: 2 }, + { chapter: 1, verse: 5 }, + ]); + }); +});