Compare commits

..

29 Commits

Author SHA1 Message Date
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 4d25a8d2e5 fea-audio-player: limpiar el aviso de error cuando el audio sí llega a reproducir
Si audio.play() fallaba tras verificar el hash (p.ej. política de autoplay
del navegador tras el await de fetch+digest), el aviso "No se pudo verificar
el audio" quedaba fijo en pantalla aunque el usuario reprodujera el audio a
mano desde los controles nativos que ya estaban visibles. El evento 'play'
real del <audio> ahora limpia ese aviso y oculta el botón, sea cual sea el
camino por el que arrancó la reproducción.

Reportado por Rafa en el canal feadulta, viendo el estado atascado en
"Más allá de la trinchera..." (post 56196) tras el bloqueo 403 de Cloudflare.
2026-08-28 05:29:23 -04:00
rafa 1333a2bb64 Merge pull request 'sync_audio_bulk.sh: lote de sincronizacion de audio TTS con parada en primer fallo' (#215) from feat/issue-sync-bulk-script into main 2026-08-24 19:28:21 +00:00
rafa 654c3d3c35 sync_audio_to_prod.py: local_meta() propaga fallos de docker exec
Mismo patrón que carta_article_ids() (commit anterior): si el docker exec
contra el contenedor WP local fallaba (contenedor caído/inexistente),
local_meta() devolvía stdout vacío en vez de comprobar returncode.
sync_one() interpretaba la cadena vacía como "sin-audio-local" (skip), no
como error, así que un lote entero podía acabar "ok=0/skip=N" tras perder
Docker en vez de abortar.

Ahora lanza RuntimeError con el stderr si docker exec falla; se propaga
por sync_one() hasta el try/except de main(), que ya cuenta err y devuelve
1 — el wrapper aborta correctamente con exit=1 y 0 sincronizaciones.

Probado: FEA_WP_CONTAINER inexistente + --ids → aborta en el primer ID
(exit=1, ok=0). Camino normal (--ids con contenedor real, dry-run) sigue
dando el mismo resultado que antes.

Revisión de Codix en el PR #215.
2026-08-24 15:27:41 -04:00
rafa 7af2173014 sync_audio_bulk.sh: parada dura real en --carta + ruta absoluta
- carta_article_ids() en sync_audio_to_prod.py ahora comprueba returncode
  del docker exec y lanza RuntimeError si falla, en vez de devolver una
  lista vacía silenciosa (bug preexistente: un fallo de Docker/consulta se
  confundía con "la carta no tiene artículos").
- El wrapper resuelve --carta a fichero temporal en vez de mapfile con
  sustitución de procesos (mapfile < <(cmd) no deja comprobar el exit
  code real de cmd), captura el rc de python explícitamente y aborta con
  exit 1 sin sincronizar nada si la consulta falla.
- sys.path ya no depende del cwd ("scripts" relativo): se pasa la ruta
  absoluta de SCRIPT_DIR al script inline.

Probado: --carta real sin cambios de comportamiento (28 candidatos,
ok=21/skip=7); consulta rota a propósito (FEA_DB_CONTAINER inexistente)
aborta limpio con exit=1 y 0 sincronizaciones; invocación desde otro
directorio de trabajo da el mismo resultado que desde la raíz del repo.

Revisión de Codix en el PR #215.
2026-08-24 15:26:05 -04:00
rafa 483c68bf5f sync_audio_bulk.sh: lote de sincronización audio TTS con parada en primer fallo
Envoltorio de sync_audio_to_prod.py para lotes de posts: carga los IDs con
un array (mapfile/--ids/--file) antes de cualquier ssh y redirige el stdin
del proceso hijo a /dev/null, evitando el bug de un while-read compartiendo
stdin con ssh (ssh se come el resto del fichero de IDs y el bucle termina
sin error tras procesar solo el primero — visto en real el 2026-08-24,
lote de 184 que solo sincronizó 1). Para en el primer fallo real, sin
reintentos ciegos, y da un resumen ok/skip al cierre.

Documentado en README con ejemplo de uso y variables de entorno.
2026-08-24 15:20:27 -04:00
rafa 2260343ec4 Merge pull request 'Verify complete audio assets before browser playback' (#213) from feat/accesibilidad-196 into main 2026-08-21 11:10:49 +00:00
rafa bc3af3cbb9 Verify complete audio assets before browser playback 2026-08-20 11:50:00 -04:00
inma bbae0571ae fixtures: .doc originales de Fray Marcos (#209)
Los 30 .doc tal cual llegan por correo, de la carta 713 a la 740, para poder
reproducir el borrado silencioso por <w:smartTag> y diffear contra lo publicado.
Pedidos por Rafa en el #209, donde el barrido quedó bloqueado sin ellos.
2026-08-19 09:06:08 +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
inma a806cf9cae doc_convert.py: conversor .doc->.docx de Fray Marcos, con el arreglo de los smartTag (#209) 2026-08-18 09:24:55 +00: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
rafa 696934016f Merge pull request 'Backfill evangelio: modo apply explícito vía variable de entorno' (#208) from fix/backfill-apply-mode into main 2026-08-12 13:57:12 +00:00
50 changed files with 32645 additions and 25 deletions
+32
View File
@@ -32,8 +32,40 @@ scripts/ — scripts Python/PHP de migración, traducción, TTS y mantenimi
| `translate_haiku.py` | Traducción automática vía Claude Haiku | | `translate_haiku.py` | Traducción automática vía Claude Haiku |
| `tts_produce.py` | Generación de audio TTS (MiniMax) | | `tts_produce.py` | Generación de audio TTS (MiniMax) |
| `sync_translations_to_prod.py` | Sincronizar traducciones local → producción | | `sync_translations_to_prod.py` | Sincronizar traducciones local → producción |
| `sync_audio_to_prod.py` | Sincronizar UN post/carta de audio TTS local → producción (sube el mp3, verifica SHA-256 remoto y persiste `fea_audio_sha256`) |
| `sync_audio_bulk.sh` | Envoltorio de `sync_audio_to_prod.py` para lotes: para en el primer fallo, sin reintentos ciegos, resumen ok/skip |
| `detect_untranslated.php` | Detectar posts sin traducir | | `detect_untranslated.php` | Detectar posts sin traducir |
## Sincronizar audio TTS a producción (local → prod)
`sync_audio_to_prod.py` sube el mp3 de UN post/carta y, desde el #213, calcula y verifica el
SHA-256 en cada subida (reproducción verificada automática, sin backfill manual — ver #212).
Para lotes de varios posts, usar `sync_audio_bulk.sh`: es el envoltorio que añade "parar en el
primer fallo" (el script Python no lo hace por sí solo, recorre toda la lista y acumula errores).
Carga los IDs con `mapfile`/mediante array y redirige el stdin de cada invocación a `/dev/null` a
propósito — un `while read id; do ...ssh...; done < fichero` sin esa precaución se rompe en
silencio porque `ssh` se come el resto del fichero de IDs (ver
`feedback-bash-while-read-ssh-stdin` en la memoria del proyecto).
```bash
export FEA_PROD_SSH_HOST="rafa@188.40.120.157"
export FEA_PROD_DOCKER_CONTAINER="wordpress-r2ssjifwj0r0ghyoqd528uaa"
# Dry-run primero, siempre
scripts/sync_audio_bulk.sh --ids 55519,55520,822 --dry-run
# Aplicar de verdad
scripts/sync_audio_bulk.sh --ids 55519,55520,822
# Desde un fichero (un ID por línea) o toda la cola de una carta
scripts/sync_audio_bulk.sh --file /tmp/ids_pendientes.txt
scripts/sync_audio_bulk.sh --carta 54254
```
Rollback de un post concreto: `python3 scripts/sync_audio_to_prod.py --rollback --ids <id>`
(borra el meta en prod, no toca el mp3 local).
## Servidor local de desarrollo ## Servidor local de desarrollo
WordPress corre en Docker local, accesible vía Tailscale: WordPress corre en Docker local, accesible vía Tailscale:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,161 @@
# 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 quiere traspasar también la
> traducción. A diferencia del audio, hoy **no hay un issue abierto pidiéndolo** — este
> documento adelanta el trabajo para cuando se dispare.
>
> **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. Antes de nada: esto también tiene un tope bloqueante
Igual que pasaba con el audio, **hoy no hay forma de decirle a WordPress "esta traducción
va en inglés y es la versión de este post en español"** desde fuera. Ese enlace (idioma +
grupo de traducción) lo gestiona Polylang con dos funciones PHP internas
(`pll_set_post_language`, `pll_save_post_translations`) que **no están expuestas por REST**
ni siquiera de lectura (el único endpoint que hay, `fea/v1/lang/{id}`, solo lee el idioma, no
permite fijarlo ni enlazar grupos).
Podéis crear el post traducido por `POST /wp/v2/posts` sin problema (igual que cualquier
artículo), pero **quedaría suelto**: sin idioma asignado y sin enlace Polylang al original en
español, así que el selector de idioma del sitio no lo encontraría y no contaría como "la
traducción EN de este artículo".
**Esto necesita el mismo tipo de solución que el audio**: un endpoint `fea/v1/crear-traduccion`
(o registrar la taxonomía `language` de Polylang en REST con permisos de administrador) que
haga internamente lo mismo que ya hace `scripts/fea_translate_helper.php::create` en local:
crear el post, `pll_set_post_language($id, $lang)`, y `pll_save_post_translations($grupo)`
enlazándolo con el ES. No lo he encargado todavía — es una pieza más grande que la del audio
(toca la lógica central de multiidioma del sitio) y prefiero que Rafa decida el momento y el
alcance antes de que Codix la construya. Documento aquí el resto del proceso para que, en
cuanto ese hueco se cierre, solo falte la llamada final.
---
## 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
- **Bloqueante (§0):** endpoint o mecanismo para fijar idioma + enlazar grupo Polylang de
cada traducción — sin esto podéis generar el texto pero no "engancharlo" al sitio como
traducción real, igual que pasaba con el audio antes del #222.
- Decisión pendiente de Rafa (no la resuelvo yo aquí): si vuestro agente de traducción va a
tener acceso de shell/SSH al WordPress (como tenía Hermes) o va a ser puramente REST como
el resto de vuestro trabajo — cambia bastante el diseño del endpoint que haría falta.
@@ -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 > 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 > [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. > 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.
--- ---
+65 -3
View File
@@ -13,11 +13,23 @@ function fea_audio_player_html(): string {
$url = get_post_meta($id, 'fea_audio_url', true); $url = get_post_meta($id, 'fea_audio_url', true);
if (!$url) return ''; if (!$url) return '';
$voice = get_post_meta($id, 'fea_audio_voice', true) ?: 'NicoFeadulta2026'; $voice = get_post_meta($id, 'fea_audio_voice', true) ?: 'NicoFeadulta2026';
return '<div class="fea-audio">' $sha256 = get_post_meta($id, 'fea_audio_sha256', true);
. '<span class="fea-audio-label">' $label = '<span class="fea-audio-label">'
. '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">' . '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">'
. '<path fill="currentColor" d="M3 10v4h4l5 5V5L7 10H3zm13.5 2a4.5 4.5 0 0 0-2.5-4.03v8.06A4.5 4.5 0 0 0 16.5 12zM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54z"/>' . '<path fill="currentColor" d="M3 10v4h4l5 5V5L7 10H3zm13.5 2a4.5 4.5 0 0 0-2.5-4.03v8.06A4.5 4.5 0 0 0 16.5 12zM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54z"/>'
. '</svg> Escucha</span>' . '</svg> Escucha</span>';
// Para un asset con hash registrado, descarga y verifica el fichero completo
// antes de delegar playback al navegador. Evita que un stream Range mezclado
// por una caché reproduzca fragmentos de otro MP3.
if (preg_match('/^[a-f0-9]{64}$/i', $sha256)) {
return '<div class="fea-audio">' . $label
. '<button type="button" class="fea-audio-verified-play" data-fea-audio-verified-play'
. ' data-src="' . esc_url($url) . '" data-sha256="' . esc_attr(strtolower($sha256)) . '"'
. ' data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '">Reproducir audio</button>'
. '<audio controls preload="none" hidden data-fea-audio-track data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '"></audio>'
. '<span class="fea-audio-status" role="status" aria-live="polite"></span></div>';
}
return '<div class="fea-audio">' . $label
. '<audio controls preload="none" src="' . esc_url($url) . '"' . '<audio controls preload="none" src="' . esc_url($url) . '"'
. ' data-fea-audio-track data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '"></audio>' . ' data-fea-audio-track data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '"></audio>'
. '</div>'; . '</div>';
@@ -56,6 +68,9 @@ add_action('wp_head', function () {
.fea-audio-label{display:inline-flex;align-items:center;gap:.35rem;font-size:.78rem; .fea-audio-label{display:inline-flex;align-items:center;gap:.35rem;font-size:.78rem;
font-weight:600;color:#8b1a2e;white-space:nowrap;line-height:1} font-weight:600;color:#8b1a2e;white-space:nowrap;line-height:1}
.fea-audio audio{height:32px;width:230px;max-width:44vw} .fea-audio audio{height:32px;width:230px;max-width:44vw}
.fea-audio-verified-play{border:1px solid #8b1a2e;border-radius:6px;background:#fff;color:#8b1a2e;padding:.35rem .6rem;font:inherit;font-size:.82rem;font-weight:600;cursor:pointer}
.fea-audio-verified-play:disabled{opacity:.65;cursor:wait}
.fea-audio-status{font-size:.76rem;color:#6b5c60}
@media(max-width:600px){ @media(max-width:600px){
.fea-audio{margin-left:0;width:100%;margin-top:.5rem} .fea-audio{margin-left:0;width:100%;margin-top:.5rem}
.fea-audio audio{flex:1 1 auto;width:auto;max-width:none} .fea-audio audio{flex:1 1 auto;width:auto;max-width:none}
@@ -73,9 +88,55 @@ add_action('wp_footer', function () {
if (!get_post_meta(get_queried_object_id(), 'fea_audio_url', true)) return; if (!get_post_meta(get_queried_object_id(), 'fea_audio_url', true)) return;
?> ?>
<script> <script>
(function () {
function hex(buffer) {
return Array.from(new Uint8Array(buffer)).map(function (n) {
return n.toString(16).padStart(2, '0');
}).join('');
}
document.querySelectorAll('[data-fea-audio-verified-play]').forEach(function (button) {
button.addEventListener('click', async function () {
var wrap = button.closest('.fea-audio');
var audio = wrap.querySelector('audio[data-fea-audio-track]');
var status = wrap.querySelector('.fea-audio-status');
button.disabled = true;
status.textContent = 'Verificando audio…';
try {
// Se descarga el objeto entero una vez y se valida su hash. Sólo
// entonces se crea un blob local: el <audio> no hará Range contra
// CDN y jamás podrá mezclar fragmentos de otro MP3.
var response = await fetch(button.dataset.src, {cache: 'no-store'});
if (!response.ok) throw new Error('HTTP ' + response.status);
var bytes = await response.arrayBuffer();
var digest = hex(await crypto.subtle.digest('SHA-256', bytes));
if (digest !== button.dataset.sha256) throw new Error('La copia recibida no coincide con el audio verificado');
audio.src = URL.createObjectURL(new Blob([bytes], {type: 'audio/mpeg'}));
audio.hidden = false;
button.hidden = true;
status.textContent = '';
await audio.play();
} catch (error) {
status.textContent = 'No se pudo verificar el audio. Inténtalo de nuevo.';
button.disabled = false;
console.error('Fe Adulta audio verification failed:', error);
}
});
});
document.querySelectorAll('audio[data-fea-audio-track]').forEach(function (audio) { document.querySelectorAll('audio[data-fea-audio-track]').forEach(function (audio) {
var fired = false; var fired = false;
audio.addEventListener('play', function () { 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; if (fired || typeof gtag !== 'function') return;
fired = true; fired = true;
gtag('event', 'audio_play', { gtag('event', 'audio_play', {
@@ -84,6 +145,7 @@ add_action('wp_footer', function () {
}); });
}); });
}); });
}());
</script> </script>
<?php <?php
}); });
+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. // "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 // · 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 // · 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 // El texto de Fray Marcos se resuelve contra el calendario litúrgico versionado
// (zona horaria del sitio) o el día indicado por ?fed=M-D, con pestañas para // (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. // 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. // Contenido solo en ES (devocional sin traducción) → categorías 14/15 fijas.
add_shortcode('fea_evangelio_diario', function($atts) { 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]; $gm = (int) $mm[1]; $gd = (int) $mm[2];
if ($gm >= 1 && $gm <= 12 && $gd >= 1 && $gd <= 31) { $m = $gm; $d = $gd; } 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; global $wpdb;
$id = $wpdb->get_var($wpdb->prepare( $id = $wpdb->get_var($wpdb->prepare(
"SELECT p.ID FROM {$wpdb->posts} p "SELECT p.ID FROM {$wpdb->posts} p
JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID 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 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 p.post_type = 'post' AND p.post_status = 'publish'
AND LOWER(TRIM(p.post_title)) = %s AND LOWER(TRIM(p.post_title)) = %s
ORDER BY p.ID ASC LIMIT 1", ORDER BY p.ID ASC LIMIT 1", $titulo_dia));
$cat, $titulo_dia));
return $id ? get_post((int) $id) : null; return $id ? get_post((int) $id) : null;
}; };
$texto = $find(14); $video = $find_video();
$video = $find(15); // 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) // navegación por día del calendario (año irrelevante; usamos un año bisiesto fijo)
$base = get_permalink(); $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;
}
+79
View File
@@ -0,0 +1,79 @@
"""doc_convert.py — convierte un .doc viejo a .docx usando Microsoft Word (COM).
Necesario para Marcos (marroro@telefonica.net), que envía .doc (Word antiguo) y
python-docx no sabe leer .doc. El contenido ya viene en EML (lo etiqueta Inma al
revisar); aquí solo cambiamos el FORMATO de archivo.
Requiere Microsoft Word instalado + pywin32 (igual que marcos_review).
API: doc_to_docx(path, force=False) -> Path | None
"""
from __future__ import annotations
from pathlib import Path
WD_FORMAT_DOCX = 16 # wdFormatDocumentDefault (.docx)
def doc_to_docx(path, force: bool = False):
"""Convierte un .doc a .docx con Word COM. Idempotente: si el .docx ya existe y
no force, lo devuelve sin reconvertir. Devuelve la ruta .docx, o None si falla."""
path = Path(path)
if path.suffix.lower() != ".doc":
return None
out = path.with_suffix(".docx")
if out.exists() and not force:
return out
try:
import win32com.client # type: ignore
except ImportError:
return None
word = win32com.client.Dispatch("Word.Application")
word.Visible = False
try:
d = word.Documents.Open(str(path))
d.SaveAs(str(out), FileFormat=WD_FORMAT_DOCX)
d.Close(SaveChanges=False)
finally:
word.Quit()
if out.exists():
quitar_smarttags(out)
return out if out.exists() else None
def quitar_smarttags(docx_path) -> int:
"""Desenvuelve los <w:smartTag> del .docx. Devuelve cuántos ha quitado.
⚠️ CRÍTICO, no es cosmético. Los .doc de Fray Marcos traen `smartTag` (el viejo
reconocimiento de nombres de Word) envolviendo expresiones como "la Iglesia" o
"la Cananea". **python-docx solo lee los <w:r> que cuelgan directamente del párrafo,
así que el texto de dentro del smartTag DESAPARECE sin dar error**: se publicaron
frases mutiladas en las cartas 736, 737 y 739 ("...no va dirigida a los apóstoles,
sino a dureza de la respuesta..."). Detectado el 12-ago-2026.
"""
import re, shutil, zipfile
from pathlib import Path
docx_path = Path(docx_path)
with zipfile.ZipFile(docx_path) as z:
piezas = {n: z.read(n) for n in z.namelist()}
xml = piezas.get("word/document.xml", b"").decode("utf-8", "replace")
nuevo, n = re.subn(r"</?w:smartTag(?:Pr)?\b[^>]*>|<w:attr\b[^>]*/>", "", xml)
if not n:
return 0
piezas["word/document.xml"] = nuevo.encode("utf-8")
tmp = docx_path.with_suffix(".docx.tmp")
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as z:
for nombre, datos in piezas.items():
z.writestr(nombre, datos)
shutil.move(str(tmp), str(docx_path))
return n
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("uso: python doc_convert.py path/al/archivo.doc", file=sys.stderr)
sys.exit(2)
res = doc_to_docx(Path(sys.argv[1]), force="--force" in sys.argv)
print(f"OK: {res}" if res else "FALLO (¿Word/pywin32?)")
sys.exit(0 if res else 1)
+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())
+115
View File
@@ -0,0 +1,115 @@
#!/bin/bash
# sync_audio_bulk.sh — Sincroniza a producción, uno por uno, un lote de posts con
# audio TTS ya generado en local. Envoltorio de sync_audio_to_prod.py que añade
# "parar en el primer fallo" (el script Python no lo hace: recorre toda la
# lista y acumula errores, ver #212/#213).
#
# Motivo del wrapper en bash con mapfile: un `while read id; do ...; done <
# fichero` normal comparte el file descriptor de stdin con cualquier `ssh`
# lanzado dentro del bucle. sync_audio_to_prod.py llama a ssh internamente; si
# el bucle no está protegido, ssh se come el resto del fichero de IDs y el
# bucle termina "sin error" tras procesar solo el primero. Detectado y
# documentado en memoria (feedback-bash-while-read-ssh-stdin) el 2026-08-24
# tras un lote real que solo sincronizó 1 de 184 posts sin ningún error visible.
#
# Uso:
# scripts/sync_audio_bulk.sh --ids 55519,55520,822
# scripts/sync_audio_bulk.sh --file /tmp/ids.txt # un ID por línea
# scripts/sync_audio_bulk.sh --carta 54254 # cola completa de la carta
# scripts/sync_audio_bulk.sh --ids 55519,55520 --dry-run # solo plan, no toca prod
#
# Requiere las mismas variables de entorno que sync_audio_to_prod.py para
# apuntar a producción (ver ese script): FEA_PROD_SSH_HOST,
# FEA_PROD_DOCKER_CONTAINER. Ejemplo:
# export FEA_PROD_SSH_HOST="rafa@188.40.120.157"
# export FEA_PROD_DOCKER_CONTAINER="wordpress-r2ssjifwj0r0ghyoqd528uaa"
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SYNC_PY="$SCRIPT_DIR/sync_audio_to_prod.py"
usage() {
echo "Uso: $0 (--ids id1,id2,... | --file ruta | --carta post_id) [--dry-run]" >&2
exit 2
}
ids_csv=""
ids_file=""
carta=""
dry_run=""
while [ $# -gt 0 ]; do
case "$1" in
--ids) ids_csv="$2"; shift 2 ;;
--file) ids_file="$2"; shift 2 ;;
--carta) carta="$2"; shift 2 ;;
--dry-run) dry_run="--dry-run"; shift ;;
*) usage ;;
esac
done
# Carga explícita de IDs en un array ANTES de cualquier llamada a ssh — es la
# parte que evita el bug de stdin. No usar `while read ... < fichero`.
ids=()
if [ -n "$ids_csv" ]; then
IFS=',' read -r -a ids <<< "$ids_csv"
elif [ -n "$ids_file" ]; then
mapfile -t ids < "$ids_file"
elif [ -n "$carta" ]; then
# No usar `mapfile < <(cmd)`: la sustitución de procesos no deja comprobar
# de forma fiable el exit code de `cmd` (mapfile ve su propio $?, no el de
# la tubería). Si la consulta falla, un array vacío se confundiría con
# "la carta no tiene artículos" y el lote saldría "éxito" sin sincronizar
# nada. Se resuelve a fichero temporal y se comprueba el rc de python
# explícitamente antes de poblar el array.
carta_tmpfile="$(mktemp)"
python3 - "$carta" "$SCRIPT_DIR" > "$carta_tmpfile" <<'PYEOF'
import sys
sys.path.insert(0, sys.argv[2])
from sync_audio_to_prod import carta_article_ids
for pid in carta_article_ids(int(sys.argv[1])):
print(pid)
PYEOF
py_rc=$?
if [ $py_rc -ne 0 ]; then
echo "ERROR: fallo consultando la cola de la carta $carta (exit $py_rc), no se sincroniza nada." >&2
rm -f "$carta_tmpfile"
exit 1
fi
mapfile -t ids < "$carta_tmpfile"
rm -f "$carta_tmpfile"
else
usage
fi
total=${#ids[@]}
if [ "$total" -eq 0 ]; then
echo "Nada que sincronizar (lista de IDs vacía)." >&2
exit 0
fi
ok=0
skip=0
n=0
for id in "${ids[@]}"; do
id="$(echo "$id" | tr -d '[:space:]')"
[ -z "$id" ] && continue
n=$((n + 1))
# stdin del hijo explícitamente en /dev/null: cinturón y tirantes además del
# array en memoria, por si algún día se cambia el bucle.
out="$(python3 "$SYNC_PY" --ids "$id" $dry_run < /dev/null)"
rc=$?
echo "$out"
if [ $rc -ne 0 ]; then
echo "=== ABORT: fallo en #$id ($n/$total procesados, $ok ok). Deteniendo lote, sin reintentar. ==="
exit 1
fi
if echo "$out" | grep -q "PLAN: subiría\|#$id: ok"; then
ok=$((ok + 1))
else
skip=$((skip + 1))
fi
done
echo "=== FIN LOTE. ok=$ok skip=$skip de $total candidatos. ==="
+15 -4
View File
@@ -19,6 +19,7 @@ Rollback (despublica en prod lo que este script publicó):
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import hashlib
import json import json
import os import os
import subprocess import subprocess
@@ -74,6 +75,8 @@ def local_meta(post_id: int, key: str) -> str:
["docker", "exec", WP_CONTAINER, "php", "/tmp/fea_post_io.php", "getmeta", str(post_id), key], ["docker", "exec", WP_CONTAINER, "php", "/tmp/fea_post_io.php", "getmeta", str(post_id), key],
capture_output=True, text=True, timeout=60, capture_output=True, text=True, timeout=60,
) )
if r.returncode != 0:
raise RuntimeError(f"docker exec {WP_CONTAINER} falló leyendo meta '{key}' de #{post_id} (rc={r.returncode}): {r.stderr.strip()[:300]}")
return r.stdout.strip() return r.stdout.strip()
@@ -85,6 +88,8 @@ def carta_article_ids(carta_id: int) -> list[int]:
DB_NAME, "-N", "-e", q], DB_NAME, "-N", "-e", q],
capture_output=True, text=True, timeout=60, capture_output=True, text=True, timeout=60,
) )
if r.returncode != 0:
raise RuntimeError(f"consulta de _carta_id={carta_id} falló (rc={r.returncode}): {r.stderr.strip()[:300]}")
return [int(x) for x in r.stdout.split() if x.isdigit()] return [int(x) for x in r.stdout.split() if x.isdigit()]
@@ -144,16 +149,21 @@ def prod_helper(subcmd: str, *args: str) -> str:
return _ssh_text(_remote_wrap(inner), timeout=60) return _ssh_text(_remote_wrap(inner), timeout=60)
def prod_upload_mp3(post_id: int) -> None: def prod_upload_mp3(post_id: int) -> str:
src = LOCAL_TTS_DIR / f"{post_id}.mp3" src = LOCAL_TTS_DIR / f"{post_id}.mp3"
data = src.read_bytes() data = src.read_bytes()
local_sha256 = hashlib.sha256(data).hexdigest()
remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3" remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3"
_ssh_upload_bytes(data, remote_path) _ssh_upload_bytes(data, remote_path)
# Verificación de tamaño: no fiarse ciegamente del rc=0 de ssh. `wc -c` debe # Verificación de tamaño+contenido: un rc=0 de ssh no acredita que el asset
# correr (y resolver la redirección) DENTRO del contenedor — ver _remote_wrap. # sea el mismo. El hash se persiste como meta y permite playback verificado.
remote_size = int(_ssh_text(_remote_wrap(f"wc -c < {remote_path}")).strip()) remote_size = int(_ssh_text(_remote_wrap(f"wc -c < {remote_path}")).strip())
if remote_size != len(data): if remote_size != len(data):
raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}") raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}")
remote_sha256 = _ssh_text(_remote_wrap(f"sha256sum {remote_path} | cut -d' ' -f1")).strip()
if remote_sha256 != local_sha256:
raise RuntimeError(f"SHA-256 no coincide tras subir #{post_id}: local={local_sha256} remoto={remote_sha256}")
return local_sha256
def prod_remove_mp3(post_id: int) -> None: def prod_remove_mp3(post_id: int) -> None:
@@ -185,8 +195,9 @@ def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
return "PLAN: subiría mp3 + setaudio" return "PLAN: subiría mp3 + setaudio"
voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026" voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
prod_upload_mp3(post_id) sha256 = prod_upload_mp3(post_id)
prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice) prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice)
prod_helper("setflag", str(post_id), "fea_audio_sha256", sha256)
if post_id not in state["synced"]: if post_id not in state["synced"]:
state["synced"].append(post_id) state["synced"].append(post_id)
save_state(state) save_state(state)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+69
View File
@@ -0,0 +1,69 @@
# `.doc` originales de Fray Marcos
Los 30 ficheros `.doc` (Word binario antiguo) tal y como los envía Fray Marcos por correo a
`ediciones@feadulta.com`, sin tocar. Subidos a petición de Rafa en el **issue #209**, para poder
reproducir y diffear desde este lado el problema de los `<w:smartTag>`.
## Por qué están aquí
`python-docx` **no lee el texto que va dentro de un `<w:smartTag>`**: lo salta sin dar error. Los `.doc`
de Fray Marcos arrastran esas etiquetas heredadas de versiones viejas de Word envolviendo expresiones
como «la Iglesia» o «la Cananea», así que al convertirlos se publicaban frases con palabras borradas en
silencio. Está corregido en `scripts/doc_convert.py` con `quitar_smarttags()`.
Ojo: la cadena `smartTag` **no aparece en el binario `.doc`**; surge al convertirlo a `.docx`, que es
formato OOXML. Para verlas hay que convertir primero.
## Cómo reproducir
```bash
python scripts/doc_convert.py "tests/fixtures/fray-marcos/739_8r_Marcos_d 20 la Cananea.doc"
```
Requiere Word instalado (la conversión va por COM). `doc_convert.py` desenvuelve los smartTag y hace el
reemplazo con reintentos, porque Dropbox bloquea el fichero unos segundos justo después de que Word lo
cree (WinError 1224).
## Alcance del problema (barrido cerrado en el #209)
De los 30 `.doc` revisados, 20 traían smartTag y sumaban 46 fragmentos envueltos, pero solo **3
artículos publicados quedaron dañados** (posts `53981`, `55078` y `55659`), ya reparados en producción.
Los comentarios de febrero a junio están intactos porque se publicaron por la vía de la migración de
Joomla, no desde el `.docx`; solo quedó expuesto lo publicado por Mixbot desde julio.
## Inventario
El prefijo numérico es el número de carta; el resto es el nombre original del fichero.
| carta | semana | fichero original |
|---:|---|---|
| 713 | semana_VI_TO | `713_8r_Marcos_d 06 plenitud de ley.doc` |
| 714 | semana_I_Cuaresma | `714_8r_Marcos_Cuar 1 tentaciones.doc` |
| 715 | semana_II_Cuaresma | `715_8r_Marcos_Cuar 2 Transfigurac.doc` |
| 716 | semana_III_Cuaresma | `716_8r_Marcos_Cuar 3 Samaritana.doc` |
| 717 | semana_IV_Cuaresma | `717_8r_Marcos_Cuar 4 ciego de naci.doc` |
| 718 | semana_V_Cuaresma | `718_8r_Marcos_Cuar 5 Lázaro.doc` |
| 719 | semana_Domingo_Ramos | `719_8r_Marcos_Cuar 6 Ramos.doc` |
| 720 | semana_Santa | `720_8r_Marcos_JUEVES SANTO.doc` |
| 720 | semana_Santa | `720_8r_Marcos_Pasc 1 resucitó.doc` |
| 720 | semana_Santa | `720_8r_Marcos_VIERNES SANTO.doc` |
| 721 | semana_II_Pascua | `721_8r_Marcos_Pasc 2 Tomás.doc` |
| 722 | semana_III_Pascua | `722_8r_Marcos_Pasc 3 Emaús.doc` |
| 723 | semana_IV_Pascua | `723_8r_Marcos_Pasc 4 pastor modelo.doc` |
| 724 | semana_V_Pascua | `724_8r_Marcos_Pasc 5 camino,verdad,vida.doc` |
| 725 | semana_VI_Pascua | `725_8r_Marcos_Pasc 6 El Paráclito.doc` |
| 726 | semana_VII_Pascua | `726_8r_Marcos_Pasc 7 ascensión.doc` |
| 727 | semana_Pentecostes | `727_8r_Marcos_PENTECOSTÉS.doc` |
| 728 | semana_Trinidad | `728_8r_Marcos_TRINIDAD.doc` |
| 729 | semana_Corpus | `729_8r_Marcos_CORPUS.doc` |
| 730 | semana_11_TO | `730_8r_Marcos_d 11 misión de los 12.doc` |
| 731 | semana_12_TO | `731_8r_Marcos_d 12 no tengas miedo.doc` |
| 732 | semana_13_TO | `732_8r_Marcos_d 13 amor a Dios o família.doc` |
| 733 | semana_14_TO | `733_8r_Marcos_d 14 sencillez.doc` |
| 734 | semana_15_TO | `734_8r_Marcos_d 15 sembrador.doc` |
| 735 | semana_16_TO | `735_8r_Marcos_d 16 cizaña.doc` |
| 736 | semana_17_TO | `736_8r_Marcos_d 17 tesoro y perla.doc` |
| 737 | semana_18_TO | `737_8r_Marcos_d 18 panes y peces.doc` |
| 738 | semana_19_TO | `738_8r_Marcos_d 19 Qué poca fe.doc` |
| 739 | semana_20_TO | `739_8r_Marcos_d 20 la Cananea.doc` |
| 740 | semana_21_TO | `740_8r_Marcos_d 21 quién es Jesús.doc` |
@@ -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())