feat: VerseSelection value object (runs + label)

This commit is contained in:
Scott Tomaszewski 2026-06-02 22:11:01 -04:00
parent 7d6b3c5b40
commit 31ee9aeaa0
2 changed files with 165 additions and 0 deletions

107
src/core/VerseSelection.ts Normal file
View file

@ -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(", ")}`;
}
}

View file

@ -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 },
]);
});
});