Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10e5431365 | |||
| 67857638ba | |||
| 3da7c79f5a | |||
| 83db29d389 | |||
| 4d25a8d2e5 | |||
| 14b94be5ea | |||
| 2713e13470 | |||
| 5807151a99 | |||
| 293cd86896 |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -87,15 +87,6 @@ FEA_TTS_CARTAS="<ID_CARTA>" python3 scripts/tts_produce.py
|
||||
Reanudable (no repite lo ya hecho, meta `fea_audio_done`) y con freno automático si la cuota de
|
||||
MiniMax se agota (para tras fallos seguidos, no se queda colgado).
|
||||
|
||||
**Alcance del TTS — qué NO llevar a audio (confusión real en la carta 741, 2026-08-26):**
|
||||
- **Solo el artículo original en español.** Las traducciones (EN/FR/IT/PT) nunca llevan audio
|
||||
propio — no meter los posts de traducción en la cola (`FEA_TTS_IDS`/`FEA_TTS_CARTAS`).
|
||||
- **Un audio por artículo, nunca uno combinado de toda la carta.** `tts_produce.py` es correcto
|
||||
(genera un mp3 por post). No usar `scripts/tts_carta.py` (legacy, concatena todos los artículos
|
||||
de la carta en un único audio) — no es el flujo que se usa hoy.
|
||||
- **Nunca la categoría `multimedia`** (término `multimedia`, id 1649 — ver
|
||||
`scripts/aplicar_clasificacion_a_bd.py`). Esas piezas no se locutan.
|
||||
|
||||
**Publicar el audio en prod** (el paso anterior solo escribe en el WordPress local):
|
||||
```bash
|
||||
python3 scripts/sync_audio_to_prod.py --carta <ID_CARTA>
|
||||
|
||||
@@ -125,6 +125,18 @@ add_action('wp_footer', function () {
|
||||
document.querySelectorAll('audio[data-fea-audio-track]').forEach(function (audio) {
|
||||
var fired = false;
|
||||
audio.addEventListener('play', function () {
|
||||
// La verificación pudo fallar en el primer intento (p.ej. audio.play()
|
||||
// rechazado por política de autoplay tras el await de verificación) y
|
||||
// dejar el mensaje de error en pantalla. Si el audio realmente rompe a
|
||||
// reproducir -en este intento o en uno posterior desde los controles
|
||||
// nativos-, ese es el estado real: limpiar el aviso y ocultar el botón.
|
||||
var wrap = audio.closest('.fea-audio');
|
||||
if (wrap) {
|
||||
var status = wrap.querySelector('.fea-audio-status');
|
||||
if (status) status.textContent = '';
|
||||
var button = wrap.querySelector('[data-fea-audio-verified-play]');
|
||||
if (button) button.hidden = true;
|
||||
}
|
||||
if (fired || typeof gtag !== 'function') return;
|
||||
fired = true;
|
||||
gtag('event', 'audio_play', {
|
||||
|
||||
@@ -1204,8 +1204,9 @@ add_shortcode('fea_evangelio', function($atts) {
|
||||
// "El Evangelio de cada día": dos devocionales diarios indexados por día del año.
|
||||
// · A la fuente cada día (texto, Fray Marcos) → categoría term_id 14
|
||||
// · Otro evangelio es posible (vídeo YouTube) → categoría term_id 15
|
||||
// Los posts están titulados "D mes" (ej. "21 junio"). Se muestra el de HOY
|
||||
// (zona horaria del sitio) o el día indicado por ?fed=M-D, con pestañas para
|
||||
// El texto de Fray Marcos se resuelve contra el calendario litúrgico versionado
|
||||
// (fecha ISO -> book_index), no por el título civil heredado de Joomla. Se muestra
|
||||
// el de HOY (zona horaria del sitio) o el día indicado por ?fed=M-D, con pestañas para
|
||||
// que el usuario elija formato y navegación día anterior / siguiente.
|
||||
// Contenido solo en ES (devocional sin traducción) → categorías 14/15 fijas.
|
||||
add_shortcode('fea_evangelio_diario', function($atts) {
|
||||
@@ -1220,23 +1221,61 @@ add_shortcode('fea_evangelio_diario', function($atts) {
|
||||
$gm = (int) $mm[1]; $gd = (int) $mm[2];
|
||||
if ($gm >= 1 && $gm <= 12 && $gd >= 1 && $gd <= 31) { $m = $gm; $d = $gd; }
|
||||
}
|
||||
$titulo_dia = $d . ' ' . $MESES[$m]; // "21 junio"
|
||||
$date = $now->setDate((int) $now->format('Y'), $m, $d);
|
||||
$calendar_path = defined('FEA_EVANGELIO_CALENDAR_PATH')
|
||||
? FEA_EVANGELIO_CALENDAR_PATH
|
||||
: WP_CONTENT_DIR . '/fea-data/calendario-liturgico-2026-2040.json';
|
||||
$calendar = is_readable($calendar_path)
|
||||
? json_decode((string) file_get_contents($calendar_path), true)
|
||||
: null;
|
||||
$book_index = (int) ($calendar['dates'][$date->format('Y-m-d')]['book_index'] ?? 0);
|
||||
|
||||
$find = function($cat) use ($titulo_dia) {
|
||||
$find_text = function($index) {
|
||||
global $wpdb;
|
||||
if ($index < 1) return null;
|
||||
$id = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT p.ID FROM {$wpdb->posts} p
|
||||
JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID
|
||||
WHERE pm.meta_key = '_fea_book_index' AND pm.meta_value = %d
|
||||
AND p.post_type = 'post' AND p.post_status = 'publish'
|
||||
ORDER BY p.ID ASC LIMIT 1",
|
||||
$index));
|
||||
return $id ? get_post((int) $id) : null;
|
||||
};
|
||||
$texto = $find_text($book_index);
|
||||
// El vídeo queda deliberadamente con el índice civil heredado: su catálogo
|
||||
// no forma parte de la migración litúrgica de #20.
|
||||
$titulo_dia = $d . ' ' . $MESES[$m];
|
||||
$find_video = function() use ($titulo_dia) {
|
||||
global $wpdb;
|
||||
$id = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT p.ID FROM {$wpdb->posts} p
|
||||
JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
|
||||
JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
|
||||
WHERE tt.taxonomy = 'category' AND tt.term_id = %d
|
||||
WHERE tt.taxonomy = 'category' AND tt.term_id = 15
|
||||
AND p.post_type = 'post' AND p.post_status = 'publish'
|
||||
AND LOWER(TRIM(p.post_title)) = %s
|
||||
ORDER BY p.ID ASC LIMIT 1",
|
||||
$cat, $titulo_dia));
|
||||
ORDER BY p.ID ASC LIMIT 1", $titulo_dia));
|
||||
return $id ? get_post((int) $id) : null;
|
||||
};
|
||||
$texto = $find(14);
|
||||
$video = $find(15);
|
||||
$video = $find_video();
|
||||
// Hasta que el importador instale el JSON y reindexe, conservar el contenido
|
||||
// civil heredado evita dejar la pestaña de texto en blanco durante el despliegue.
|
||||
if (!$texto && !$book_index) {
|
||||
$legacy_text = function() use ($titulo_dia) {
|
||||
global $wpdb;
|
||||
$id = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT p.ID FROM {$wpdb->posts} p
|
||||
JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
|
||||
JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
|
||||
WHERE tt.taxonomy = 'category' AND tt.term_id = 14
|
||||
AND p.post_type = 'post' AND p.post_status = 'publish'
|
||||
AND LOWER(TRIM(p.post_title)) = %s
|
||||
ORDER BY p.ID ASC LIMIT 1", $titulo_dia));
|
||||
return $id ? get_post((int) $id) : null;
|
||||
};
|
||||
$texto = $legacy_text();
|
||||
}
|
||||
|
||||
// navegación por día del calendario (año irrelevante; usamos un año bisiesto fijo)
|
||||
$base = get_permalink();
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
/**
|
||||
* Migra A la fuente cada día al índice estable del libro.
|
||||
*
|
||||
* Uso (por defecto solo informa):
|
||||
* wp eval-file scripts/import_evangelio_diario.php --path=/var/www/html
|
||||
* Escritura explícita: FEA_EVANGELIO_APPLY=1 wp eval-file ...
|
||||
*
|
||||
* Requiere que entradas-libro.json esté junto a este script durante la ejecución.
|
||||
* Nunca elimina posts: actualiza únicamente los ya identificados de categoría 14 y
|
||||
* crea los índices ausentes. Es idempotente por _fea_book_index.
|
||||
*/
|
||||
$apply = getenv('FEA_EVANGELIO_APPLY') === '1';
|
||||
$book_file = __DIR__ . '/../data/evangelio-diario/entradas-libro.json';
|
||||
$calendar_file = __DIR__ . '/../data/evangelio-diario/calendario-liturgico-2026-2040.json';
|
||||
if (!is_readable($book_file)) { fwrite(STDERR, "No se lee $book_file\n"); exit(2); }
|
||||
if (!is_readable($calendar_file)) { fwrite(STDERR, "No se lee $calendar_file\n"); exit(2); }
|
||||
$book = json_decode(file_get_contents($book_file), true, 512, JSON_THROW_ON_ERROR);
|
||||
if (count($book) !== 500) { fwrite(STDERR, "El libro debe tener 500 entradas\n"); exit(2); }
|
||||
|
||||
$norm = static function ($text) {
|
||||
$text = html_entity_decode(wp_strip_all_tags((string) $text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
return preg_replace('/\s+/u', ' ', trim(mb_strtolower($text, 'UTF-8')));
|
||||
};
|
||||
$by_motto = [];
|
||||
foreach ($book as $i => $entry) {
|
||||
$key = $norm($entry['motto']);
|
||||
if ($key !== '') $by_motto[$key][] = $i + 1;
|
||||
}
|
||||
$existing = get_posts([
|
||||
'post_type' => 'post', 'post_status' => 'any', 'posts_per_page' => -1,
|
||||
'category' => 14, 'orderby' => 'ID', 'order' => 'ASC',
|
||||
]);
|
||||
$used = [];
|
||||
$updated = $ambiguous = $unmatched = $duplicate = 0;
|
||||
$to_update = [];
|
||||
foreach ($existing as $post) {
|
||||
$matches = [];
|
||||
foreach ($by_motto as $motto => $indices) {
|
||||
if (str_contains($norm($post->post_content), $motto)) $matches = array_merge($matches, $indices);
|
||||
}
|
||||
$matches = array_values(array_unique($matches));
|
||||
if (count($matches) !== 1) {
|
||||
$ambiguous += count($matches) > 1; $unmatched += count($matches) === 0;
|
||||
continue;
|
||||
}
|
||||
if (isset($used[$matches[0]])) { $duplicate++; continue; }
|
||||
$idx = $matches[0]; $used[$idx] = true;
|
||||
$entry = $book[$idx - 1];
|
||||
$to_update[] = [$post->ID, $idx, $entry];
|
||||
$updated++;
|
||||
}
|
||||
$planned_new = count($book) - count($used);
|
||||
if ($ambiguous || $unmatched || $duplicate) {
|
||||
printf("DRY-RUN: existentes indexados=%d; nuevos=%d; ambiguos=%d; sin coincidencia=%d; duplicados=%d; total=%d\n",
|
||||
$updated, $planned_new, $ambiguous, $unmatched, $duplicate, count($book));
|
||||
fwrite(STDERR, "ABORTAR APLICACIÓN: hay posts existentes sin correspondencia única.\n");
|
||||
exit(3);
|
||||
}
|
||||
// El JSON se instala antes de tocar contenido: el shortcode lo consume desde
|
||||
// WP_CONTENT_DIR/fea-data y así código y calendario llegan como una unidad.
|
||||
if ($apply) {
|
||||
$calendar_target = WP_CONTENT_DIR . '/fea-data/calendario-liturgico-2026-2040.json';
|
||||
if (!wp_mkdir_p(dirname($calendar_target)) || !copy($calendar_file, $calendar_target)) {
|
||||
fwrite(STDERR, "No se pudo instalar el calendario en $calendar_target\n"); exit(1);
|
||||
}
|
||||
}
|
||||
if ($apply) foreach ($to_update as [$post_id, $idx, $entry]) {
|
||||
update_post_meta($post_id, '_fea_book_index', (string) $idx);
|
||||
update_post_meta($post_id, '_fea_cita', $entry['citation']);
|
||||
update_post_meta($post_id, '_fea_fuente', 'A la fuente cada día — Fray Marcos, entrada #' . $idx);
|
||||
}
|
||||
$created = 0;
|
||||
foreach ($book as $i => $entry) {
|
||||
$idx = $i + 1;
|
||||
if (isset($used[$idx])) continue;
|
||||
$body = '<h1>' . esc_html($entry['title']) . '</h1>'
|
||||
. '<p><strong>' . esc_html($entry['citation']) . '</strong></p>'
|
||||
. '<p>' . esc_html($entry['gospel']) . '</p>'
|
||||
. '<p><em>' . esc_html($entry['motto']) . '</em></p>';
|
||||
foreach ($entry['paragraphs'] as $paragraph) $body .= '<p>' . esc_html($paragraph) . '</p>';
|
||||
if ($apply) {
|
||||
$id = wp_insert_post(['post_title' => $entry['title'], 'post_content' => $body,
|
||||
'post_status' => 'publish', 'post_category' => [14]], true);
|
||||
if (is_wp_error($id)) { fwrite(STDERR, $id->get_error_message() . "\n"); exit(1); }
|
||||
update_post_meta($id, '_fea_book_index', (string) $idx);
|
||||
update_post_meta($id, '_fea_cita', $entry['citation']);
|
||||
update_post_meta($id, '_fea_fuente', 'A la fuente cada día — Fray Marcos, entrada #' . $idx);
|
||||
}
|
||||
$created++;
|
||||
}
|
||||
printf("%s: existentes indexados=%d; nuevos=%d; ambiguos=%d; sin coincidencia=%d; duplicados=%d; total=%d\n",
|
||||
$apply ? 'APLICADO' : 'DRY-RUN', $updated, $created, $ambiguous, $unmatched, $duplicate, count($book));
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/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())
|
||||
@@ -187,13 +187,6 @@ def save_state(state: dict) -> None:
|
||||
|
||||
# ── Sync ─────────────────────────────────────────────────────────────────────
|
||||
def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
|
||||
# issue #190: páginas de cuentas/donaciones (ej. #18036 "numeros") llevan
|
||||
# fea_audio_skip=1 desde el hotfix del 3-ago, pero pueden conservar un
|
||||
# fea_audio_done=1 y un mp3 locales obsoletos de antes del guard. Sin este
|
||||
# chequeo, cualquier --carta cuyo cluster las incluya vuelve a publicar el
|
||||
# audio improcedente en prod (nos pasó el 26-ago con la carta 741).
|
||||
if local_meta(post_id, "fea_audio_skip") == "1":
|
||||
return "excluido (fea_audio_skip)"
|
||||
if local_meta(post_id, "fea_audio_done") != "1":
|
||||
return "sin-audio-local"
|
||||
if not (LOCAL_TTS_DIR / f"{post_id}.mp3").exists():
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user