Compare commits

..

21 Commits

Author SHA1 Message Date
rafa d2f79ae953 docs: cerrar el bloqueante de traducción — endpoint crear-traduccion ya desplegado
Actualiza la guía de autoservicio de traducción con la llamada real a
fea/v1/crear-traduccion (#225, ya en producción), verificada de extremo a
extremo por Claudix contra prod con un post desechable.
2026-08-31 07:50:52 -04:00
rafa 75e2bf2535 Merge pull request 'feat(api): endpoint para enlazar traducciones Polylang (#222)' (#225) from feat/crear-traduccion-api into main 2026-08-31 11:48:40 +00:00
rafa 3fb5a3b3f9 test: usar --form-string para el campo content en la integración
curl -F trata un valor que empieza por "<" como "leer desde este fichero",
no como el literal HTML que se pretendía enviar (falla con curl: (26)
Failed to open/read local data from file). --form-string evita esa lectura
especial. Verificado por Claudix (QA) contra wordpress-web local: con el
fix, los 8 casos pasan y Polylang enlaza idioma/grupo/categoría
correctamente (56426 es -> 56427 en, grupo {es:56426,en:56427}, categoria
6->3077).
2026-08-31 07:47:54 -04:00
rafa 86257843e8 feat: add REST endpoint to create Polylang translations 2026-08-31 07:45:19 -04:00
rafa 8c83a3a695 Merge pull request 'docs: guía de autoservicio TTS para Inma/Mixbot (#222)' (#223) from docs/tts-audio-inma-222 into main 2026-08-31 11:39:04 +00:00
rafa 2f207a5515 docs: añadir lectura de cuota MiniMax a la guía de audio
Rafa confirmó que el backlog historico del #188 se queda con Inma/Mixbot
(su equipo está 24/7, aprovecha mejor las ventanas de 5h de MiniMax) y
pidió documentarles cómo leer la cuota antes de lanzar tandas grandes.
2026-08-31 07:38:52 -04:00
rafa 8b016844d9 docs: cerrar el bloqueante del #222 — endpoint subir-audio ya desplegado
Actualiza la guía de autoservicio TTS con la llamada real a
fea/v1/subir-audio (#224, ya en producción), verificada de extremo a
extremo por Claudix con una subida real y limpieza posterior.
2026-08-31 07:38:22 -04:00
rafa 815412f7fa feat(api): endpoint para subir audio TTS al artículo (#222) (#224) 2026-08-31 11:35:00 +00:00
rafa babff260a1 fix: cap TTS audio uploads at 25 MiB 2026-08-31 07:32:23 -04:00
rafa 1bb1eb925a docs: guía de autoservicio de traducción para Inma/Mixbot
Segunda parte del traspaso pedido por Rafa (audio ya en #223): reglas de
traducción exactas, la técnica de contexto de título del #203 (sin fusionar
a main todavía), checklist de QA reformulado en REST puro, y cómo leer la
cuota de MiniMax. Señala el mismo tipo de hueco que tenía el audio antes del
#222: no hay forma de fijar idioma/grupo Polylang de una traducción por REST.
2026-08-31 07:27:05 -04:00
rafa 0abaacfcc0 feat: add REST endpoint for TTS audio uploads 2026-08-31 07:12:22 -04:00
rafa 5714d60c10 docs: guía de autoservicio TTS para Inma/Mixbot (#222)
Documenta el proceso exacto de preparación de texto y pausas para MiniMax
que hasta ahora solo vivía en scripts/minimax_tts.py, para que Inma/Mixbot
puedan generar audio sin pasar por Hermes/PC de Rafa. Señala el hueco de
REST (fea/v1/subir-audio) que sigue bloqueando el paso de publicación.
2026-08-31 07:06:52 -04:00
rafa 84cb370742 Merge pull request 'feat: preparar migración del evangelio diario (issue #20)' from feat/issue-20-deploy into main
Aprobado por Rafa. Revisión (Claudix, 2026-08-30): importador idempotente con guardas de match dudoso/colisión antes de escribir; despliegue del JSON del calendario cableado en el propio import; fallback civil mientras no está desplegado. Dry-run contra producción limpio tras despejar 5 casos de contenido duplicado real (497/500 en cobertura, 304 existentes + 196 nuevos = 500, 0 ambiguos/duplicados/sin-coincidencia).
2026-08-30 09:12:14 -04:00
rafa 10e5431365 fix: asegurar despliegue del calendario litúrgico 2026-08-30 08:46:55 -04:00
rafa 67857638ba feat: preparar migración del evangelio diario (issue #20) 2026-08-30 08:44:10 -04:00
rafa 3da7c79f5a Merge pull request 'fix: reescribir generador del calendario litúrgico 2026-2040 (issue #20)' from feat/issue-20-evangelio-diario into main
Aprobado por Rafa. Verificación independiente (Claudix, 2026-08-30): test de cobertura 497/500 + 75 chequeos de fechas móviles OK; JSON regenerado desde el script idéntico byte a byte al commiteado; merge limpio.
2026-08-30 08:40:38 -04:00
rafa 83db29d389 Merge pull request 'fea-audio-player: limpiar el aviso de error cuando el audio sí llega a reproducir' (#221) from fix/audio-status-clear-on-play into main 2026-08-30 10:34:56 +00:00
rafa 14b94be5ea fix: reescribir generador del calendario litúrgico 2026-2040 (issue #20)
Retira el commit 2713e13, que tenía 3 causas raíz confirmadas por Opix y
Claudix (ver issue #20, comment-676 y siguientes): domingos de TO/Pascua
desplazados -1 semana, Cuaresma contada mal en bloques de 7 desde Ceniza, y
Adviento evaluado antes que Navidad (fuga el 26-31 de diciembre, rompe la
Sagrada Familia). Además special() daba precedencia a fiestas fijas sobre
domingos y hasta sobre la Pascua misma (2035: la Anunciación se comía el
Domingo de Resurrección).

Reescritura completa:
- Numeración de Tiempo Ordinario con omisión de semanas según la norma real
  (Normas Universales sobre el Año Litúrgico, 1969): se cuenta hacia atrás
  desde Cristo Rey = semana 34.
- Las 3 semanas tapadas por Pentecostés/Trinidad/Corpus llevan ferias de TO
  en sus días laborables (el TO arranca el lunes después de Pentecostés).
- Ascensión y Corpus trasladados al domingo (práctica CEE); Bautismo del
  Señor, Sagrada Familia, Cristo Rey y Ascensión tienen índice propio (antes
  se disolvían en la etiqueta genérica del domingo vecino).
- 26 dic-Bautismo indexado por fecha fija (como el libro), no por día de la
  semana.
- Precedencia real para las 11 solemnidades de fecha fija: ceden ante
  domingos de Adviento/Cuaresma/Pascua, Ramos, lunes-jueves de Semana Santa,
  Triduo, Ascensión y Pentecostés; entre solemnidades "del Señor" > "de un
  Santo" (Corpus gana a San Juan Bautista en 2038, no al revés). Anunciación
  con la regla de traslado específica documentada.

Cambio de diseño: cada fecha referencia directamente `book_index` (1-500)
sobre entradas-libro.json en vez de una clave de texto libre — evita que el
vocabulario del calendario diverja del contenido real del libro.

Test de aceptación (tests/test_liturgical_calendar_coverage.py): cobertura
bidireccional contra las 500 entradas + fechas móviles verificadas con un
cómputo de Pascua independiente. 497/500 entradas alcanzadas en 2026-2040;
las 3 restantes (34, 62, 134) están documentadas en el propio JSON como
aritmética real del rango, no fallo del generador.

Metodología de validación con fuentes no-CEE: horariosdemisa.com y
archisevilla.org (traslado de la Ascensión), remitly.com/aciprensa/nacion.com
(traslado de Corpus), catholicvirginian.org/diocesetucson (regla de traslado
de la Anunciación). Detalle completo en issue #20.

Co-authored-by: rafa <rcalvotorrejon@gmail.com>
2026-08-18 11:03:47 -04:00
rafa 2713e13470 feat: generate 2026-2040 liturgical calendar
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-17 05:33:09 -04:00
rafa 5807151a99 fix: preserve daily Gospel editorial metadata
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-17 01:26:06 -04:00
rafa 293cd86896 feat: add canonical daily Gospel book data
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-17 01:22:06 -04:00
14 changed files with 32275 additions and 9 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,175 @@
# Guía de traducción (EN/FR/IT/PT) en autoservicio — para Inma / Mixbot
> **Para quién es este documento:** para Inma y Mixbot, a partir de la conversación del
> 2026-08-31 en Buzz: además del audio ([#222](https://gitea.feadulta.com/rafa/feadulta/issues/222),
> ver `docs/guia-tts-audio-autoservicio-inma-mixbot.md`), Rafa confirmó que la traducción se
> traspasa también. El endpoint para enlazar traducciones (PR #225) se hizo bajo el paraguas
> del propio #222.
>
> **Motores autorizados por Rafa (2026-08-31):** un agente local con Haiku desde el propio
> Claude/Cowork de Inma (no la API de pago de Rafa — es otra cuenta, otro caso), o MiniMax
> con el mismo token ya compartido para el audio. **Gemma queda fuera para vosotros**: es un
> modelo local que corre en la GPU del PC de Rafa, no accesible desde fuera.
---
## 0. Crear y enlazar la traducción — ya resuelto
Ya existe un endpoint que crea el post traducido **y** lo enlaza con Polylang (idioma +
grupo de traducción) en una sola llamada — no hace falta tocar nada de bajo nivel:
```
POST https://www.feadulta.com/wp-json/fea/v1/crear-traduccion
Authorization: Basic <usuario:contraseña_de_aplicación>
Content-Type: multipart/form-data
es_id=<ID del post ES>
lang=en|fr|it|pt
title=<título traducido>
content=<HTML traducido>
excerpt=<opcional>
status=draft|publish (default draft)
model=<opcional, solo trazabilidad — p.ej. "minimax" o "haiku-local">
```
Respuesta (`201` si crea, `200` si ya existía — **es idempotente**, repetir la llamada con el
mismo `es_id`/`lang` nunca duplica):
```json
{"es_id": 56786, "lang": "en", "translation_id": 56787, "created": true,
"url": "https://www.feadulta.com/en/?p=56787"}
```
Por detrás replica exactamente lo que hacía `scripts/fea_translate_helper.php::create` en
local: asigna el idioma antes de las categorías, mapea cada categoría del ES a su
equivalente traducida (o la deja en español si no existe traducción de esa categoría),
preserva el resto del grupo de traducciones si el ES ya tenía otros idiomas, y añade los
metas de trazabilidad (`traduccion_automatica`, `traduccion_origen`, `traduccion_modelo`,
`traduccion_fecha`).
Verificado por Claudix el 2026-08-31 con una prueba real de extremo a extremo en producción
(post ES desechable → traducción EN creada → confirmado con `pll_get_post_translations` que
el grupo quedó `{es: ..., en: ...}` — todo borrado después).
---
## 1. Las reglas de traducción (igual las use vuestro Claude/Haiku o MiniMax)
Esto es el prompt real que usa el pipeline de Rafa (`scripts/translate_post.py`), verificado
palabra por palabra el 2026-08-31 contra el código — usadlo tal cual, no lo resumáis:
> Eres un traductor profesional de textos religiosos cristianos (espiritualidad y teología
> católica). Traduce del español al {idioma destino}. REGLAS ESTRICTAS:
> 1. Conserva EXACTAMENTE el marcado HTML (etiquetas y atributos) y los shortcodes entre `[ ]`
> y `{ }`. No los traduzcas ni los reordenes.
> 2. NO traduzcas las referencias bíblicas ni sus abreviaturas (p. ej. "Jn 3, 16", "Isaías 5,
> 1-7", "Mt 5"). Déjalas idénticas.
> 3. Conserva los nombres propios de persona y lugar (salvo exónimos establecidos).
> 4. Glosario fijo: "Fe Adulta" → "Fe Adulta" (en/fr/it/pt, no se traduce); "EFFA" → "EFFA"
> (igual, en los 4 idiomas).
> 5. Traducción FIEL: no resumas, no añadas, no comentes.
Para el **título**, además: que quede natural en el idioma destino (no lo dejéis en español
salvo que sea un nombre propio o una marca), y si es un modismo, traducid el sentido, no lo
calquéis literal.
## 2. El "learning" del título — dadle el primer párrafo como contexto
Esto es lo que pedía Rafa explícitamente documentar. **Aviso de precisión:** este cambio
existe como commit (`4475ebb`, rama `feat/issue-200-title-context`) pero **no está fusionado
a `main`** — verificado el 2026-08-31 comparando el histórico. O sea: si alguna vez volvéis a
usar `scripts/translate_post.py` tal cual desde main, el título se traduce SIN este contexto.
Para vuestro proceso propio (agente Haiku o llamada directa a MiniMax) aplicad la técnica de
todas formas, es la que ya dio mejor resultado en pruebas:
- **Traducid primero el cuerpo del artículo, el título al final** — necesitáis el cuerpo ya
traducido (o al menos leído) para poder darle contexto al título.
- Al traducir el título, añadid al prompt el **primer párrafo del cuerpo en español**
(recortado a 2000 caracteres), como contexto de significado — no se traduce ni se incluye
literal en la salida, solo ayuda al modelo a entender modismos o dobles sentidos del
título que sin ese contexto salen mal traducidos.
- Instrucción exacta añadida al prompt del título: *"Si es un modismo, interpreta su sentido
según el contexto, no lo calques."*
## 3. Troceo de artículos largos
Si usáis MiniMax para traducir (no Haiku, que admite el artículo entero de una vez gracias a
su contexto de 200k), trocead el HTML por párrafos completos (nunca a mitad de una etiqueta)
en bloques de ~5.000 caracteres, y unid las traducciones de cada trozo en el mismo orden.
## 4. QA — checklist antes de dar una traducción por buena
Reformulado en REST puro (el script original, `gate_mec2.php`, corre dentro de WP y usa
funciones de Polylang no disponibles desde fuera — pero las comprobaciones sí son igual de
válidas mirando el JSON de cada post por la API):
Para cada origen ES y cada idioma que hayáis traducido, comparad contra el post ES:
1. **Cobertura**: los 4 idiomas presentes (o los que toque — Pagola nunca lleva traducción
automática, ver §5).
2. **Estado**: la traducción en `draft` hasta que se revise (salvo que el ES ya esté
`publish` y la carta lo pida — ver `docs/guia-publicacion-carta-inma.md`).
3. **Sin residuos de generación**: buscar literalmente en el `content` devuelto por la API
cadenas como `</strong>**`, `</strong></strong>`, `&lt;&gt;`, `<<<INI>>>`, `<<<FIN>>>`,
`|||FIN`, o cualquier resto de las marcas del prompt.
4. **Sin alfabetos ajenos**: ningún carácter árabe/cirílico/CJK en el resultado (residuo
típico de generación que se cuela cuando el modelo "piensa" en otro idioma).
5. **Mismo nº de párrafos** que el ES (contar apariciones de `<p` en el HTML). Un desajuste
suele significar que el modelo se comió contenido en el troceado.
6. **Mismo nº de enlaces `<a href>`** que el ES — **es el check que más falla y el más
importante**: en la carta 739, el italiano perdió 15 de 39 enlaces en uno de los trozos
troceados mientras EN/FR salieron bien, y nada posterior lo detecta si no lo comprobáis
aquí. Si hay menos enlaces en la traducción que en el ES, **no la deis por buena**.
7. **Referencias bíblicas equivalentes**: extraer patrones tipo `capítulo,versículo` de
ambos textos y comparar solo capítulo:versículo, **no la abreviatura del libro completa**
(el inglés cambia la coma por dos puntos y traduce el nombre: `Mt 15,21-28` en ES pasa a
`Matthew 15:21-28` en EN — comparar literal daría un falso positivo de "referencia
perdida").
8. **Título distinto del ES** (salvo portugués, donde algunos nombres propios/citas
coinciden de forma legítima).
Capa editorial adicional si tenéis acceso a un modelo capaz de juzgar fidelidad (vuestro
Claude/Haiku local sirve para esto): pedidle que lea origen y traducción y devuelva un
veredicto `approve` / `needs_review` / `rewrite` con la cita exacta del problema — descartad
cualquier hallazgo cuya cita no aparezca literal en el texto (evita ediciones basadas en
alucinaciones del propio revisor).
## 5. Excepciones que no lleváis por la vía automática
- **Autores que aportan su propia traducción humana** (hoy, Pagola: 4 DOCX por carta,
adjuntos en el issue de la semana) — nunca generéis fallback automático para ellos aunque
falte alguno de los 4 idiomas.
- **Textos litúrgicos enlazados desde la carta** (evangelio, lecturas) — confirmar con Rafa
si entran en el alcance de "todo lo de la carta" o se dejan fuera; ha habido confusión
antes sobre esto (no dar por hecho que "ya deben estar traducidos").
## 6. Cómo leer la cuota de MiniMax
Con el mismo token que ya tenéis para el audio:
```bash
curl -s https://api.minimax.io/v1/token_plan/remains \
-H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json"
```
Del JSON de respuesta:
- `base_resp.status_code` debe ser `0` (cualquier otro valor es error de cuenta/plan, ver
`base_resp.status_msg`).
- `model_remains` es una lista por modelo. El de texto/traducción es el que tiene
`model_name == "general"`; los de audio son los `speech-2.8-*` que ya usáis para TTS.
- De cada entrada: `current_interval_remaining_percent` (cuánto queda de la ventana de 5h
que se resetea sola — `end_time` es cuándo) y `current_weekly_remaining_percent` /
`weekly_end_time` (la ventana semanal, más amplia). Restad a 100 para saber "cuánto habéis
gastado" si preferís verlo así.
Igual que con el audio: un HTTP 200 no es garantía de cuota — cuando `t2a_v2` o el endpoint
de traducción devuelvan `base_resp.status_code=2056` (plan agotado) o `1039` (límite de
ritmo), parad y reintentad más tarde, no machaquéis la API.
## 7. Qué falta para que esto sea autoservicio de verdad
Con el endpoint del §0 ya no queda ningún bloqueante de infraestructura conocido para crear
y enlazar traducciones por REST. Lo que sigue siendo trabajo vuestro, no de infraestructura:
generar el texto (motores del principio del documento), aplicar el QA del §4 antes de
llamar al endpoint, y decidir cuándo pasáis una traducción de `draft` a `publish` (mismo
criterio que la carta en español, ver `docs/guia-publicacion-carta-inma.md`).
@@ -0,0 +1,268 @@
# Guía de generación de audio (TTS) en autoservicio — para Inma / Mixbot
> **Para quién es este documento:** para Inma y Mixbot, a partir del traspaso pedido por
> Rafa (issue [#222](https://gitea.feadulta.com/rafa/feadulta/issues/222)): dejar de pedirle
> el audio a Hermes y generarlo y subirlo vosotros mismos, con vuestro propio token de MiniMax.
>
> **Estado a 2026-08-31:** completo y **desplegado en producción**. El endpoint
> `fea/v1/subir-audio` (PR [#224](https://gitea.feadulta.com/rafa/feadulta/pulls/224)) ya está
> en prod y verificado de extremo a extremo por Claudix con una subida real. El punto 1 del
> #222 queda cerrado — ver §6 para la llamada real.
>
> Este documento sustituye, para el caso de uso "Inma/Mixbot generan directamente", a la
> sección 4 de `docs/guia-tts-traduccion-inma.md` (que describe el flujo antiguo vía Hermes
> ejecutando `scripts/tts_produce.py` en el PC de Rafa). Ese flujo sigue existiendo como
> alternativa si alguna vez hace falta que Hermes lo dispare, pero deja de ser el camino
> principal.
---
## 1. Por qué este documento existe
La generación de audio con MiniMax no es "coger el texto del artículo y pedirle un mp3".
El resultado suena mal (entonación cortada, pausas artificiales o inexistentes, sección de
cabecera/firma leída en voz alta) si no se prepara el texto antes. Todo lo de abajo salió de
prueba y error real sobre cartas ya publicadas — no es una recomendación genérica de MiniMax,
es la receta que ya suena bien en producción. Seguidla tal cual, no la simplifiquéis.
## 2. La cuenta y las voces
- **Endpoint:** `https://api.minimax.io/v1/t2a_v2` (modelo de síntesis).
- **Modelo:** `speech-2.8-hd` (calidad) o `speech-2.8-turbo` (más barato, para pruebas).
- **Voces clonadas por autor** (issue #152 — solo estos autores usan su propia voz; el resto
cae a la voz por defecto):
| Autor | WP user_id | `voice_id` |
|---|---:|---|
| Fray Marcos | 382 | `FrayMarcosFeadulta2026` |
| José Antonio Pagola | 383 | `PagolaFeadulta2026` |
| José Luis Sicre | 774 | `SicreFeadulta2026` |
| José Arregi | 386 | `ArregiFeadulta2026` |
- **Voz por defecto (resto de autores):** confirmar con Rafa cuál es la vigente antes de
arrancar — hay más de una en la cuenta (`NicoFeadulta2026` y una más reciente,
`nico_feadulta_es_20260802`) y usar la que no toca es un error fácil de cometer sin verlo
(issue #189, todavía sin cerrar del todo). **No deis por hecho ninguna de las dos sin
preguntar primero.**
- Un autor nuevo sin voz clonada = voz por defecto. Clonar una voz nueva requiere el
procedimiento del #152 (audio limpio 2-5 min, sin música de fondo) — no es autoservicio
desde este documento, es un paso aparte.
## 3. Preparar el texto (la parte que importa)
Partiendo del **HTML del artículo en español** (el campo `content` que devuelve
`GET /wp-json/wp/v2/posts/{id}?context=edit`), en este orden exacto:
### 3.1 Limpiar el HTML a texto plano por párrafos
1. Sustituir `</p>`, `<br>`/`<br/>` y `</h1>``</h6>` por un salto de línea.
2. Quitar el resto de etiquetas HTML.
3. Quitar shortcodes `[...]` (multimedia, cajas, etc. — no se locutan).
4. Decodificar entidades HTML (`&aacute;``á`, etc.).
5. Separar en párrafos por línea, colapsar espacios múltiples, descartar líneas de 1
carácter o menos (restos de saltos vacíos).
### 3.2 Cortar la firma final del autor
Los artículos suelen terminar con una línea corta tipo `José Antonio Pagola` o
`Fray Marcos` — hay que **conservar esa línea y cortar todo lo que venga después**
(enlaces, notas, anexos), porque eso sí sobra en el audio.
Cómo detectarla (mirando desde el final del artículo hacia atrás, no desde el principio):
un párrafo cuenta como firma si tiene entre 2 y 6 palabras, ninguna palabra supera lo
razonable para un nombre propio, no contiene dígitos ni `:`, `;`, `http`, `www.`, `@`, y
cada palabra empieza en mayúscula (se permiten minúsculas para conectores como "de", "del",
"la", "las", "los", "y", "e"). **Importante:** solo cuenta como firma si hay al menos 200
caracteres de contenido real *antes* de ese párrafo — si no, podéis confundir un encabezado
inicial corto (p. ej. `"CORPUS (A)"`, `"DOMINGO XI (A)"`) con la firma y cortar el artículo
entero por error. Buscad la firma desde el final, quedaos con el primer párrafo (contando
desde atrás) que cumpla la condición, y descartad todo lo posterior.
### 3.3 Expandir abreviaturas bíblicas
MiniMax lee mal `Mt 5, 1-12`. Antes de generar el audio, expandir el nombre del libro
cuando la abreviatura va seguida de un número (cita bíblica): `Mt``Mateo`, `Mc``Marcos`,
`Lc``Lucas`, `Jn``Juan`, `Rom`/`Rm``Romanos`, etc. Hay un listado largo de ~70
abreviaturas en `scripts/minimax_tts.py::expand_bible_abbreviations` (repo `joomla-migration`)
— reutilizadlo tal cual en vez de rehacer la lista a mano, es fácil dejarse alguna.
### 3.4 Insertar pausas — el hallazgo central de este documento
MiniMax soporta marcas de pausa literales dentro del texto: `<#0.3#>` inserta 0,3 s de
silencio en ese punto exacto. Sin estas marcas el audio suena corrido y sin respiración;
poniendo una pausa fija igual en todos los puntos suena robótico. La receta que sí funciona
es **pausa proporcional a la longitud de la frase que se acaba de cerrar**:
- Tras cada fin de frase (`.`, `!`, `?`, `…`), calcular el nº de palabras de esa frase:
- frase corta (< 6 palabras) → pausa **0,1 s**
- frase media (6-12 palabras) → pausa **0,2 s**
- frase larga (> 12 palabras) → pausa **0,3 s**
(umbrales configurables, pero estos son los que ya suenan bien; no hace falta tocarlos)
- Entre párrafos (allí donde había una línea en blanco en el original) → pausa fija de
**0,7 s**, más larga que cualquier pausa de frase — es lo que hace que el oído perciba el
cambio de idea/tema.
- La última pausa de frase de cada párrafo se descarta (queda sustituida por la del
párrafo, que ya es más larga — poner las dos seguidas suena a silencio muerto).
- **Todo bloque (título, párrafo) que no termine en signo de puntuación se cierra con un
punto antes de generar el audio.** Sin esto, MiniMax deja la entonación "abierta" en el
aire al final de un título o frase cortada — se nota mucho y es fácil no darse cuenta en
texto pero sí al oído.
Ejemplo de transformación (simplificado):
```
Entrada: "Jesús les dijo esto. Y añadió una frase mucho más larga sobre lo que significaba
seguirle de verdad en el día a día.\n\nOtro párrafo distinto."
Salida: "Jesús les dijo esto. <#0.1#> Y añadió una frase mucho más larga sobre lo que
significaba seguirle de verdad en el día a día. <#0.7#> Otro párrafo distinto."
```
(la frase corta cerró con 0,1s pero esa marca se sustituye por la de párrafo 0,7s al ser la
última del bloque; la larga, al no ser la última del párrafo, habría llevado 0,3s si hubiera
seguido más texto en el mismo párrafo).
Referencia de implementación exacta (por si hay dudas de un caso concreto):
`scripts/minimax_tts.py::add_pauses` + `_sent_pause` + `ensure_terminal_punctuation`.
### 3.5 Trocear si el texto es largo
MiniMax limita cada petición a 10.000 caracteres; dejamos margen porque las marcas de pausa
también cuentan, así que **el límite práctico es 8.000 caracteres por petición**. Cartas
largas hay que trocearlas:
- Cortar **solo por los puntos donde ya hay una marca de pausa** (nunca a mitad de frase).
- Entre petición y petición, **esperar 35 segundos** — es el límite de ritmo (TPM) de la
cuenta MiniMax; sin esta espera, la segunda petición puede fallar aunque quede cuota.
- Los fragmentos de audio resultantes se concatenan con `ffmpeg` (`concat` a nivel de audio,
sin recodificar más de lo necesario) para producir un único mp3 final.
## 4. Llamar a la API
```
POST https://api.minimax.io/v1/t2a_v2
Authorization: Bearer <TOKEN>
Content-Type: application/json
{
"model": "speech-2.8-hd",
"text": "<texto con pausas <#s#> ya insertadas>",
"voice_setting": {"voice_id": "<voice_id de la tabla del §2>", "speed": 1.0, "vol": 1.0, "pitch": 0},
"audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "mp3", "channel": 1},
"language_boost": "Spanish"
}
```
La respuesta trae el audio en `data.audio` como **hexadecimal**, no base64 — decodificarlo a
bytes antes de escribir el mp3.
### Errores de cuota — no reintentéis en bucle
Si `data.audio` viene vacío, mirad `base_resp.status_code`:
- **`2056`** = cuota del plan agotada (se resetea con el tiempo, horas).
- **`1039`** = límite de ritmo por minuto (TPM) — bajad la cadencia, no lo repitáis al
segundo siguiente.
Un HTTP 200 **no significa que haya audio** — hay que mirar siempre el `base_resp` del
cuerpo. Ante cualquiera de estos dos códigos: **parad y reintentad más tarde**, no
machaquéis la API en bucle esperando que cambie.
**Cómo comprobar la cuota disponible antes de lanzar una tanda** (importante si vais a
procesar el backlog histórico del #188 además de la carta semanal, para repartir bien las
ventanas de 5h):
```bash
curl -s https://api.minimax.io/v1/token_plan/remains \
-H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json"
```
Buscad en `model_remains` la entrada cuyo `model_name` sea el modelo que vayáis a usar
(`speech-2.8-hd`/`speech-2.8-turbo`). Cada entrada trae
`current_interval_remaining_percent` (lo que queda de la ventana de 5h, y `end_time` de
cuándo se resetea) y `current_weekly_remaining_percent`/`weekly_end_time` (la ventana
semanal). Con eso podéis calcular cuántos audios caben antes de la próxima ventana y
programar el resto para la siguiente, en vez de descubrirlo con un `2056` a mitad de tanda.
## 5. Acabado del audio (opcional pero es lo que suena en producción)
El pipeline actual aplica dos retoques más al mp3 antes de darlo por bueno — no son
obligatorios para que el audio "funcione", pero sí para que suene igual que el resto del
catálogo ya publicado:
1. **Ruido de confort:** una capa de ruido marrón muy suave (`amplitude=0.004`, filtrado
paso-alto 120 Hz / paso-bajo 3800 Hz) mezclada con el audio entero. Sin esto, los
silencios de las pausas `<#…#>` contrastan de forma audible con el suelo de ruido del
habla generada — se nota como un "hueco" en vez de una pausa natural.
2. **Fade in/out:** entrada suave de 0,08 s y salida de 0,5 s en los últimos 0,5 s del
audio, para evitar un corte brusco ("bump") al final.
Receta `ffmpeg` exacta usada hoy (referencia: `scripts/minimax_tts.py::t2a`, líneas finales):
```bash
ffmpeg -y -i entrada.mp3 -filter_complex \
"anoisesrc=color=brown:amplitude=0.004:sample_rate=32000[n];\
[n]highpass=f=120,lowpass=f=3800[nf];\
[0:a][nf]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[m];\
[m]afade=t=in:st=0:d=0.08,afade=t=out:st=<duración-0.5>:d=0.5[a]" \
-map "[a]" -b:a 128k salida.mp3
```
Si vuestro entorno de generación no tiene `ffmpeg` disponible, generad el audio igual sin
este paso y avisad a Rafa/Claudix — es preferible subir un audio sin el acabado a no subir
nada, pero que quede constancia de que ese paso se saltó (para que quien revise sepa por qué
suena ligeramente distinto al resto).
## 6. Subir el resultado al artículo
El endpoint que faltaba ya está desplegado en producción (#224), con el mismo patrón que
`fea/v1/subir-avatar` (#175) que ya usáis:
```
POST https://www.feadulta.com/wp-json/fea/v1/subir-audio
Authorization: Basic <usuario:contraseña_de_aplicación> (o el header equivalente que useis)
Content-Type: multipart/form-data
post_id=<ID del artículo>
audio=<fichero .mp3, máx. 25 MiB>
voice_id=<opcional — el voice_id usado, para que quede registrado>
```
Respuesta (`201`):
```json
{
"post_id": 56785,
"audio_url": "https://www.feadulta.com/wp-content/uploads/tts/56785.mp3",
"voice_id": "NicoFeadulta2026",
"sha256": "<huella del mp3 subido>",
"previous_audio": {"url": null, "voice": null, "done": null, "sha256": null, "error": null}
}
```
`previous_audio` trae el estado de antes de vuestra subida (o todo `null` si el post no tenía
audio) — guardadlo si vais a reemplazar un audio ya publicado, por si hay que deshacer.
**Límite real: 25 MiB por fichero** (`upload_max_filesize`/`post_max_size` del servidor, y el
propio endpoint valida lo mismo con un mensaje claro). Los audios de la carta semanal rondan
5-6 MB, así que hay margen — pero si algún artículo muy largo genera un mp3 más grande,
avisad en vez de forzarlo: el límite se fijó a propósito en 25 MiB (no en los 256 MiB que se
había propuesto al principio) porque el caso de 198 MB del #212/#190 era un error de
generación a corregir, no un tamaño a soportar.
Solo admite `.mp3`/`audio/mpeg` — mismo nivel de permiso que el resto de endpoints `fea/v1`
de escritura (vuestro usuario Editor/Administrator). Verificado por Claudix el 2026-08-31 con
una subida real de extremo a extremo contra prod (post desechable, borrado después).
## 7. Lo que NO cambia con este traspaso
- Las traducciones (EN/FR/IT/PT) siguen, de momento, por el camino de siempre
(`docs/guia-tts-traduccion-inma.md`, motor Gemma/MiniMax vía Hermes) — este documento
cubre solo audio. Si el traspaso de traducciones también avanza, hará falta un documento
equivalente a este (y probablemente el mismo tipo de hueco de REST para enlazar las
traducciones vía Polylang) — no asumáis que ya está resuelto por analogía con el audio.
- El backlog histórico de TTS (issue #188, ~1.700 audios pendientes) es un problema aparte
de mucho más volumen que la carta semanal (~15-20/semana). Si el traspaso del #222 incluye
también ese backlog, es una decisión de Rafa que hay que confirmar explícitamente antes de
empezar — no arranquéis el backlog solo porque ya tengáis el mecanismo funcionando para la
carta semanal.
+13
View File
@@ -8,6 +8,19 @@
> audio solo al publicar — esa automatización es la propuesta abierta
> [issue #23](https://gitea.feadulta.com/rafa/feadulta/issues/23), sin implementar. Todo lo de
> abajo es un proceso que **hay que pedir**, hoy solo ejecutable en el servidor/PC de Rafa.
>
> **Actualización 2026-08-31 (issue [#222](https://gitea.feadulta.com/rafa/feadulta/issues/222)):**
> el **audio (TTS)** deja de depender de Hermes — Inma/Mixbot lo generan y suben con su propio
> token de MiniMax. La §4 de abajo describe el flujo viejo (Hermes ejecutando
> `scripts/tts_produce.py` en el PC de Rafa); para el flujo nuevo de autoservicio, con la receta
> exacta de preparación de texto y pausas, usar
> **`docs/guia-tts-audio-autoservicio-inma-mixbot.md`**. Rafa confirmó el mismo día que las
> **traducciones** van también hacia autoservicio (agente local con Haiku desde el Claude de
> Inma, o MiniMax con el mismo token) aunque todavía sin un issue como el #222 que lo dispare
> — ver **`docs/guia-traduccion-autoservicio-inma-mixbot.md`** para las reglas, el QA y el
> hueco de infraestructura equivalente (falta enlazar idioma/grupo Polylang por REST). La §3
> de abajo sigue describiendo el flujo viejo vía Hermes, todavía válido mientras no se cierre
> ese hueco.
---
+169
View File
@@ -0,0 +1,169 @@
<?php
/**
* Plugin Name: Fe Adulta — API crear traducción
* Description: Endpoint REST idempotente para crear y enlazar traducciones Polylang.
* Version: 1.0
*
* POST /wp-json/fea/v1/crear-traduccion
* es_id=<post ES>, lang=<en|fr|it|pt>, title/content/excerpt/status/model
*
* Ver issue gitea.feadulta.com/rafa/feadulta#222.
*/
if (!defined('ABSPATH')) exit;
const FEA_TRANSLATION_LANGUAGES = ['en', 'fr', 'it', 'pt'];
add_action('rest_api_init', function () {
register_rest_route('fea/v1', '/crear-traduccion', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'fea_crear_traduccion_handle',
'permission_callback' => 'fea_crear_traduccion_can_call',
// Se valida después de autorizar para que las llamadas anónimas
// obtengan 401 aunque omitan parámetros.
'args' => [
'es_id' => [
'sanitize_callback' => 'absint',
],
'lang' => [
'sanitize_callback' => 'sanitize_key',
],
'status' => [
'sanitize_callback' => 'sanitize_key',
],
'model' => [
'sanitize_callback' => 'sanitize_text_field',
],
],
]);
});
/** Editor o superior: mismo nivel que los demás endpoints fea/v1 de escritura. */
function fea_crear_traduccion_can_call(WP_REST_Request $request) {
if (!is_user_logged_in()) {
return new WP_Error(
'fea_crear_traduccion_not_authenticated',
'Debes autenticarte para crear una traducción.',
['status' => 401]
);
}
if (!current_user_can('edit_others_posts')) {
return new WP_Error(
'fea_crear_traduccion_forbidden',
'No tienes permiso para crear traducciones.',
['status' => 403]
);
}
return true;
}
function fea_crear_traduccion_handle(WP_REST_Request $request) {
$es_id = absint($request->get_param('es_id'));
if (!$es_id) {
return new WP_Error(
'fea_crear_traduccion_invalid_source',
'es_id es obligatorio.',
['status' => 400]
);
}
$lang = (string) $request->get_param('lang');
if (!in_array($lang, FEA_TRANSLATION_LANGUAGES, true)) {
return new WP_Error(
'fea_crear_traduccion_invalid_language',
'lang debe ser en, fr, it o pt.',
['status' => 400]
);
}
$status = (string) ($request->get_param('status') ?: 'draft');
if (!in_array($status, ['draft', 'publish'], true)) {
return new WP_Error(
'fea_crear_traduccion_invalid_status',
'status debe ser draft o publish.',
['status' => 400]
);
}
$source = get_post($es_id);
if (!$source) {
return new WP_Error(
'fea_crear_traduccion_source_not_found',
'No existe el post español indicado.',
['status' => 404]
);
}
if (!function_exists('pll_get_post') || !function_exists('pll_set_post_language') || !function_exists('pll_save_post_translations')) {
return new WP_Error(
'fea_crear_traduccion_polylang_unavailable',
'Polylang no está disponible.',
['status' => 500]
);
}
// Idempotencia dura: se responde antes de interpretar un payload nuevo.
$existing_id = (int) pll_get_post($es_id, $lang);
if ($existing_id && !get_post($existing_id)) $existing_id = 0;
if ($existing_id) {
return new WP_REST_Response([
'es_id' => $es_id,
'lang' => $lang,
'translation_id' => $existing_id,
'created' => false,
'url' => get_permalink($existing_id),
], 200);
}
$title = (string) ($request->get_param('title') ?? '');
$new_id = wp_insert_post([
'post_title' => wp_slash($title),
'post_content' => wp_slash((string) ($request->get_param('content') ?? '')),
'post_excerpt' => wp_slash((string) ($request->get_param('excerpt') ?? '')),
'post_name' => sanitize_title($title),
'post_status' => $status,
'post_type' => 'post',
'post_author' => (int) $source->post_author,
'post_date' => $source->post_date,
'to_ping' => '',
'pinged' => '',
], true);
if (is_wp_error($new_id)) {
return new WP_Error(
'fea_crear_traduccion_insert_failed',
'No se pudo crear la traducción: ' . $new_id->get_error_message(),
['status' => 500]
);
}
// El idioma se asigna antes de mapear categorías para que Polylang admita
// los términos traducidos en el nuevo post.
pll_set_post_language($new_id, $lang);
$categories = wp_get_post_categories($es_id);
$mapped_categories = [];
foreach ($categories as $category_id) {
$translated_category = function_exists('pll_get_term') ? (int) pll_get_term($category_id, $lang) : 0;
$mapped_categories[] = $translated_category ?: $category_id;
}
if ($mapped_categories) {
wp_set_post_categories($new_id, array_values(array_unique($mapped_categories)));
}
$translations = function_exists('pll_get_post_translations') ? pll_get_post_translations($es_id) : ['es' => $es_id];
if (!$translations) $translations = ['es' => $es_id];
$translations[$lang] = $new_id;
pll_save_post_translations($translations);
update_post_meta($new_id, 'traduccion_automatica', '1');
update_post_meta($new_id, 'traduccion_origen', $es_id);
update_post_meta($new_id, 'traduccion_modelo', (string) ($request->get_param('model') ?? ''));
update_post_meta($new_id, 'traduccion_fecha', gmdate('c'));
return new WP_REST_Response([
'es_id' => $es_id,
'lang' => $lang,
'translation_id' => (int) $new_id,
'created' => true,
'url' => get_permalink($new_id),
], 201);
}
+48 -9
View File
@@ -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();
+197
View File
@@ -0,0 +1,197 @@
<?php
/**
* Plugin Name: Fe Adulta — API subir audio TTS
* Description: Endpoint REST acotado para publicar el MP3 TTS de un artículo.
* Version: 1.0
*
* POST /wp-json/fea/v1/subir-audio
* multipart/form-data: post_id=<id>, audio=<MP3>, voice_id=<opcional>
*
* Ver issue gitea.feadulta.com/rafa/feadulta#222.
*/
if (!defined('ABSPATH')) exit;
const FEA_AUDIO_MAX_BYTES = 26214400; // 25 MiB: límite operativo de producción.
add_action('rest_api_init', function () {
register_rest_route('fea/v1', '/subir-audio', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'fea_subir_audio_handle',
'permission_callback' => 'fea_subir_audio_can_call',
// La validación de post_id ocurre en el handler, después de autorizar:
// una llamada anónima siempre recibe 401 aunque omita parámetros.
'args' => [
'post_id' => [
'sanitize_callback' => 'absint',
],
'voice_id' => [
'sanitize_callback' => 'sanitize_text_field',
],
],
]);
});
/** Editor o superior: mismo nivel que los endpoints de escritura existentes. */
function fea_subir_audio_can_call(WP_REST_Request $request) {
if (!is_user_logged_in()) {
return new WP_Error(
'fea_subir_audio_not_authenticated',
'Debes autenticarte para subir un audio.',
['status' => 401]
);
}
if (!current_user_can('edit_others_posts')) {
return new WP_Error(
'fea_subir_audio_forbidden',
'No tienes permiso para subir audios.',
['status' => 403]
);
}
return true;
}
function fea_subir_audio_handle(WP_REST_Request $request) {
$post_id = absint($request->get_param('post_id'));
if (!$post_id) {
return new WP_Error(
'fea_subir_audio_invalid_post',
'post_id es obligatorio.',
['status' => 400]
);
}
if (!get_post($post_id)) {
return new WP_Error(
'fea_subir_audio_post_not_found',
'No existe el artículo indicado.',
['status' => 404]
);
}
$files = $request->get_file_params();
$file = $files['audio'] ?? null;
$validation = fea_subir_audio_validate_file($file);
if (is_wp_error($validation)) return $validation;
$previous = [
'url' => get_post_meta($post_id, 'fea_audio_url', true) ?: null,
'voice' => get_post_meta($post_id, 'fea_audio_voice', true) ?: null,
'done' => get_post_meta($post_id, 'fea_audio_done', true) ?: null,
'sha256' => get_post_meta($post_id, 'fea_audio_sha256', true) ?: null,
'error' => get_post_meta($post_id, 'fea_audio_error', true) ?: null,
];
require_once ABSPATH . 'wp-admin/includes/file.php';
$upload_dir_filter = 'fea_subir_audio_upload_dir';
add_filter('upload_dir', $upload_dir_filter);
try {
$uploaded = wp_handle_upload($file, [
'test_form' => false,
'mimes' => ['mp3' => 'audio/mpeg'],
'unique_filename_callback' => function ($dir, $name, $ext) use ($post_id) {
return $post_id . '.mp3';
},
]);
} finally {
remove_filter('upload_dir', $upload_dir_filter);
}
if (!empty($uploaded['error']) || empty($uploaded['file'])) {
return new WP_Error(
'fea_subir_audio_upload_failed',
'No se pudo guardar el audio: ' . ($uploaded['error'] ?? 'error desconocido'),
['status' => 400]
);
}
$sha256 = hash_file('sha256', $uploaded['file']);
if ($sha256 === false) {
wp_delete_file($uploaded['file']);
return new WP_Error(
'fea_subir_audio_hash_failed',
'No se pudo calcular la huella del audio subido.',
['status' => 500]
);
}
// El path es estable para que los players y el proceso de backfill no dependan
// de las carpetas por mes que normalmente crea WordPress.
$audio_url = home_url('/wp-content/uploads/tts/' . $post_id . '.mp3');
update_post_meta($post_id, 'fea_audio_url', $audio_url);
update_post_meta($post_id, 'fea_audio_voice', (string) $request->get_param('voice_id'));
update_post_meta($post_id, 'fea_audio_done', '1');
update_post_meta($post_id, 'fea_audio_sha256', $sha256);
delete_post_meta($post_id, 'fea_audio_error');
return new WP_REST_Response([
'post_id' => $post_id,
'audio_url' => $audio_url,
'voice_id' => (string) $request->get_param('voice_id'),
'sha256' => $sha256,
'previous_audio' => $previous,
], 201);
}
/** Guarda el binario en uploads/tts sin cambiar globalmente la configuración. */
function fea_subir_audio_upload_dir($uploads) {
$uploads['subdir'] = '/tts';
$uploads['path'] = $uploads['basedir'] . $uploads['subdir'];
$uploads['url'] = $uploads['baseurl'] . $uploads['subdir'];
if (!wp_mkdir_p($uploads['path'])) {
$uploads['error'] = 'No se pudo crear el directorio de audios.';
}
return $uploads;
}
function fea_subir_audio_validate_file($file) {
if (!is_array($file) || empty($file['tmp_name']) || !isset($file['error'])) {
return new WP_Error(
'fea_subir_audio_missing_file',
'audio es obligatorio.',
['status' => 400]
);
}
if ((int) $file['error'] !== UPLOAD_ERR_OK) {
return new WP_Error(
'fea_subir_audio_file_error',
'La subida de audio falló.',
['status' => 400]
);
}
if ((int) $file['size'] <= 0 || (int) $file['size'] > FEA_AUDIO_MAX_BYTES) {
return new WP_Error(
'fea_subir_audio_file_too_large',
'El audio debe ocupar entre 1 byte y 25 MiB.',
['status' => 400]
);
}
$type = wp_check_filetype((string) ($file['name'] ?? ''), ['mp3' => 'audio/mpeg']);
if (($type['ext'] ?? '') !== 'mp3' || ($type['type'] ?? '') !== 'audio/mpeg') {
return new WP_Error(
'fea_subir_audio_unsupported_type',
'Solo se admiten archivos MP3 (audio/mpeg).',
['status' => 400]
);
}
if (strtolower((string) ($file['type'] ?? '')) !== 'audio/mpeg') {
return new WP_Error(
'fea_subir_audio_unsupported_type',
'audio debe declararse como audio/mpeg.',
['status' => 400]
);
}
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = $finfo ? finfo_file($finfo, $file['tmp_name']) : false;
if ($finfo) finfo_close($finfo);
if ($mime !== false && $mime !== 'audio/mpeg') {
return new WP_Error(
'fea_subir_audio_invalid_audio',
'audio debe contener datos MPEG válidos.',
['status' => 400]
);
}
}
return true;
}
+435
View File
@@ -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()
+93
View File
@@ -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));
+182
View File
@@ -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())
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Integración local para POST /wp-json/fea/v1/crear-traduccion (#222).
# Requiere un post ES temporal SIN traducción inglesa; el caller lo elimina
# junto con la traducción creada al terminar.
set -euo pipefail
: "${FEA_TEST_URL:?Falta FEA_TEST_URL}"
: "${FEA_TEST_AUTH:?Falta FEA_TEST_AUTH}"
: "${FEA_TEST_ES_ID:?Falta FEA_TEST_ES_ID}"
endpoint="${FEA_TEST_URL%/}/wp-json/fea/v1/crear-traduccion"
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
assert_status() {
local expected="$1" actual="$2" label="$3" response_file="$4"
if [[ "$actual" != "$expected" ]]; then
echo "FAIL: $label — esperado HTTP $expected, recibido $actual" >&2
cat "$response_file" >&2 || true
exit 1
fi
}
status="$(curl -sS -o "$tmpdir/unauth.json" -w '%{http_code}' -X POST "$endpoint")"
assert_status 401 "$status" 'llamada sin autenticar' "$tmpdir/unauth.json"
status="$(curl -sS -o "$tmpdir/no-source.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F 'lang=en' "$endpoint")"
assert_status 400 "$status" 'falta es_id' "$tmpdir/no-source.json"
status="$(curl -sS -o "$tmpdir/no-lang.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "es_id=$FEA_TEST_ES_ID" "$endpoint")"
assert_status 400 "$status" 'falta lang' "$tmpdir/no-lang.json"
status="$(curl -sS -o "$tmpdir/bad-lang.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "es_id=$FEA_TEST_ES_ID" -F 'lang=de' "$endpoint")"
assert_status 400 "$status" 'lang no admitido' "$tmpdir/bad-lang.json"
status="$(curl -sS -o "$tmpdir/bad-status.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "es_id=$FEA_TEST_ES_ID" -F 'lang=en' -F 'status=private' "$endpoint")"
assert_status 400 "$status" 'status no admitido' "$tmpdir/bad-status.json"
status="$(curl -sS -o "$tmpdir/no-such-source.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F 'es_id=999999999' -F 'lang=en' "$endpoint")"
assert_status 404 "$status" 'post ES inexistente' "$tmpdir/no-such-source.json"
status="$(curl -sS -o "$tmpdir/created.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "es_id=$FEA_TEST_ES_ID" -F 'lang=en' -F 'title=Translation API test' --form-string 'content=<p>Body API test</p>' -F 'excerpt=Excerpt API test' -F 'status=draft' -F 'model=test-model' "$endpoint")"
assert_status 201 "$status" 'creación de traducción' "$tmpdir/created.json"
python3 - "$tmpdir/created.json" "$FEA_TEST_ES_ID" <<'PY'
import json, sys
payload = json.load(open(sys.argv[1]))
assert int(payload['es_id']) == int(sys.argv[2]), payload
assert payload['lang'] == 'en', payload
assert int(payload['translation_id']) > 0, payload
assert payload['created'] is True, payload
assert payload['url'].startswith(('http://', 'https://')), payload
print('PASS: traducción creada', payload['translation_id'])
PY
status="$(curl -sS -o "$tmpdir/idempotent.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "es_id=$FEA_TEST_ES_ID" -F 'lang=en' -F 'title=No debe crear otro post' "$endpoint")"
assert_status 200 "$status" 'idempotencia' "$tmpdir/idempotent.json"
python3 - "$tmpdir/created.json" "$tmpdir/idempotent.json" <<'PY'
import json, sys
created = json.load(open(sys.argv[1]))
again = json.load(open(sys.argv[2]))
assert again['translation_id'] == created['translation_id'], (created, again)
assert again['created'] is False, again
print('PASS: idempotencia', again['translation_id'])
PY
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Integración local para POST /wp-json/fea/v1/subir-audio (#222).
# Requiere FEA_TEST_URL, FEA_TEST_AUTH y FEA_TEST_POST_ID (post temporal).
set -euo pipefail
: "${FEA_TEST_URL:?Falta FEA_TEST_URL}"
: "${FEA_TEST_AUTH:?Falta FEA_TEST_AUTH}"
: "${FEA_TEST_POST_ID:?Falta FEA_TEST_POST_ID}"
endpoint="${FEA_TEST_URL%/}/wp-json/fea/v1/subir-audio"
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
# MP3 pequeño y válido (silencio MPEG-1 Layer III), sin depender de ffmpeg.
printf '\377\373\220\144' > "$tmpdir/audio.mp3"
dd if=/dev/zero bs=1 count=413 >> "$tmpdir/audio.mp3" 2>/dev/null
printf 'no es un mp3' > "$tmpdir/not-audio.txt"
# Un byte por encima del límite de aplicación de 25 MiB. Se comprueba antes
# del tipo de fichero, por lo que puede ser disperso y no necesita ser audio.
truncate -s 26214401 "$tmpdir/too-large.mp3"
assert_status() {
local expected="$1" actual="$2" label="$3" response_file="$4"
if [[ "$actual" != "$expected" ]]; then
echo "FAIL: $label — esperado HTTP $expected, recibido $actual" >&2
cat "$response_file" >&2 || true
exit 1
fi
}
status="$(curl -sS -o "$tmpdir/unauth.json" -w '%{http_code}' -X POST "$endpoint")"
assert_status 401 "$status" 'llamada sin autenticar' "$tmpdir/unauth.json"
status="$(curl -sS -o "$tmpdir/no-post.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -X POST "$endpoint")"
assert_status 400 "$status" 'falta post_id' "$tmpdir/no-post.json"
status="$(curl -sS -o "$tmpdir/no-audio.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "post_id=$FEA_TEST_POST_ID" "$endpoint")"
assert_status 400 "$status" 'falta audio' "$tmpdir/no-audio.json"
status="$(curl -sS -o "$tmpdir/too-large.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "post_id=$FEA_TEST_POST_ID" -F "audio=@$tmpdir/too-large.mp3;type=audio/mpeg" "$endpoint")"
assert_status 400 "$status" 'audio por encima de 25 MiB' "$tmpdir/too-large.json"
status="$(curl -sS -o "$tmpdir/wrong-type.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "post_id=$FEA_TEST_POST_ID" -F "audio=@$tmpdir/not-audio.txt;type=text/plain" "$endpoint")"
assert_status 400 "$status" 'extensión o mime no MP3' "$tmpdir/wrong-type.json"
status="$(curl -sS -o "$tmpdir/no-such-post.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F 'post_id=999999999' -F "audio=@$tmpdir/audio.mp3;type=audio/mpeg" "$endpoint")"
assert_status 404 "$status" 'post inexistente' "$tmpdir/no-such-post.json"
status="$(curl -sS -o "$tmpdir/success.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "post_id=$FEA_TEST_POST_ID" -F 'voice_id=NicoFeadulta2026' -F "audio=@$tmpdir/audio.mp3;type=audio/mpeg" "$endpoint")"
assert_status 201 "$status" 'subida válida' "$tmpdir/success.json"
python3 - "$tmpdir/success.json" "$FEA_TEST_POST_ID" "$tmpdir/audio.mp3" <<'PY'
import hashlib, json, sys
payload = json.load(open(sys.argv[1]))
assert int(payload['post_id']) == int(sys.argv[2]), payload
assert payload['audio_url'].endswith(f'/wp-content/uploads/tts/{sys.argv[2]}.mp3'), payload
assert payload['voice_id'] == 'NicoFeadulta2026', payload
assert payload['sha256'] == hashlib.sha256(open(sys.argv[3], 'rb').read()).hexdigest(), payload
assert 'previous_audio' in payload, payload
print('PASS: audio subido', payload['audio_url'])
PY
# La siguiente subida del mismo post debe exponer el estado anterior, para que
# el cliente pueda restaurarlo si su flujo posterior falla.
status="$(curl -sS -o "$tmpdir/replaced.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "post_id=$FEA_TEST_POST_ID" -F 'voice_id=NicoFeadulta2026' -F "audio=@$tmpdir/audio.mp3;type=audio/mpeg" "$endpoint")"
assert_status 201 "$status" 'reemplazo de audio' "$tmpdir/replaced.json"
python3 - "$tmpdir/replaced.json" "$FEA_TEST_POST_ID" <<'PY'
import json, sys
payload = json.load(open(sys.argv[1]))
assert payload['previous_audio']['url'].endswith(f'/wp-content/uploads/tts/{sys.argv[2]}.mp3'), payload
assert payload['previous_audio']['done'] == '1', payload
print('PASS: estado anterior devuelto')
PY
+155
View File
@@ -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())