Fix splitNamedAntiphon silently upgrading draft text to verified
Deploy / deploy (push) Successful in 53s

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 11:34:21 -04:00
parent ea29e5ee45
commit 2658a2b94e
5 changed files with 86 additions and 23 deletions
+1 -2
View File
@@ -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 },
+5 -4
View File
@@ -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 },
+25 -8
View File
@@ -121,15 +121,18 @@ export function resolveMinorHourAntiphon(
hourId: string,
day: LiturgicalDay,
weekdayDefault: Partial<Record<string, string>>,
): Partial<Record<string, string>> {
): 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<Record<string, string>>): {
* 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<Record<string, string>> = {};
const fullText: Partial<Record<string, string>> = {};
for (const [lang, t] of Object.entries(text)) {
const incipitStatus: Partial<Record<string, 'verified' | 'draft' | 'missing'>> = {};
const fullStatus: Partial<Record<string, 'verified' | 'draft' | 'missing'>> = {};
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 } };
}