Compare commits

..

1 Commits

Author SHA1 Message Date
rafa ea5ecafc3d fix: capture gospel when assigning carta
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-21 06:59:06 -04:00
7 changed files with 19 additions and 31175 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -407,14 +407,22 @@ traducción inmediata, avisar a Rafa directamente además de pedírselo a Hermes
| Asignar `_carta_id` a un artículo | `POST /wp-json/fea/v1/carta-id/{id}` con body `{"carta_id": N}` | | Asignar `_carta_id` a un artículo | `POST /wp-json/fea/v1/carta-id/{id}` con body `{"carta_id": N}` |
| Borrar `_carta_id` de un artículo | `DELETE /wp-json/fea/v1/carta-id/{id}` | | Borrar `_carta_id` de un artículo | `DELETE /wp-json/fea/v1/carta-id/{id}` |
## Regla para la sección Evangelio y comentarios al Evangelio ## Evangelio y comentarios: regla y paso obligatorio para Mixbot
La sección empieza siempre con el enlace al post del evangelio de esa semana (por La sección empieza siempre con el enlace al post del evangelio de esa semana (por
ejemplo, `MATEO 14, 22-33`) y después van los comentarios. Si ese post no existe, ejemplo, `MATEO 14, 22-33`) y después van los comentarios. Si ese post no existe,
avisad antes de publicar la carta: no empecéis la sección por un comentario. avisad antes de publicar la carta: no empecéis la sección por un comentario.
No hay que completar ningún campo nuevo. A partir de ese enlace, el sitio asigna la Primero guarda la carta (puede quedar como borrador) con esa sección ya completa y
cita bíblica automáticamente a los comentarios nuevos. anota su ID. Después asigna ese ID a **cada comentario al evangelio** de la semana con
el endpoint `POST /wp-json/fea/v1/carta-id/<id-del-comentario>` y el cuerpo
`{"carta_id": <id-de-la-carta>}`.
No hay que escribir la cita ni el texto bíblico a mano: al asignar `_carta_id`, el
plugin localiza la única lectura bíblica enlazada en la sección y añade
`_cita_evangelio` automáticamente, incluso si el comentario ya estaba publicado. Si
la carta tiene cero o varias lecturas, el plugin no adivina: corrige la sección y
vuelve a asignar el ID de carta.
Campos útiles del post: `title`, `content` (HTML), `status` (`draft`/`publish`/`future`), Campos útiles del post: `title`, `content` (HTML), `status` (`draft`/`publish`/`future`),
`date` (ISO 8601 para programar), `categories` (array de IDs), `featured_media` (ID de adjunto). `date` (ISO 8601 para programar), `categories` (array de IDs), `featured_media` (ID de adjunto).
+8
View File
@@ -126,6 +126,14 @@ add_action('transition_post_status', function($new_status, $old_status, $post) {
if ($new_status === 'publish' && $old_status !== 'publish') fea_evangelio_capture_meta($post->ID); if ($new_status === 'publish' && $old_status !== 'publish') fea_evangelio_capture_meta($post->ID);
}, 20, 3); }, 20, 3);
/** Completa la cita cuando llega la carta, aunque el comentario ya estuviera publicado. */
function fea_evangelio_capture_meta_on_carta_assignment($meta_id, $post_id, $meta_key, $meta_value) {
if ($meta_key !== '_carta_id' || !(int) $meta_value) return;
fea_evangelio_capture_meta((int) $post_id);
}
add_action('added_post_meta', 'fea_evangelio_capture_meta_on_carta_assignment', 20, 4);
add_action('updated_post_meta', 'fea_evangelio_capture_meta_on_carta_assignment', 20, 4);
/** Añade referencia y texto plegado, cerrado por defecto, antes del comentario. */ /** Añade referencia y texto plegado, cerrado por defecto, antes del comentario. */
add_filter('the_content', function($content) { add_filter('the_content', function($content) {
if (!is_single() || is_admin()) return $content; if (!is_single() || is_admin()) return $content;
-435
View File
@@ -1,435 +0,0 @@
#!/usr/bin/env python3
"""Genera la tabla fecha -> entrada del libro (book_index 1-500) + ciclo A/B/C
para el shortcode del Evangelio de cada día, 2026-2040.
Reescritura completa (issue #20) tras retirar el commit 2713e13: aquella
version numeraba los domingos con un desfase de -1, contaba Cuaresma en
bloques de 7 desde Ceniza, evaluaba Adviento antes que Navidad (fuga el
26-31 de diciembre), y dejaba sin clave propia Bautismo/Ascension/Familia de
Nazaret/Cristo Rey, ademas de dar precedencia a fiestas fijas (Anunciacion,
San Juan Bautista...) sobre domingos y hasta sobre la Pascua misma.
Diseño distinto a proposito: en vez de una clave de texto libre (que hay que
mantener sincronizada a mano con el vocabulario del libro), cada fecha
referencia directamente el `source_index` (1-500) de
data/evangelio-diario/entradas-libro.json. Los tests de este fichero
comprueban cobertura bidireccional contra esas 500 entradas.
Fuentes:
- Computo de Pascua: algoritmo gregoriano (Meeus/Jones/Butcher), independiente
de cualquier tabla publicada.
- Normas Universales sobre el Año Litúrgico y el Calendario (1969): tabla de
precedencia (Adviento/Cuaresma/Pascua > solemnidades fijas; entre
solemnidades fijas del Señor > de un Santo) y regla especifica de traslado
de la Anunciación.
- Practica de la CEE: Ascensión y Corpus Christi trasladados al domingo.
- CEE, "Tabla temporal de las principales celebraciones del Año Litúrgico
2021-2044" (referencia general, NO la unica fuente usada para validar --
ver issue #20 para el detalle de fuentes cruzadas independientes).
"""
from __future__ import annotations
from datetime import date, timedelta
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BOOK_PATH = ROOT / "data/evangelio-diario/entradas-libro.json"
OUT_PATH = ROOT / "data/evangelio-diario/calendario-liturgico-2026-2040.json"
YEAR_FROM = 2026
YEAR_TO = 2040
def easter_sunday(year: int) -> date:
a = year % 19
b = year // 100
c = year % 100
d_ = b // 4
e = b % 4
f = (b + 8) // 25
g = (b - f + 1) // 3
h = (19 * a + b - d_ - g + 15) % 30
i = c // 4
k = c % 4
l = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 22 * l) // 451
month = (h + l - 7 * m + 114) // 31
day = ((h + l - 7 * m + 114) % 31) + 1
return date(year, month, day)
def prev_sunday_on_or_before(d: date) -> date:
return d - timedelta(days=(d.weekday() + 1) % 7)
def next_sunday_on_or_after(d: date) -> date:
return d + timedelta(days=(6 - d.weekday()) % 7)
def cycle_for_civil_year(y: int) -> str:
return {1: "A", 2: "B", 0: "C"}[y % 3]
class YearFacts:
"""Fechas moviles/fijas derivadas de la Pascua y de Navidad para el año
civil `y`. El tramo de Adviento/Navidad de diciembre de `y` referencia el
ciclo A/B/C del año litúrgico SIGUIENTE (y+1), que es el que empieza."""
def __init__(self, y: int):
self.y = y
self.easter = easter_sunday(y)
self.ash_wed = self.easter - timedelta(days=46)
self.palm_sunday = self.easter - timedelta(days=7)
self.holy_mon = self.easter - timedelta(days=6)
self.holy_tue = self.easter - timedelta(days=5)
self.holy_wed = self.easter - timedelta(days=4)
self.holy_thu = self.easter - timedelta(days=3)
self.good_fri = self.easter - timedelta(days=2)
self.holy_sat = self.easter - timedelta(days=1)
self.pentecost = self.easter + timedelta(days=49)
self.trinity = self.pentecost + timedelta(days=7)
self.corpus = self.trinity + timedelta(days=7) # trasladado a domingo (CEE)
self.ascension = self.easter + timedelta(days=42) # trasladada al 7º domingo (CEE)
self.epiphany = date(y, 1, 6)
self.baptism = (
self.epiphany + timedelta(days=1)
if self.epiphany.weekday() == 6
else next_sunday_on_or_after(self.epiphany)
)
xmas_prev = date(y - 1, 12, 25)
self.holy_family = (
date(y - 1, 12, 30)
if xmas_prev.weekday() == 6
else next_sunday_on_or_after(xmas_prev)
)
cand = next_sunday_on_or_after(date(y, 1, 2))
self.dom2_navidad = cand if cand.day <= 5 else None
self.christmas = date(y, 12, 25)
sun_before_xmas_eve = prev_sunday_on_or_before(self.christmas - timedelta(days=1))
self.advent1 = sun_before_xmas_eve - timedelta(days=21)
self.advent2 = self.advent1 + timedelta(days=7)
self.advent3 = self.advent1 + timedelta(days=14)
self.advent4 = self.advent1 + timedelta(days=21)
self.christ_king = self.advent1 - timedelta(days=7)
# Numeracion de Tiempo Ordinario con omision de semanas (norma real:
# se cuenta hacia atras desde Cristo Rey=semana 34). L = ultima semana
# de TO alcanzada (entera o parcial) antes de Ceniza.
self.week2_sunday = self.baptism + timedelta(days=7)
self.L = 2 + (self.ash_wed - self.week2_sunday).days // 7
first_post = self.corpus + timedelta(days=7)
self.K = (self.christ_king - first_post).days // 7 + 1
self.resume_week = 35 - self.K
def lent_week_base(n: int) -> int:
return {1: 145, 2: 154, 3: 163, 4: 172, 5: 181}[n]
def ot_week_base(n: int) -> int:
"""idx base [DomA,DomB,DomC,Lun..Sab] de la semana N de TO (2..34)."""
if 2 <= n <= 9:
return 69 + (n - 2) * 9
if 10 <= n <= 33:
return 265 + (n - 10) * 9
if n == 34:
return 481
raise ValueError(f"semana de TO fuera de rango: {n}")
CYC_OFFSET = {"A": 0, "B": 1, "C": 2}
def build_calendar(year_from: int, year_to: int) -> dict[str, dict]:
facts = {y: YearFacts(y) for y in range(year_from - 1, year_to + 2)}
out: dict[str, dict] = {}
def set_day(d: date, idx: int, cyc: str, note: str) -> None:
iso = d.isoformat()
if iso in out:
raise RuntimeError(
f"colision interna en {iso}: ya tenia idx={out[iso]['idx']} "
f"({out[iso]['note']}), se queria poner idx={idx} ({note})"
)
out[iso] = {"idx": idx, "cycle": cyc, "note": note}
for y in range(year_from, year_to + 1):
f = facts[y]
cyc = cycle_for_civil_year(y)
# -- Tiempo Ordinario semana 1 (tras el Bautismo, sin domingo propio) --
wk1_start = f.baptism + timedelta(days=1)
for i in range(6):
set_day(wk1_start + timedelta(days=i), 63 + i, cyc, "TOwk1")
# -- Tiempo Ordinario semanas 2..L (antes de Ceniza) --
for n in range(2, f.L + 1):
base = ot_week_base(n)
sun = f.week2_sunday + timedelta(days=(n - 2) * 7)
set_day(sun, base + CYC_OFFSET[cyc], cyc, f"TOwk{n}Sun")
for i in range(6):
d = sun + timedelta(days=1 + i)
if d >= f.ash_wed:
break
set_day(d, base + 3 + i, cyc, f"TOwk{n}dia{i}")
# -- Cuaresma --
set_day(f.ash_wed, 141, cyc, "Ceniza")
for i, idx in enumerate((142, 143, 144)):
set_day(f.ash_wed + timedelta(days=1 + i), idx, cyc, "post-ceniza")
for n in range(1, 6):
base = lent_week_base(n)
sun = f.easter - timedelta(days=49 - 7 * n)
set_day(sun, base + CYC_OFFSET[cyc], cyc, f"Cuaresma{n}Dom")
for i in range(6):
set_day(sun + timedelta(days=1 + i), base + 3 + i, cyc, f"Cuaresma{n}dia{i}")
# -- Semana Santa / Triduo --
set_day(f.palm_sunday, 190 + CYC_OFFSET[cyc], cyc, "Ramos")
for d, idx in ((f.holy_mon, 193), (f.holy_tue, 194), (f.holy_wed, 195), (f.holy_thu, 196)):
set_day(d, idx, cyc, "SemanaSanta")
set_day(f.good_fri, 197, cyc, "ViernesSanto")
set_day(f.holy_sat, 198, cyc, "VigiliaPascual")
# -- Pascua --
set_day(f.easter, 199, cyc, "Resurreccion")
for i in range(6):
set_day(f.easter + timedelta(days=1 + i), 200 + i, cyc, "OctavaPascua")
set_day(f.easter + timedelta(days=7), 206, cyc, "DomIIPascua")
pascua_blocks = [(207, 213), (216, 222), (225, 231), (234, 240)]
for wi, (mon_base, sun_base) in enumerate(pascua_blocks):
prev_sun = f.easter + timedelta(days=7 * (wi + 1))
for i in range(6):
set_day(prev_sun + timedelta(days=1 + i), mon_base + i, cyc, f"Pascua{wi}")
sun = prev_sun + timedelta(days=7)
set_day(sun, sun_base + CYC_OFFSET[cyc], cyc, f"PascuaDom{wi}")
wk7_mon = f.easter + timedelta(days=7 * 5 + 1)
for i in range(6):
set_day(wk7_mon + timedelta(days=i), 243 + i, cyc, "Pascua wk7")
set_day(f.ascension, 249 + CYC_OFFSET[cyc], cyc, "Ascension")
wk8_mon = f.ascension + timedelta(days=1)
for i in range(6):
d = wk8_mon + timedelta(days=i)
if d >= f.pentecost:
break
set_day(d, 252 + i, cyc, "Pascua wk8")
set_day(f.pentecost, 258, cyc, "Pentecostes")
set_day(f.trinity, 259 + CYC_OFFSET[cyc], cyc, "Trinidad")
set_day(f.corpus, 262 + CYC_OFFSET[cyc], cyc, "Corpus")
# Las 3 semanas "tapadas" por Pentecostes/Trinidad/Corpus: el TO
# arranca el lunes despues de Pentecostes (norma real); solo el
# domingo de cada una de esas 3 semanas lo tapa la solemnidad, sus
# ferias Lun-Sab SI son de Tiempo Ordinario.
first_post = f.corpus + timedelta(days=7)
for gap_i, gap_sunday in enumerate((f.pentecost, f.trinity, f.corpus)):
gap_n = f.resume_week - 3 + gap_i
gap_base = ot_week_base(gap_n)
for i in range(6):
set_day(gap_sunday + timedelta(days=1 + i), gap_base + 3 + i, cyc, f"TOwk{gap_n}gap")
# -- Tiempo Ordinario semanas resume_week..34 --
for n in range(f.resume_week, 35):
sun = first_post + timedelta(days=(n - f.resume_week) * 7)
if n == 34:
set_day(sun, 481 + CYC_OFFSET[cyc], cyc, "CristoRey")
for i in range(6):
set_day(sun + timedelta(days=1 + i), 484 + i, cyc, "TOwk34dia")
else:
base = ot_week_base(n)
set_day(sun, base + CYC_OFFSET[cyc], cyc, f"TOwk{n}Dom-post")
for i in range(6):
set_day(sun + timedelta(days=1 + i), base + 3 + i, cyc, f"TOwk{n}dia-post")
# -- Adviento (ciclo del año litúrgico que empieza, y+1) --
cyc_next = cycle_for_civil_year(y + 1)
set_day(f.advent1, 1 + CYC_OFFSET[cyc_next], cyc_next, "Adviento1")
set_day(f.advent2, 10 + CYC_OFFSET[cyc_next], cyc_next, "Adviento2")
set_day(f.advent3, 19 + CYC_OFFSET[cyc_next], cyc_next, "Adviento3")
set_day(f.advent4, 35 + CYC_OFFSET[cyc_next], cyc_next, "Adviento4")
# ferias genericas de Adviento (semanas 1-3), consumidas dia a dia
# (no-domingo) desde adviento1+1 hasta el 16 de diciembre inclusive.
pool = [4, 5, 6, 7, 8, 9, 13, 14, 15, 16, 17, 18, 22, 23, 24, 25, 26]
pi = 0
d = f.advent1 + timedelta(days=1)
cutover = date(y, 12, 17)
while d < cutover:
if d.weekday() != 6:
if pi >= len(pool):
raise RuntimeError(f"pool de ferias de Adviento agotado en {d.isoformat()}")
set_day(d, pool[pi], cyc_next, "AdvientoFeria")
pi += 1
d += timedelta(days=1)
# 17..23 de diciembre por fecha fija.
fixed_dec = {17: 27, 18: 28, 19: 29, 20: 30, 21: 31, 22: 32, 23: 33}
for day_num, idx in fixed_dec.items():
d = date(y, 12, day_num)
if d.weekday() == 6:
continue # ya asignado como domingo de Adviento arriba
set_day(d, idx, cyc_next, f"{day_num}dic")
# 24 de diciembre: SIEMPRE Nochebuena (idx38) salvo que sea Adviento4
# (domingo). La feria generica "24 diciembre" (idx34) por tanto no se
# usa nunca con este diseño -- decision de contenido a confirmar, ver
# nota en el JSON de salida.
d24 = date(y, 12, 24)
if d24.weekday() != 6:
set_day(d24, 38, cyc_next, "Nochebuena")
set_day(date(y, 12, 25), 39, cyc_next, "Navidad")
# -- 26 dic .. Bautismo, atraviesa el limite de año civil --
for y in range(year_from - 1, year_to + 1):
cyc_adv = cycle_for_civil_year(y + 1)
fnext = facts[y + 1]
holy_family = fnext.holy_family
fixed_oct = {26: 41, 27: 42, 28: 43, 29: 44, 30: 45, 31: 46}
for day_num, idx in fixed_oct.items():
d = date(y, 12, day_num)
if d == holy_family:
set_day(d, 40, cyc_adv, "SagradaFamilia")
else:
set_day(d, idx, cyc_adv, f"{day_num}dic-octava")
ny = y + 1
if ny > year_to + 1:
continue
fb = facts[ny]
cyc_ny = cycle_for_civil_year(ny)
set_day(date(ny, 1, 1), 47, cyc_adv, "MariaMadre")
dom2 = fb.dom2_navidad
for day_num in (2, 3, 4, 5):
d = date(ny, 1, day_num)
if dom2 is not None and d == dom2:
set_day(d, 48, cyc_adv, "DomIINavidad")
else:
idx = {2: 49, 3: 50, 4: 51, 5: 52}[day_num]
set_day(d, idx, cyc_adv, f"{day_num}ene")
set_day(fb.epiphany, 53, cyc_ny, "Epifania")
set_day(fb.baptism, 54 + CYC_OFFSET[cyc_ny], cyc_ny, "Bautismo")
d = fb.epiphany + timedelta(days=1)
fixed_ene = {7: 57, 8: 58, 9: 59, 10: 60, 11: 61, 12: 62}
while d < fb.baptism:
if d.day in fixed_ene:
set_day(d, fixed_ene[d.day], cyc_ny, f"{d.day}ene")
d += timedelta(days=1)
return out, facts
def apply_fixed_feasts(out: dict, facts: dict, year_from: int, year_to: int) -> list[tuple[str, str, int]]:
"""Aplica las 11 solemnidades de fecha fija del libro (8dic..2nov) con
precedencia real: ceden ante domingos de Adviento/Cuaresma/Pascua, Ramos,
lunes-jueves de Semana Santa, Triduo, Ascension y Pentecostes (rank1), y
ante Trinidad/Corpus/Cristo Rey (rank2 "del Señor" > rank2 "de un
Santo"). En cualquier otro dia (feria ordinaria) gana la fija."""
fixed = {
(12, 8): 490, (3, 19): 491, (3, 25): 492, (6, 24): 493, (6, 29): 494,
(7, 22): 495, (7, 25): 496, (8, 6): 497, (8, 15): 498, (11, 1): 499, (11, 2): 500,
}
def rank1_days(f) -> set[date]:
s = {f.ash_wed, f.palm_sunday, f.holy_mon, f.holy_tue, f.holy_wed,
f.holy_thu, f.good_fri, f.holy_sat, f.easter, f.pentecost,
f.advent1, f.advent2, f.advent3, f.advent4}
for n in range(1, 6):
s.add(f.easter - timedelta(days=49 - 7 * n))
for i in range(6):
s.add(f.easter + timedelta(days=1 + i))
for n in range(1, 7):
s.add(f.easter + timedelta(days=7 * n))
return s
def rank2_movable_days(f) -> set[date]:
return {f.trinity, f.corpus, f.christ_king}
transferred: list[tuple[str, str, int]] = []
for y in range(year_from, year_to + 1):
f = facts[y]
r1 = rank1_days(f)
r2m = rank2_movable_days(f)
for (mo, da), idx in fixed.items():
d = date(y, mo, da)
iso = d.isoformat()
if iso not in out:
continue
if d not in r1 and d not in r2m:
out[iso] = {"idx": idx, "cycle": out[iso]["cycle"], "note": "fija-normal"}
continue
loser_note = out[iso]["note"]
cyc = out[iso]["cycle"]
if idx == 492: # Anunciacion: regla especifica documentada
if d in {f.easter} or any(d == f.easter + timedelta(days=i) for i in range(1, 7)) \
or d in {f.easter + timedelta(days=7 * n) for n in range(1, 7)} \
or d in {f.palm_sunday, f.holy_mon, f.holy_tue, f.holy_wed, f.holy_thu}:
target = f.easter + timedelta(days=8) # lunes tras el 2º domingo de Pascua
else:
target = d + timedelta(days=1) # domingo de Cuaresma -> dia siguiente
else:
target = d + timedelta(days=1) # norma general: dia siguiente libre
tries = 0
while target in r1 or target in r2m or (
target.isoformat() in out and out[target.isoformat()]["idx"] >= 490
):
target += timedelta(days=1)
tries += 1
if tries > 15:
raise RuntimeError(f"no se encontro hueco para trasladar idx={idx} desde {iso}")
set_iso = target.isoformat()
out[set_iso] = {"idx": idx, "cycle": cyc, "note": f"TRASLADADA de {iso} ({loser_note})"}
transferred.append((iso, set_iso, idx))
return transferred
def coverage_report(out: dict) -> tuple[list[int], list[int]]:
book = json.loads(BOOK_PATH.read_text(encoding="utf-8"))
used = {v["idx"] for v in out.values()}
invalid = sorted(i for i in used if i < 1 or i > len(book))
never_used = sorted(set(range(1, len(book) + 1)) - used)
return invalid, never_used
def main() -> None:
out, facts = build_calendar(YEAR_FROM, YEAR_TO)
transferred = apply_fixed_feasts(out, facts, YEAR_FROM, YEAR_TO)
out = {k: v for k, v in sorted(out.items()) if f"{YEAR_FROM}-01-01" <= k <= f"{YEAR_TO}-12-31"}
expected_days = (date(YEAR_TO, 12, 31) - date(YEAR_FROM, 1, 1)).days + 1
assert len(out) == expected_days, f"faltan fechas: {len(out)} != {expected_days}"
invalid, never_used = coverage_report(out)
assert not invalid, f"indices fuera de 1-500: {invalid}"
dates_out = {k: {"book_index": v["idx"], "cycle": v["cycle"]} for k, v in out.items()}
result = {
"schema": 2,
"range": {"from": f"{YEAR_FROM}-01-01", "to": f"{YEAR_TO}-12-31"},
"sources": [
"https://www.conferenciaepiscopal.es/tabla-temporal-celebraciones-ano-liturgico-2021-2044/",
"Computo de Pascua: algoritmo gregoriano (Meeus/Jones/Butcher), independiente de tablas publicadas",
"Normas Universales sobre el Año Litúrgico y el Calendario (1969): tabla de precedencia y traslados",
],
"notes": [
"Cada fecha referencia book_index (1-500) sobre entradas-libro.json, no una clave de texto libre.",
"TO: numeracion con omision de semanas segun la norma real (cuenta atras desde Cristo Rey=semana34).",
"Ascension y Corpus trasladados al domingo (CEE); Bautismo/Sagrada Familia/Cristo Rey con indice propio.",
"Solemnidades fijas con logica de precedencia real (Adviento/Cuaresma/Pascua/Ramos/Triduo/Ascension/"
"Pentecostes ganan; entre solemnidades 'del Señor' > 'de un Santo'; Anunciacion con regla especifica).",
f"idx nunca alcanzado en {YEAR_FROM}-{YEAR_TO}: {never_used} (aritmetica real del rango, ver issue #20; "
"idx34 '24 diciembre' es ademas una decision de contenido: Nochebuena(38) gana siempre el 24-dic).",
],
"dates": dates_out,
}
OUT_PATH.write_text(json.dumps(result, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
print(f"escritas {len(dates_out)} fechas en {OUT_PATH}")
print(f"solemnidades fijas trasladadas: {len(transferred)}")
for t in transferred:
print(" ", t)
print(f"indices del libro nunca alcanzados en {YEAR_FROM}-{YEAR_TO}: {never_used}")
if __name__ == "__main__":
main()
-182
View File
@@ -1,182 +0,0 @@
#!/usr/bin/env python3
"""Parse the InDesign HTML export of *A la fuente cada día*.
The output is deliberately source-only: it contains no civil-date mapping.
That mapping belongs in the separate, reviewable liturgical calendar JSON.
"""
from __future__ import annotations
import argparse
import html
import json
import re
from pathlib import Path
TITLE_SOLEMNITY = "V-T-tulo-2-l-nea"
TITLE_OR_CITATION = "V-T-tulo-L-a-S"
GOSPEL = "Sangr-a-2-de-t--independiente"
MOTTO = "V-P-rrafo-2"
PARAGRAPH = "V-P-rrafo"
CITATION_RE = re.compile(r"\((?:Mt|Mc|Lc|Jn)\b[^)]*\)", re.IGNORECASE)
MANUAL_CITATIONS = {
# Three malformed/omitted references in the InDesign export, resolved from
# the printed Gospel text in the same source entry.
"Miércoles de la 32ª semana Lc 17,11-19)": "Lc 17,11-19",
"22 de Julio": "Jn 20,1-2.11-18",
"2 de noviembre": "Jn 11,21-44",
"DOMINGO IV DE ADVIENTO (C)": "Lc 1,39-45",
"Sábado de la 7ª semana (Mc 10 13-16)": "Mc 10,13-16",
"8 de diciembre": "Lc 1,26-38",
"1 de noviembre": "Mt 5,1-12a",
"DOMINGO DE RAMOS (A)": "Mt 26,14-27,66",
"DOMINGO DE RAMOS (B)": "Mc 14,1-15,47",
"DOMINGO DE RAMOS (C)": "Lc 22,14-23,56",
"VIERNES SANTO": "Jn 18,1-19,42",
"DOMINGO IV TIEMPO ORDINARIO (A)": "Mt 5,1-12a",
}
def text(fragment: str) -> str:
"""Return normalized text from the small, well-formed HTML fragments."""
fragment = re.sub(r"<[^>]+>", "", fragment)
return re.sub(r"\s+", " ", html.unescape(fragment).replace("\xa0", " ")).strip()
def css_base(class_name: str) -> str:
return class_name.split(" ", 1)[0]
def is_citation(value: str) -> bool:
return bool(CITATION_RE.fullmatch(value.strip()))
def clean_citation(value: str) -> str:
match = CITATION_RE.search(value)
return (match.group(0) if match else value).strip().removeprefix("(").removesuffix(")").strip()
def new_entry(title: str, kind: str) -> dict[str, object]:
return {
"source_index": 0,
"kind": kind,
"title": title,
"citation": "",
"gospel": "",
"motto": "",
"paragraphs": [],
"editorial_notes": [],
}
def set_citation_from(value: str, entry: dict[str, object]) -> None:
"""Capture a reference embedded in a weekday or feast-title line."""
if entry["citation"]:
return
match = CITATION_RE.search(value)
if match:
entry["citation"] = clean_citation(match.group(0))
elif "pasión según" in value.lower():
entry["citation"] = value.strip().removeprefix("(").removesuffix(")")
def parse(source: Path) -> list[dict[str, object]]:
raw = source.read_text(encoding="utf-8")
blocks = re.findall(r'<p class="([^"]+)"[^>]*>(.*?)</p>', raw, re.S)
entries: list[dict[str, object]] = []
current: dict[str, object] | None = None
def finish() -> None:
nonlocal current
if current is not None:
current["source_index"] = len(entries) + 1
entries.append(current)
current = None
for class_name, fragment in blocks:
role, value = css_base(class_name), text(fragment)
if not value:
continue
if role == TITLE_SOLEMNITY:
finish()
current = new_entry(value, "solemnity")
set_citation_from(value, current)
continue
if role == TITLE_OR_CITATION and not is_citation(value):
# This class is overloaded. After an already complete entry it is
# the next weekday title (and also the Holy Family title). Right
# after a large title it is instead a rubric such as "INMACULADA"
# or "Pasión según Mt", so it must remain part of that entry.
if current is None or current["motto"] or current["paragraphs"]:
finish()
current = new_entry(value, "weekday")
set_citation_from(value, current)
continue
if current is None:
continue
if role == TITLE_OR_CITATION and is_citation(value):
current["citation"] = clean_citation(value)
elif role == TITLE_OR_CITATION:
set_citation_from(value, current)
elif role == GOSPEL:
current["gospel"] = value
elif role == MOTTO:
current["motto"] = value
elif role == PARAGRAPH:
if not current["gospel"] and not current["motto"] and value.startswith("Si toca alguna semana anterior"):
current["editorial_notes"].append(value)
continue
# One source inconsistency (Easter VI, cycle A) styles the Gospel as
# a normal paragraph. Before the motto, that position is unambiguous.
if not current["gospel"] and not current["motto"]:
current["gospel"] = value
else:
current["paragraphs"].append(value)
finish()
for entry in entries:
if entry["title"] in MANUAL_CITATIONS:
entry["citation"] = MANUAL_CITATIONS[entry["title"]]
return entries
def audit(entries: list[dict[str, object]]) -> list[str]:
issues: list[str] = []
for entry in entries:
missing = [field for field in ("citation", "gospel", "motto") if not entry[field]]
if not entry["paragraphs"]:
missing.append("paragraphs")
if missing:
issues.append(f"#{entry['source_index']} {entry['title']}: missing {', '.join(missing)}")
return issues
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("source", type=Path)
parser.add_argument("output", type=Path)
args = parser.parse_args()
entries = parse(args.source)
issues = audit(entries)
# The earlier exploratory JSON had 504 records, but four were artificial
# citation-only splits (Holy Family and Baptism A/B/C). The source has 500
# complete, independently publishable comments.
if len(entries) != 500:
raise SystemExit(f"expected 500 complete entries; got {len(entries)}")
if issues:
raise SystemExit("source audit failed:\n" + "\n".join(issues))
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(entries, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"wrote {len(entries)} complete entries to {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-155
View File
@@ -1,155 +0,0 @@
#!/usr/bin/env python3
"""Test de aceptación del calendario litúrgico 2026-2040 (issue #20).
Criterio acordado entre Codix/Opix/Claudix: cobertura bidireccional contra
las 500 entradas del libro, más un puñado de fechas móviles verificadas de
forma independiente (cómputo de Pascua propio, no la fuente del generador).
Uso: python3 tests/test_liturgical_calendar_coverage.py
Sale con exit code != 0 si algo falla.
"""
from __future__ import annotations
import json
import sys
from datetime import date, timedelta
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CAL_PATH = ROOT / "data/evangelio-diario/calendario-liturgico-2026-2040.json"
BOOK_PATH = ROOT / "data/evangelio-diario/entradas-libro.json"
def easter_sunday(year: int) -> date:
a = year % 19
b = year // 100
c = year % 100
d_ = b // 4
e = b % 4
f = (b + 8) // 25
g = (b - f + 1) // 3
h = (19 * a + b - d_ - g + 15) % 30
i = c // 4
k = c % 4
l = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 22 * l) // 451
month = (h + l - 7 * m + 114) // 31
day = ((h + l - 7 * m + 114) % 31) + 1
return date(year, month, day)
def fail(msg: str, errors: list[str]) -> None:
errors.append(msg)
def main() -> int:
errors: list[str] = []
cal = json.loads(CAL_PATH.read_text(encoding="utf-8"))
book = json.loads(BOOK_PATH.read_text(encoding="utf-8"))
dates = cal["dates"]
n_book = len(book)
# 1) toda fecha del rango existe y tiene book_index valido
d = date.fromisoformat(cal["range"]["from"])
end = date.fromisoformat(cal["range"]["to"])
n_dates = 0
while d <= end:
iso = d.isoformat()
if iso not in dates:
fail(f"fecha ausente: {iso}", errors)
else:
idx = dates[iso]["book_index"]
if not (1 <= idx <= n_book):
fail(f"{iso}: book_index fuera de rango: {idx}", errors)
n_dates += 1
d += timedelta(days=1)
# 2) toda entrada del libro es alcanzable en algun año del rango
used = {v["book_index"] for v in dates.values()}
never_used = sorted(set(range(1, n_book + 1)) - used)
# las 3 excepciones conocidas y documentadas en el propio JSON (ver "notes")
known_gaps = {34, 62, 134}
unexpected_gaps = [i for i in never_used if i not in known_gaps]
if unexpected_gaps:
fail(f"entradas del libro nunca alcanzadas (no documentadas): {unexpected_gaps}", errors)
# 3) fechas moviles clave, computadas de forma independiente (no depende
# del propio generador ni de su fuente CEE)
checks_ok = 0
for year in range(2026, 2041):
easter = easter_sunday(year)
expects = [
(easter, 199, "Pascua"),
(easter - timedelta(days=46), 141, "Ceniza"),
(easter + timedelta(days=49), 258, "Pentecostes"),
]
for dt, expected_idx, label in expects:
v = dates.get(dt.isoformat())
got = v["book_index"] if v else None
if got != expected_idx:
fail(f"{year} {label} {dt}: esperado idx={expected_idx}, obtenido={got}", errors)
else:
checks_ok += 1
palm = easter - timedelta(days=7)
v = dates.get(palm.isoformat())
if not (v and 190 <= v["book_index"] <= 192):
fail(f"{year} Ramos {palm}: obtenido={v}", errors)
else:
checks_ok += 1
trinity = easter + timedelta(days=56)
v = dates.get(trinity.isoformat())
if not (v and 259 <= v["book_index"] <= 261):
fail(f"{year} Trinidad {trinity}: obtenido={v}", errors)
else:
checks_ok += 1
# 4) solemnidades con clave propia, todos los años (no diluidas en un
# domingo/feria generico) -- el bug central del commit retirado 2713e13
for year in range(2026, 2041):
ep = date(year, 1, 6)
baptism = (ep + timedelta(days=1)) if ep.weekday() == 6 else (
ep + timedelta(days=(6 - ep.weekday()) % 7)
)
v = dates.get(baptism.isoformat())
if not (v and 54 <= v["book_index"] <= 56):
fail(f"{year} Bautismo {baptism}: sin clave propia ({v})", errors)
easter = easter_sunday(year)
ascension = easter + timedelta(days=42)
v = dates.get(ascension.isoformat())
if not (v and 249 <= v["book_index"] <= 251):
fail(f"{year} Ascension {ascension}: sin clave propia ({v})", errors)
corpus = easter + timedelta(days=63)
v = dates.get(corpus.isoformat())
if not (v and 262 <= v["book_index"] <= 264):
fail(f"{year} Corpus {corpus}: sin clave propia o no en domingo ({v})", errors)
years_with_ck = {k[:4] for k, v in dates.items() if 481 <= v["book_index"] <= 483}
if len(years_with_ck) != 15:
fail(f"Cristo Rey ausente en algun año: {sorted(years_with_ck)}", errors)
years_with_family = {k[:4] for k, v in dates.items() if v["book_index"] == 40}
if len(years_with_family) != 15:
fail(f"Sagrada Familia ausente en algun año: {sorted(years_with_family)}", errors)
# 5) Adviento no debe seguir contando tras Navidad (fuga del commit retirado)
leak = [k for k, v in dates.items()
if k[5:7] == "12" and int(k[8:10]) >= 26 and v["book_index"] <= 37]
if leak:
fail(f"fuga de Adviento tras Navidad: {leak[:10]}", errors)
print(f"fechas verificadas: {n_dates}")
print(f"book_index distintos usados: {len(used)}/{n_book}")
print(f"chequeos de fechas moviles ok: {checks_ok}")
if errors:
print(f"\nFALLOS ({len(errors)}):")
for e in errors:
print(" -", e)
return 1
print("\nTodo OK.")
return 0
if __name__ == "__main__":
sys.exit(main())