mirror of
https://github.com/scotttomaszewski/obsidian-disciples-journal.git
synced 2026-07-22 05:42:13 +00:00
feat: VerseSelection value object (runs + label)
This commit is contained in:
parent
7d6b3c5b40
commit
31ee9aeaa0
2 changed files with 165 additions and 0 deletions
107
src/core/VerseSelection.ts
Normal file
107
src/core/VerseSelection.ts
Normal 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(", ")}`;
|
||||
}
|
||||
}
|
||||
58
test/VerseSelection.test.ts
Normal file
58
test/VerseSelection.test.ts
Normal 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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue