From 2658a2b94e7e2353c48f659f94abbcef9684cce9 Mon Sep 17 00:00:00 2001 From: Will Estes Date: Wed, 12 Aug 2026 11:34:21 -0400 Subject: [PATCH] Fix splitNamedAntiphon silently upgrading draft text to verified Every antiphon that goes through the incipit/full split -- which is every antiphon across every hour -- lost its real status the moment it passed through here: splitNamedAntiphon hardcoded `verified` on its output via verifiedText(), regardless of what the source's actual status was. Concretely, this meant St. Scholastica's antiphon (I translated it myself, no English existed in the source), Matthias's and several other apostles' reworded English, and Gregory the Great's translated antiphon all silently rendered as if fully verified -- losing the "unverified draft text" UI marker (src/ui/styles.css's .text-draft, a dashed underline + tooltip) that's supposed to distinguish "copied straight from the source" from "Claude's own rendering." Fixed by having splitNamedAntiphon accept a real ResolvedText and carry its per-language status through unchanged to both outputs, instead of silently upgrading everything to verified. Callers whose antiphon never had a status field to begin with (plain weekday-default antiphons and Lauds psalmody overrides -- neither type tracks status at all, always implicitly verified by the source file's own live-checked convention) now wrap with verifiedText() explicitly at the call site, rather than that assumption being buried inside splitNamedAntiphon itself. resolveMinorHourAntiphon (Prime/Terce/Sext/None's override lookup) now returns a full ResolvedText for the same reason. Verified end-to-end with St. Scholastica's real draft-English antiphon, both via a direct unit test and visually in the browser (the dashed underline now shows correctly under her Benedictus antiphon). Co-Authored-By: Claude Sonnet 5 --- TODO.md | 32 +++++++++++++++++++++-------- src/hours/compline.ts | 3 +-- src/hours/lauds.ts | 9 ++++---- src/hours/resolve-common.ts | 33 ++++++++++++++++++++++-------- tests/hours/resolve-common.test.ts | 32 ++++++++++++++++++++++++++++- 5 files changed, 86 insertions(+), 23 deletions(-) diff --git a/TODO.md b/TODO.md index 7194d8a..18abfe5 100644 --- a/TODO.md +++ b/TODO.md @@ -365,16 +365,32 @@ saint winning on the three verified collision dates, and St. Thomas Becket (Semiduplex) still transferring off the Sunday rather than winning, unchanged by the fix. -### Bug found while checking February's results, not yet fixed +### Bug found while checking February's results — fixed (2026-08) -`hours/resolve-common.ts`'s `splitNamedAntiphon` always marks its output -`verified` regardless of the input status (`verifiedText()` hardcodes it) — +`hours/resolve-common.ts`'s `splitNamedAntiphon` always marked its output +`verified` regardless of the input status (`verifiedText()` hardcoded it) — so a `draft`-marked antiphon (Scholastica's translation, Matthias's -reworded English) renders as if fully verified everywhere it's actually -displayed, even though the source YAML correctly says `draft`. Affects -every split antiphon across every hour (Prime, Terce, Compline, Lauds), not -just these two — pre-existing, just never surfaced until content with a -real `draft` status went through it. +reworded English, and every other reworded/translated antiphon from the +sanctoral pull) rendered as if fully verified everywhere it's actually +displayed — silently dropping the "unverified draft text" UI marker +(`src/ui/styles.css`'s `.text-draft`, a dashed underline + tooltip) — even +though the source YAML correctly said `draft`. Affected every split +antiphon across every hour (Prime, Terce, Sext, None, Compline, Lauds), +not just Scholastica/Matthias — pre-existing, just never surfaced until +content with a real `draft` status went through it. + +Fixed by having `splitNamedAntiphon` take a real `ResolvedText` (not just +bare text) and carry its actual per-language status through to both the +incipit and full outputs. Callers that only ever had a plain +`Partial>` with no status of its own to begin with +(the plain weekday-default antiphons, Lauds psalmody overrides — neither +type tracks a status field, always implicitly verified by their own +live-checked file convention) now wrap with `verifiedText()` explicitly +at the call site instead of that being silently baked into +`splitNamedAntiphon` itself. `resolveMinorHourAntiphon` (Prime/Terce/ +Sext/None's own override lookup) now returns a full `ResolvedText` too, +for the same reason. Verified end-to-end with St. Scholastica's own +draft-English antiphon (`tests/hours/resolve-common.test.ts`). ## Mechanism not built at all diff --git a/src/hours/compline.ts b/src/hours/compline.ts index 4e02a02..82f5257 100644 --- a/src/hours/compline.ts +++ b/src/hours/compline.ts @@ -3,7 +3,6 @@ import type { LiturgicalDay } from '../calendar/types'; import { resolveEveningDay } from '../calendar/vespers'; import { getDayLabel } from '../calendar/day-label'; import { getPsalmVerses } from '../psalter'; -import { getCommonProper } from '../propers'; import { getHymnDoxologyId } from './hymn-doxology'; import { getOpeningVersicleId } from './opening-versicle'; import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon'; @@ -63,7 +62,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { }, ]; case 'nunc-dimittis': { - const { incipit, full } = splitNamedAntiphon(getCommonProper('nunc-dimittis-antiphon').text); + const { incipit, full } = splitNamedAntiphon(resolveCommon('nunc-dimittis-antiphon')); const opening = isDoubleOrHigher(day.winner) ? full : incipit; return [ { kind: 'canticle', canticleId: 'nunc-dimittis', text: resolveCommon('nunc-dimittis'), antiphon: opening }, diff --git a/src/hours/lauds.ts b/src/hours/lauds.ts index 221fa31..25b945f 100644 --- a/src/hours/lauds.ts +++ b/src/hours/lauds.ts @@ -16,6 +16,7 @@ import { splitNamedAntiphon, resolveOfficeWinner, ALWAYS_OVERRIDE_TEMPORAL_IDS, + verifiedText, } from './resolve-common'; import laudsDefinitionData from '../data/hours/lauds.yml'; import laudsAntiphonsData from '../data/hours/lauds-antiphons.yml'; @@ -88,7 +89,7 @@ function psalmParts(numbers: number[], antiphon: BilingualText, opening: Resolve antiphon: i === 0 ? opening : undefined, verses: getPsalmVerses(number).map((v) => ({ n: v.n, text: v.text, status: v.status })), })); - parts.push({ kind: 'antiphon', text: splitNamedAntiphon(antiphon).full }); + parts.push({ kind: 'antiphon', text: splitNamedAntiphon(verifiedText(antiphon)).full }); return parts; } @@ -136,7 +137,7 @@ function canticleText(canticleId: string, slice?: [number, number]): ResolvedTex function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] { const wd = getPsalmodyOverrideFor(day) ?? laudsAntiphons[day.weekday]; const opening = (antiphon: BilingualText) => { - const { incipit, full } = splitNamedAntiphon(antiphon); + const { incipit, full } = splitNamedAntiphon(verifiedText(antiphon)); return isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; }; const parts: ResolvedPart[] = [ @@ -151,7 +152,7 @@ function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] { } const canticle = getCanticle(wd.canticle.id); const canticleOpening = opening(wd.canticle.antiphon); - const canticleClosing = splitNamedAntiphon(wd.canticle.antiphon).full; + const canticleClosing = splitNamedAntiphon(verifiedText(wd.canticle.antiphon)).full; if (wd.canticle.split) { // Said in two pieces, own Gloria Patri each, one shared antiphon // framing both (opening before the first, full repeated only after @@ -232,7 +233,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { case 'lauds-office': return resolveOffice(day); case 'benedictus': { - const { incipit, full } = splitNamedAntiphon(getBenedictusAntiphon(day).text); + const { incipit, full } = splitNamedAntiphon(getBenedictusAntiphon(day)); const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; return [ { kind: 'canticle', canticleId: 'benedictus', text: resolveCommon('benedictus'), antiphon: opening }, diff --git a/src/hours/resolve-common.ts b/src/hours/resolve-common.ts index 3311e93..4d412e4 100644 --- a/src/hours/resolve-common.ts +++ b/src/hours/resolve-common.ts @@ -121,15 +121,18 @@ export function resolveMinorHourAntiphon( hourId: string, day: LiturgicalDay, weekdayDefault: Partial>, -): Partial> { +): ResolvedText { const overrideId = getMinorHourOverrideId(day); if (overrideId) { - const proper = getCommonProper(`${hourId}-antiphon-${overrideId}`); + const proper = resolveCommon(`${hourId}-antiphon-${overrideId}`); if (proper.status.la !== 'missing' || proper.status.en !== 'missing') { - return proper.text; + return proper; } } - return weekdayDefault; + // The plain weekday default (data/hours/{hour}-antiphons.yml) never + // carries its own status field at all — always implicitly verified by + // the file's own live-checked convention, not a guess. + return verifiedText(weekdayDefault); } /** Same idea as resolveMinorHourAntiphon, for the chapter @@ -277,20 +280,34 @@ export function appendDoxology(body: ResolvedText, doxology: ResolvedText): Reso /** Splits a bilingual antiphon (one string per language, each with an * embedded "*") into its incipit and full forms, per language — each * prefixed "Ant. " inline, the same way "V."/"R." are baked directly into - * versicle text rather than rendered as a separate UI marker. */ -export function splitNamedAntiphon(text: Partial>): { + * versicle text rather than rendered as a separate UI marker. + * + * Takes the antiphon's real status and carries it through to both + * outputs unchanged — previously hardcoded `verified` on everything it + * touched regardless of the source's actual status (`verifiedText()`), + * so a `draft` antiphon (e.g. St. Scholastica's, self-translated — see + * TODO.md) silently lost its "unverified draft text" marker + * (`src/ui/styles.css`'s `.text-draft`, a dashed underline + tooltip) + * the moment it passed through here — which every antiphon does, since + * this is the shared incipit/full split used by every hour. */ +export function splitNamedAntiphon(antiphon: ResolvedText): { incipit: ResolvedText; full: ResolvedText; } { const incipitText: Partial> = {}; const fullText: Partial> = {}; - for (const [lang, t] of Object.entries(text)) { + const incipitStatus: Partial> = {}; + const fullStatus: Partial> = {}; + for (const [lang, t] of Object.entries(antiphon.text)) { if (!t) { continue; } const split = splitAntiphon(t); incipitText[lang] = `Ant. ${split.incipit}`; fullText[lang] = `Ant. ${split.full}`; + const status = antiphon.status[lang] ?? 'missing'; + incipitStatus[lang] = status; + fullStatus[lang] = status; } - return { incipit: verifiedText(incipitText), full: verifiedText(fullText) }; + return { incipit: { text: incipitText, status: incipitStatus }, full: { text: fullText, status: fullStatus } }; } diff --git a/tests/hours/resolve-common.test.ts b/tests/hours/resolve-common.test.ts index e098f2f..7d3da49 100644 --- a/tests/hours/resolve-common.test.ts +++ b/tests/hours/resolve-common.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { resolveDay } from '../../src/calendar'; -import { getDayCollect } from '../../src/hours/resolve-common'; +import { getDayCollect, splitNamedAntiphon } from '../../src/hours/resolve-common'; describe('getDayCollect', () => { it('resolves a real, verified collect for an ordinary Sunday', () => { @@ -25,3 +25,33 @@ describe('getDayCollect', () => { expect(collect.status.en).toBe('missing'); }); }); + +describe('splitNamedAntiphon', () => { + it('carries a verified status through to both the incipit and full forms', () => { + const { incipit, full } = splitNamedAntiphon({ + text: { la: 'Foo * bar baz.', en: 'Foo * bar baz.' }, + status: { la: 'verified', en: 'verified' }, + }); + expect(incipit.status).toEqual({ la: 'verified', en: 'verified' }); + expect(full.status).toEqual({ la: 'verified', en: 'verified' }); + }); + + it('carries a draft status through to both forms, per language -- the bug this fixes: it used to hardcode verified on everything regardless of the source status, silently dropping the "unverified draft text" UI marker (src/ui/styles.css .text-draft) from every draft antiphon', () => { + const { incipit, full } = splitNamedAntiphon({ + text: { la: 'Foo * bar baz.', en: 'Foo * bar baz.' }, + status: { la: 'verified', en: 'draft' }, + }); + expect(incipit.status).toEqual({ la: 'verified', en: 'draft' }); + expect(full.status).toEqual({ la: 'verified', en: 'draft' }); + }); + + it("reflects St. Scholastica's own real draft-English antiphon end to end, through Lauds", async () => { + const { resolveOrdo } = await import('../../src/hours/lauds'); + const ordo = resolveOrdo('2029-02-10'); // St. Scholastica, a clean year + const idx = ordo.parts.findIndex((p) => p.kind === 'canticle' && p.canticleId === 'benedictus'); + const canticle = ordo.parts[idx]; + const repeat = ordo.parts[idx + 1]; + expect(canticle?.kind === 'canticle' ? canticle.antiphon?.status.en : undefined).toBe('draft'); + expect(repeat?.kind === 'antiphon' ? repeat.text.status.en : undefined).toBe('draft'); + }); +});