Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10e5431365 | |||
| 67857638ba | |||
| 3da7c79f5a | |||
| 83db29d389 | |||
| 4d25a8d2e5 | |||
| 1333a2bb64 | |||
| 654c3d3c35 | |||
| 7af2173014 | |||
| 483c68bf5f | |||
| 2260343ec4 | |||
| bc3af3cbb9 | |||
| bbae0571ae | |||
| 14b94be5ea | |||
| a806cf9cae | |||
| 2713e13470 | |||
| 5807151a99 | |||
| 293cd86896 | |||
| 696934016f | |||
| 06d7057e76 | |||
| ef2978fc21 | |||
| 587b10c2b3 | |||
| 6b966a3b5f | |||
| 754915b91f | |||
| 75c0f07eab | |||
| f0946be1fc | |||
| d836297c89 | |||
| d9ff5e891c | |||
| 5cf588ffe3 | |||
| a734ffc933 | |||
| b3a2ae682b | |||
| 825434a0b7 | |||
| 908188ddf2 | |||
| 68b95aae89 | |||
| 82c352aa15 | |||
| 51ae723c5a | |||
| 7a623e8bb9 |
@@ -17,6 +17,7 @@ scripts/ — scripts Python/PHP de migración, traducción, TTS y mantenimi
|
|||||||
| `fea-homepage.php` | Tarjetas de portada |
|
| `fea-homepage.php` | Tarjetas de portada |
|
||||||
| `fea-share.php` | Sección "Comparte FeAdulta" (Facebook, Instagram, Imprimir) |
|
| `fea-share.php` | Sección "Comparte FeAdulta" (Facebook, Instagram, Imprimir) |
|
||||||
| `fea-ui.php` | Estilos globales, menú, fondo cálido en artículos |
|
| `fea-ui.php` | Estilos globales, menú, fondo cálido en artículos |
|
||||||
|
| `fea-accesibilidad.php` | Tipografía Atkinson Hyperlegible (self-hosted) + toggle claro/oscuro persistente |
|
||||||
| `fea-beta-feedback.php` | Barra de feedback beta + endpoint `/fea/v1/lang/{id}` |
|
| `fea-beta-feedback.php` | Barra de feedback beta + endpoint `/fea/v1/lang/{id}` |
|
||||||
| `fea-search.php` | Buscador móvil nativo |
|
| `fea-search.php` | Buscador móvil nativo |
|
||||||
| `fea-pensamientos.php` | Widget de pensamientos |
|
| `fea-pensamientos.php` | Widget de pensamientos |
|
||||||
@@ -31,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
@@ -407,5 +407,14 @@ traducción inmediata, avisar a Rafa directamente además de pedírselo a Hermes
|
|||||||
| Asignar `_carta_id` a un artículo | `POST /wp-json/fea/v1/carta-id/{id}` con body `{"carta_id": N}` |
|
| Asignar `_carta_id` a un artículo | `POST /wp-json/fea/v1/carta-id/{id}` con body `{"carta_id": N}` |
|
||||||
| Borrar `_carta_id` de un artículo | `DELETE /wp-json/fea/v1/carta-id/{id}` |
|
| Borrar `_carta_id` de un artículo | `DELETE /wp-json/fea/v1/carta-id/{id}` |
|
||||||
|
|
||||||
|
## Regla para la sección Evangelio y comentarios al Evangelio
|
||||||
|
|
||||||
|
La sección empieza siempre con el enlace al post del evangelio de esa semana (por
|
||||||
|
ejemplo, `MATEO 14, 22-33`) y después van los comentarios. Si ese post no existe,
|
||||||
|
avisad antes de publicar la carta: no empecéis la sección por un comentario.
|
||||||
|
|
||||||
|
No hay que completar ningún campo nuevo. A partir de ese enlace, el sitio asigna la
|
||||||
|
cita bíblica automáticamente a los comentarios nuevos.
|
||||||
|
|
||||||
Campos útiles del post: `title`, `content` (HTML), `status` (`draft`/`publish`/`future`),
|
Campos útiles del post: `title`, `content` (HTML), `status` (`draft`/`publish`/`future`),
|
||||||
`date` (ISO 8601 para programar), `categories` (array de IDs), `featured_media` (ID de adjunto).
|
`date` (ISO 8601 para programar), `categories` (array de IDs), `featured_media` (ID de adjunto).
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta — Accesibilidad (tipografía + contraste)
|
||||||
|
* Description: Tipografía Atkinson Hyperlegible global (self-hosted) + toggle
|
||||||
|
* claro/oscuro persistente, pensado para el público mayor de
|
||||||
|
* Fe Adulta (issue rafa/feadulta#196, evidencia en comment #609).
|
||||||
|
* Version: 1.0
|
||||||
|
*
|
||||||
|
* Alcance decidido en el issue (2026-08-08): SOLO dos cosas, sin más superficie
|
||||||
|
* de UI que la necesaria —
|
||||||
|
* 1. Atkinson Hyperlegible sustituye la tipografía del cuerpo, sin selector.
|
||||||
|
* 2. Un único toggle claro/oscuro persistente (claro por defecto).
|
||||||
|
* Nada de panel de accesibilidad, A+/Aa ni cambio de tamaño de letra.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
if (!defined('FEA_A11Y_ACCENT_DARK')) {
|
||||||
|
// Variante clara del carmesí de marca (#8b1a2e) para que enlaces/foco
|
||||||
|
// cumplan contraste AA sobre fondo casi negro (el carmesí original da
|
||||||
|
// ~1.8:1 sobre #0d0d0d, muy por debajo del 4.5:1 que exige texto).
|
||||||
|
define('FEA_A11Y_ACCENT_DARK', '#e8899e');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 1) Anti-flash: fija el tema ANTES de que el navegador pinte nada ─────
|
||||||
|
* Se engancha con prioridad muy baja para salir de los primeros bytes de
|
||||||
|
* <head>, igual que el resto del sitio guarda preferencias en localStorage
|
||||||
|
* (ver fea-beta-feedback.php). Sin esto habría un parpadeo claro→oscuro. */
|
||||||
|
add_action('wp_head', function () {
|
||||||
|
if (is_admin()) return;
|
||||||
|
?>
|
||||||
|
<script>(function(){try{
|
||||||
|
var t = localStorage.getItem('fea_theme');
|
||||||
|
if (t === 'dark') document.documentElement.setAttribute('data-fea-theme', 'dark');
|
||||||
|
}catch(e){}})();</script>
|
||||||
|
<?php
|
||||||
|
}, 1);
|
||||||
|
|
||||||
|
/* ── 2) Fuente self-hosted + estilos de tema oscuro ───────────────────── */
|
||||||
|
add_action('wp_head', function () {
|
||||||
|
if (is_admin()) return;
|
||||||
|
$base = plugins_url('fea-accesibilidad/fonts', __FILE__);
|
||||||
|
$accent_dark = FEA_A11Y_ACCENT_DARK;
|
||||||
|
?>
|
||||||
|
<style id="fea-a11y">
|
||||||
|
/* ── Atkinson Hyperlegible (Braille Institute, OFL), subset latin ──── */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Atkinson Hyperlegible';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('<?php echo esc_url($base); ?>/atkinson-hyperlegible-regular.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Atkinson Hyperlegible';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 501 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('<?php echo esc_url($base); ?>/atkinson-hyperlegible-bold.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sustitución global del cuerpo de texto (no opcional, issue #196).
|
||||||
|
Especificidad baja a propósito: el menú (fea-ui.php, issue #95) usa
|
||||||
|
una sans de UI más específica y debe seguir ganando. */
|
||||||
|
body {
|
||||||
|
font-family: 'Atkinson Hyperlegible', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6,
|
||||||
|
.fea-hero-title,
|
||||||
|
.fea-section-title {
|
||||||
|
font-family: 'Atkinson Hyperlegible', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
input, textarea, select, button {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toggle claro/oscuro: botón inyectado junto al selector de idioma ── */
|
||||||
|
#fea-theme-btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 30px; height: 30px; border-radius: 50%;
|
||||||
|
border: 1px solid rgba(0,0,0,0.25); background: none; color: inherit;
|
||||||
|
cursor: pointer; font-size: 0.95rem; line-height: 1; padding: 0;
|
||||||
|
}
|
||||||
|
#fea-theme-btn:hover { background: rgba(0,0,0,0.06); }
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════════
|
||||||
|
MODO OSCURO — activado por [data-fea-theme="dark"] en <html>
|
||||||
|
══════════════════════════════════════════════════════════════════ */
|
||||||
|
html[data-fea-theme="dark"] {
|
||||||
|
/* Deja que el navegador oscurezca controles nativos gratis:
|
||||||
|
audio/video, scrollbars, checkboxes, selects sin estilo propio. */
|
||||||
|
color-scheme: dark;
|
||||||
|
--wp--preset--color--base: #0d0d0d;
|
||||||
|
--wp--preset--color--contrast: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] body {
|
||||||
|
background-color: #0d0d0d !important;
|
||||||
|
color: #ececec !important;
|
||||||
|
}
|
||||||
|
/* En artículos, fea-ui.php fija --wp--preset--color--base en el body
|
||||||
|
para el modo claro. El contenedor desplegable del menú hereda esa
|
||||||
|
variable, así que hay que restaurarla aquí en oscuro. */
|
||||||
|
html[data-fea-theme="dark"] body.single-post {
|
||||||
|
--wp--preset--color--base: #0d0d0d;
|
||||||
|
--wp--preset--color--contrast: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] a,
|
||||||
|
html[data-fea-theme="dark"] a:visited { color: <?php echo esc_html($accent_dark); ?>; }
|
||||||
|
html[data-fea-theme="dark"] a:focus-visible,
|
||||||
|
html[data-fea-theme="dark"] button:focus-visible {
|
||||||
|
outline-color: <?php echo esc_html($accent_dark); ?> !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cabecera/pie y bloques de grupo sin fondo explícito heredan el body;
|
||||||
|
solo hay que corregir los pocos sitios con color fijo en claro. */
|
||||||
|
html[data-fea-theme="dark"] input,
|
||||||
|
html[data-fea-theme="dark"] textarea,
|
||||||
|
html[data-fea-theme="dark"] select {
|
||||||
|
background-color: #1c1c1c;
|
||||||
|
color: #ececec;
|
||||||
|
border-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search block uses more-specific own color rules. */
|
||||||
|
html[data-fea-theme="dark"] .wp-block-search__inside-wrapper,
|
||||||
|
html[data-fea-theme="dark"] .wp-block-search__input {
|
||||||
|
background-color: #1c1c1c;
|
||||||
|
color: #ececec;
|
||||||
|
border-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .wp-block-search__button {
|
||||||
|
background-color: #ececec;
|
||||||
|
color: #0d0d0d;
|
||||||
|
border-color: #ececec;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-link-wrap { background: #161311; border-bottom-color: #2a2320; }
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-link { color: <?php echo esc_html($accent_dark); ?>; }
|
||||||
|
|
||||||
|
/* Crop the white edge embedded in slide images in dark mode only. */
|
||||||
|
html[data-fea-theme="dark"] .fea-hero-slider .n2-ss-slide-background-image {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-hero-slider .n2-ss-slide-background-image img {
|
||||||
|
transform: scale(1.015);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portada — franja hero (fea-homepage.php) */
|
||||||
|
html[data-fea-theme="dark"] .fea-hero-band {
|
||||||
|
background: linear-gradient(180deg, #1a1512, #0d0d0d);
|
||||||
|
border-bottom-color: #2a2320;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-hero-title,
|
||||||
|
html[data-fea-theme="dark"] .fea-section-title {
|
||||||
|
color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-section-label,
|
||||||
|
html[data-fea-theme="dark"] .fea-section-more,
|
||||||
|
html[data-fea-theme="dark"] .fea-card-author {
|
||||||
|
color: <?php echo esc_html($accent_dark); ?>;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-hero-meta { color: #b8afa8; }
|
||||||
|
|
||||||
|
/* Tarjetas de autor/artículo (fea-homepage.php) */
|
||||||
|
html[data-fea-theme="dark"] .fea-card {
|
||||||
|
background: #161311;
|
||||||
|
border-color: #2a2320;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-card-title a { color: #ececec; }
|
||||||
|
|
||||||
|
/* Archivos (categoría, búsqueda y autor) — fea-homepage.php fija aquí
|
||||||
|
fondos y texto claros que no heredan del tema. */
|
||||||
|
html[data-fea-theme="dark"] .wp-block-query-title {
|
||||||
|
color: #ececec !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-archive-card {
|
||||||
|
background: #161311;
|
||||||
|
border-color: #2a2320;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-archive-title a {
|
||||||
|
color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-archive-excerpt {
|
||||||
|
color: #b8afa8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comentarios al evangelio: el panel usa colores editoriales claros fijos. */
|
||||||
|
html[data-fea-theme="dark"] .fea-com-evangelio {
|
||||||
|
background: #1a1711;
|
||||||
|
border-left-color: #c9973e;
|
||||||
|
color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-com-evangelio-details summary {
|
||||||
|
color: #f0c674;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Elementos editoriales presentes en artículos y resultados. */
|
||||||
|
html[data-fea-theme="dark"] .fea-audio {
|
||||||
|
background: #161311;
|
||||||
|
border-color: #3a302b;
|
||||||
|
border-left-color: <?php echo esc_html($accent_dark); ?>;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-byline-name { color: #ececec; }
|
||||||
|
html[data-fea-theme="dark"] .fea-byline-cat,
|
||||||
|
html[data-fea-theme="dark"] .fea-eed-link .fecha { color: #b8afa8; }
|
||||||
|
html[data-fea-theme="dark"] .fea-share-rule { border-top-color: #3a302b; }
|
||||||
|
html[data-fea-theme="dark"] .fea-share-row { color: #d5cbc3; }
|
||||||
|
html[data-fea-theme="dark"] .fea-share-label,
|
||||||
|
html[data-fea-theme="dark"] .fea-share-sep { color: #b8afa8; }
|
||||||
|
|
||||||
|
/* Formulario y filtros de búsqueda avanzada. */
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-details {
|
||||||
|
background: #161311; border-color: #3a302b;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-summary {
|
||||||
|
background: #24181c; border-bottom-color: #3a302b;
|
||||||
|
color: <?php echo esc_html($accent_dark); ?>;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-label,
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-chips-label,
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-meta,
|
||||||
|
html[data-fea-theme="dark"] .fea-archive-card .fea-adv-byline { color: #b8afa8; }
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-chip {
|
||||||
|
background: #2a1b20; border-color: #56343d; color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-input,
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-select {
|
||||||
|
background: #1c1c1c; border-color: #3a3a3a; color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-count { color: #b8afa8; }
|
||||||
|
|
||||||
|
/* Recopilatorios y galería multimedia. */
|
||||||
|
html[data-fea-theme="dark"] .fea-recop-year { border-bottom-color: #3a302b; }
|
||||||
|
html[data-fea-theme="dark"] .fea-recop-list li { border-bottom-color: #2a2320; }
|
||||||
|
html[data-fea-theme="dark"] .fea-recop-list a,
|
||||||
|
html[data-fea-theme="dark"] .fea-mm-title { color: #ececec; }
|
||||||
|
html[data-fea-theme="dark"] .fea-recop-date,
|
||||||
|
html[data-fea-theme="dark"] .fea-mm-date,
|
||||||
|
html[data-fea-theme="dark"] .fea-mm-excerpt { color: #b8afa8; }
|
||||||
|
html[data-fea-theme="dark"] .fea-mm-card { background: #161311; border-color: #2a2320; }
|
||||||
|
html[data-fea-theme="dark"] .fea-mm-thumb { background: #241f1c; }
|
||||||
|
|
||||||
|
/* Galerías de pensamientos y navegación del tema base. */
|
||||||
|
html[data-fea-theme="dark"] .fea-gallery-item {
|
||||||
|
background: #161311; border-color: #3a302b;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-lightbox-frame img { background: #161311; }
|
||||||
|
html[data-fea-theme="dark"] .fea-lightbox button { background: #2a2320; color: #ececec; }
|
||||||
|
html[data-fea-theme="dark"] .wp-block-navigation .wp-block-navigation-item__content {
|
||||||
|
color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .wp-block-navigation__submenu-container {
|
||||||
|
background: #161311; border-color: #3a302b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Página de apoyo: plantilla propia con colores claros explícitos. */
|
||||||
|
html[data-fea-theme="dark"] .fea-support-page {
|
||||||
|
--fea-support-ink: #ececec;
|
||||||
|
--fea-support-warm: #161311;
|
||||||
|
--fea-support-line: #3a302b;
|
||||||
|
--fea-support-card: #161311;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-support-hero,
|
||||||
|
html[data-fea-theme="dark"] .fea-support-banner {
|
||||||
|
background: #1a1711 !important;
|
||||||
|
border-color: #3a302b !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-support-card,
|
||||||
|
html[data-fea-theme="dark"] .fea-support-button.is-secondary {
|
||||||
|
background: #161311 !important;
|
||||||
|
border-color: #3a302b !important;
|
||||||
|
color: #ececec !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-support-card__title,
|
||||||
|
html[data-fea-theme="dark"] .fea-support-progress__numbers span,
|
||||||
|
html[data-fea-theme="dark"] .fea-support-progress__note,
|
||||||
|
html[data-fea-theme="dark"] .fea-support-sidebar__small { color: #b8afa8; }
|
||||||
|
html[data-fea-theme="dark"] .fea-support-progress__track { background: #3a302b; }
|
||||||
|
|
||||||
|
/* Colaboradores: stylesheet propio del tema con tarjetas claras y !important. */
|
||||||
|
html[data-fea-theme="dark"] .entry-content .wrapper {
|
||||||
|
background: #161311 !important;
|
||||||
|
border-color: #3a302b !important;
|
||||||
|
color: #ececec !important;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,.45) !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .entry-content .wrapper strong {
|
||||||
|
color: #ececec !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .entry-content .wrapper a,
|
||||||
|
html[data-fea-theme="dark"] .entry-content .wrapper a:visited {
|
||||||
|
color: #ececec !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .entry-content .wrapper a:hover {
|
||||||
|
color: <?php echo esc_html($accent_dark); ?> !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selector de idioma (fea-homepage.php) */
|
||||||
|
html[data-fea-theme="dark"] #fea-lang-btn { border-color: rgba(255,255,255,0.3); }
|
||||||
|
html[data-fea-theme="dark"] #fea-lang-btn:hover,
|
||||||
|
html[data-fea-theme="dark"] #fea-theme-btn:hover { background: rgba(255,255,255,0.08); }
|
||||||
|
html[data-fea-theme="dark"] #fea-theme-btn { border-color: rgba(255,255,255,0.3); }
|
||||||
|
html[data-fea-theme="dark"] #fea-lang-dropdown {
|
||||||
|
background: #161311; border-color: #3a3a3a;
|
||||||
|
box-shadow: 0 6px 16px rgba(0,0,0,.5);
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] #fea-lang-dropdown a { color: #ececec; }
|
||||||
|
html[data-fea-theme="dark"] #fea-lang-dropdown a:hover { background: #241f1c; }
|
||||||
|
html[data-fea-theme="dark"] #fea-lang-dropdown a[aria-current] { background: #2a2320; color: #fff; }
|
||||||
|
|
||||||
|
/* Buscador móvil (fea-search.php) */
|
||||||
|
html[data-fea-theme="dark"] .fea-search-bar { background: #0d0d0d; border-bottom-color: #2a2320; }
|
||||||
|
html[data-fea-theme="dark"] .fea-search { background: #1c1c1c; border-color: #3a3a3a; }
|
||||||
|
html[data-fea-theme="dark"] .fea-search input[type=search] { color: #ececec; }
|
||||||
|
|
||||||
|
/* Reproductor de audio (fea-audio-player.php) — el <audio> nativo ya
|
||||||
|
cambia solo vía color-scheme; solo hace falta la etiqueta de texto. */
|
||||||
|
html[data-fea-theme="dark"] .fea-audio-label { color: #b8afa8; }
|
||||||
|
|
||||||
|
/* Barra y tarjeta de Beta Feedback (fea-beta-feedback.php) */
|
||||||
|
html[data-fea-theme="dark"] #fea-beta-bar {
|
||||||
|
background: #161311; border-top-color: #2a2320; color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] #fea-beta-bar .fea-beta-dismiss { color: #b8afa8; }
|
||||||
|
html[data-fea-theme="dark"] #fea-fb .fea-fb-card {
|
||||||
|
background: #161311; border-color: #3a3a3a; color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] #fea-fb button.fea-fb-vote {
|
||||||
|
background: #1c1c1c; border-color: #3a3a3a; color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] #fea-fb button.fea-fb-vote.sel {
|
||||||
|
border-color: <?php echo esc_html($accent_dark); ?>; background: #2a1418;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] #fea-fb textarea { background: #1c1c1c; border-color: #3a3a3a; color: #ececec; }
|
||||||
|
|
||||||
|
/* Barra de consentimiento de cookies (fea-cookie-consent.php) */
|
||||||
|
html[data-fea-theme="dark"] #fea-cc {
|
||||||
|
background: #161311 !important; color: #ececec !important; border-top-color: #2a2320 !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] #fea-cc a { color: #b8afa8 !important; }
|
||||||
|
html[data-fea-theme="dark"] #fea-cc .fea-cc-reject { color: #ececec !important; border-color: #3a3a3a !important; }
|
||||||
|
</style>
|
||||||
|
<?php
|
||||||
|
}, 30);
|
||||||
|
|
||||||
|
/* ── 3) Botón de toggle, inyectado en el header junto al selector de idioma ──
|
||||||
|
* Mismo patrón que el selector de idioma (fea-homepage.php): se crea por JS
|
||||||
|
* y se inserta en el nav del header, para no tocar el template FSE. */
|
||||||
|
add_action('wp_footer', function () {
|
||||||
|
if (is_admin()) return;
|
||||||
|
?>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
// El footer puede renderizarse más de una vez en algunos templates FSE.
|
||||||
|
// Evitamos crear un segundo toggle en ese caso.
|
||||||
|
if (document.getElementById('fea-theme-btn')) return;
|
||||||
|
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.id = 'fea-theme-btn';
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.setAttribute('aria-pressed', document.documentElement.getAttribute('data-fea-theme') === 'dark' ? 'true' : 'false');
|
||||||
|
btn.setAttribute('aria-label', 'Cambiar a modo oscuro');
|
||||||
|
btn.textContent = document.documentElement.getAttribute('data-fea-theme') === 'dark' ? '☀️' : '🌙';
|
||||||
|
|
||||||
|
function setLabel(isDark) {
|
||||||
|
btn.setAttribute('aria-pressed', isDark ? 'true' : 'false');
|
||||||
|
btn.setAttribute('aria-label', isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro');
|
||||||
|
btn.textContent = isDark ? '☀️' : '🌙';
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var isDark = document.documentElement.getAttribute('data-fea-theme') === 'dark';
|
||||||
|
if (isDark) {
|
||||||
|
document.documentElement.removeAttribute('data-fea-theme');
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-fea-theme', 'dark');
|
||||||
|
}
|
||||||
|
setLabel(!isDark);
|
||||||
|
try { localStorage.setItem('fea_theme', !isDark ? 'dark' : 'light'); } catch (e) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
function inject() {
|
||||||
|
var header = document.querySelector('header.wp-block-template-part') || document.querySelector('header');
|
||||||
|
if (!header) return;
|
||||||
|
var navGroup = header.querySelector('.wp-block-navigation__container') || header.querySelector('nav');
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.className = 'wp-block-navigation-item';
|
||||||
|
li.style.cssText = 'display:flex;align-items:center;margin-left:0.5rem;';
|
||||||
|
li.appendChild(btn);
|
||||||
|
if (!navGroup) {
|
||||||
|
header.style.position = 'relative';
|
||||||
|
li.style.cssText = 'position:absolute;top:50%;right:1.5rem;transform:translateY(-50%);';
|
||||||
|
header.appendChild(li);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Insertar después del selector de idioma si ya se inyectó, si no, al final del nav.
|
||||||
|
var langSwitcher = navGroup.querySelector('#fea-lang-switcher');
|
||||||
|
if (langSwitcher) {
|
||||||
|
var anchor = langSwitcher;
|
||||||
|
while (anchor.parentNode && anchor.parentNode !== navGroup) anchor = anchor.parentNode;
|
||||||
|
anchor.parentNode.insertBefore(li, anchor.nextSibling);
|
||||||
|
} else {
|
||||||
|
navGroup.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', inject);
|
||||||
|
} else {
|
||||||
|
inject();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
}, 21);
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta — Evangelio en comentarios
|
||||||
|
* Description: Deriva la cita de la carta semanal y muestra la lectura en los comentarios al evangelio.
|
||||||
|
* Version: 1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
function fea_evangelio_category($es_id) {
|
||||||
|
return function_exists('fea_cat') ? (int) fea_cat($es_id) : (int) $es_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_evangelio_comment_categories() {
|
||||||
|
return array_map('fea_evangelio_category', [1647, 1646]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_evangelio_is_comment($post_id) {
|
||||||
|
return has_category(fea_evangelio_comment_categories(), $post_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lee el meta del comentario; las traducciones heredan el dato editorial del original ES. */
|
||||||
|
function fea_evangelio_comment_meta($post_id, $key) {
|
||||||
|
$value = get_post_meta($post_id, $key, true);
|
||||||
|
if ($value !== '' && $value !== null) return $value;
|
||||||
|
if (!function_exists('pll_get_post')) return $value;
|
||||||
|
|
||||||
|
$source_id = (int) pll_get_post((int) $post_id, 'es');
|
||||||
|
return $source_id && $source_id !== (int) $post_id
|
||||||
|
? get_post_meta($source_id, $key, true)
|
||||||
|
: $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_evangelio_reading_title($cita) {
|
||||||
|
$cita = trim(wp_strip_all_tags((string) $cita));
|
||||||
|
if (!preg_match('/^(Mt|Mc|Lc|Jn)\s+(.+)$/u', $cita, $m)) return '';
|
||||||
|
$books = ['Mt' => 'MATEO', 'Mc' => 'MARCOS', 'Lc' => 'LUCAS', 'Jn' => 'JUAN'];
|
||||||
|
return $books[$m[1]] . ' ' . preg_replace('/\s+/u', ' ', trim($m[2]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Localiza la lectura española, sin depender del slug (que puede llevar sufijos). */
|
||||||
|
function fea_evangelio_reading_id($cita) {
|
||||||
|
global $wpdb;
|
||||||
|
static $cache = [];
|
||||||
|
$title = fea_evangelio_reading_title($cita);
|
||||||
|
if ($title === '') return 0;
|
||||||
|
if (isset($cache[$title])) return $cache[$title];
|
||||||
|
// La coincidencia se hace siempre contra la lectura original en español; después
|
||||||
|
// Polylang la sustituye por la traducción del comentario cuando exista.
|
||||||
|
$cat = 1645;
|
||||||
|
$sql = $wpdb->prepare(
|
||||||
|
"SELECT p.ID FROM {$wpdb->posts} p
|
||||||
|
INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
|
||||||
|
INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
|
||||||
|
WHERE p.post_type = 'post' AND p.post_status = 'publish' AND p.post_title = %s
|
||||||
|
AND tt.taxonomy = 'category' AND tt.term_id = %d
|
||||||
|
ORDER BY p.post_date DESC LIMIT 1",
|
||||||
|
$title, $cat
|
||||||
|
);
|
||||||
|
return $cache[$title] = (int) $wpdb->get_var($sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Devuelve los evangelios enlazados en la sección pertinente de una carta. */
|
||||||
|
function fea_evangelio_from_carta($carta_id) {
|
||||||
|
if (!function_exists('fea_parse_carta_sections')) return [];
|
||||||
|
if (function_exists('pll_get_post')) $carta_id = (int) (pll_get_post((int) $carta_id, 'es') ?: $carta_id);
|
||||||
|
$links = fea_parse_carta_sections((int) $carta_id)['evangelio'] ?? [];
|
||||||
|
$result = [];
|
||||||
|
foreach ($links as $id) {
|
||||||
|
$post = get_post($id);
|
||||||
|
if (!$post) continue;
|
||||||
|
$cita = get_post_meta($id, '* Cita biblica', true);
|
||||||
|
if (!$cita && preg_match('/^(MATEO|MARCOS|LUCAS|JUAN)\s+(.+)$/u', trim($post->post_title), $m)) {
|
||||||
|
$abbr = ['MATEO' => 'Mt', 'MARCOS' => 'Mc', 'LUCAS' => 'Lc', 'JUAN' => 'Jn'][$m[1]];
|
||||||
|
$cita = $abbr . ' ' . $m[2];
|
||||||
|
}
|
||||||
|
if (fea_evangelio_reading_title($cita)) $result[(string) $id] = trim($cita);
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Busca una carta en las tres categorías históricas, dentro de cuatro días. */
|
||||||
|
function fea_evangelio_nearest_carta_id(WP_Post $post) {
|
||||||
|
$date = mysql2date('Y-m-d', $post->post_date, false);
|
||||||
|
$cartas = get_posts([
|
||||||
|
'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => 10,
|
||||||
|
'category__in' => array_map('fea_evangelio_category', [6, 21, 22]),
|
||||||
|
'date_query' => [[
|
||||||
|
'after' => date('Y-m-d', strtotime($date . ' -4 days')),
|
||||||
|
'before' => date('Y-m-d', strtotime($date . ' +4 days')), 'inclusive' => true,
|
||||||
|
]],
|
||||||
|
'orderby' => 'date', 'order' => 'DESC',
|
||||||
|
]);
|
||||||
|
if (!$cartas) return 0;
|
||||||
|
usort($cartas, function($a, $b) use ($post) {
|
||||||
|
return abs(strtotime($a->post_date) - strtotime($post->post_date)) <=> abs(strtotime($b->post_date) - strtotime($post->post_date));
|
||||||
|
});
|
||||||
|
return (int) $cartas[0]->ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escribe solo metadatos ausentes cuando la carta identifica un único evangelio. */
|
||||||
|
function fea_evangelio_capture_meta($post_id) {
|
||||||
|
$post = get_post($post_id);
|
||||||
|
if (!$post || $post->post_type !== 'post' || $post->post_status !== 'publish' || !fea_evangelio_is_comment($post_id)) return false;
|
||||||
|
if (get_post_meta($post_id, '_cita_evangelio', true)) return false;
|
||||||
|
$carta_id = (int) get_post_meta($post_id, '_carta_id', true);
|
||||||
|
if (!$carta_id) $carta_id = fea_evangelio_nearest_carta_id($post);
|
||||||
|
if (!$carta_id) return false;
|
||||||
|
$evangelios = fea_evangelio_from_carta($carta_id);
|
||||||
|
if (count($evangelios) !== 1) {
|
||||||
|
error_log(sprintf('Fe Adulta: comentario %d sin cita; carta %d tiene %d evangelios.', $post_id, $carta_id, count($evangelios)));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
update_post_meta($post_id, '_cita_evangelio', reset($evangelios));
|
||||||
|
if (!get_post_meta($post_id, '_carta_id', true)) update_post_meta($post_id, '_carta_id', $carta_id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_evangelio_summary_label($lang) {
|
||||||
|
$labels = [
|
||||||
|
'es' => 'Leer el evangelio', 'en' => 'Read the Gospel', 'fr' => 'Lire l’Évangile',
|
||||||
|
'it' => 'Leggi il Vangelo', 'pt' => 'Ler o Evangelho',
|
||||||
|
];
|
||||||
|
return $labels[$lang] ?? $labels['es'];
|
||||||
|
}
|
||||||
|
|
||||||
|
add_action('transition_post_status', function($new_status, $old_status, $post) {
|
||||||
|
if ($new_status === 'publish' && $old_status !== 'publish') fea_evangelio_capture_meta($post->ID);
|
||||||
|
}, 20, 3);
|
||||||
|
|
||||||
|
/** Añade referencia y texto plegado, cerrado por defecto, antes del comentario. */
|
||||||
|
add_filter('the_content', function($content) {
|
||||||
|
if (!is_single() || is_admin()) return $content;
|
||||||
|
global $post;
|
||||||
|
if (!$post || !fea_evangelio_is_comment($post->ID)) return $content;
|
||||||
|
$cita = trim((string) fea_evangelio_comment_meta($post->ID, '_cita_evangelio'));
|
||||||
|
if (!$cita) return $content;
|
||||||
|
$reading_id = fea_evangelio_reading_id($cita);
|
||||||
|
$lang = function_exists('pll_current_language') ? pll_current_language() : 'es';
|
||||||
|
if ($reading_id && $lang && $lang !== 'es' && function_exists('pll_get_post')) $reading_id = (int) (pll_get_post($reading_id, $lang) ?: $reading_id);
|
||||||
|
$reading = $reading_id ? get_post($reading_id) : null;
|
||||||
|
$starts_with_cita = preg_match('/^\s*' . preg_quote($cita, '/') . '(?:[\s\.,:;\-–—]|$)/iu', wp_strip_all_tags($content));
|
||||||
|
$html = '<section class="fea-com-evangelio" aria-label="' . esc_attr(fea_evangelio_summary_label($lang)) . '">';
|
||||||
|
if (!$starts_with_cita) $html .= '<p class="fea-com-evangelio-cita">' . esc_html($cita) . '</p>';
|
||||||
|
if ($reading && trim(wp_strip_all_tags($reading->post_content)) !== '') {
|
||||||
|
$html .= '<details class="fea-com-evangelio-details"><summary>' . esc_html(fea_evangelio_summary_label($lang)) . '</summary>'
|
||||||
|
. wpautop(do_shortcode($reading->post_content)) . '</details>';
|
||||||
|
}
|
||||||
|
return $html . '</section>' . $content;
|
||||||
|
}, 8);
|
||||||
|
|
||||||
|
add_action('wp_head', function() {
|
||||||
|
if (!is_single()) return;
|
||||||
|
global $post;
|
||||||
|
if (!$post || !fea_evangelio_is_comment($post->ID)) return;
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
.fea-com-evangelio { margin: 0 0 1.75rem; padding: 1rem 1.15rem; background: #f7f4ed; border-left: 4px solid #a77b2d; }
|
||||||
|
.fea-com-evangelio-cita { margin: 0 0 .65rem; font-weight: 700; }
|
||||||
|
.fea-com-evangelio-details summary { cursor: pointer; font-weight: 700; color: #51401e; }
|
||||||
|
.fea-com-evangelio-details > :last-child { margin-bottom: 0; }
|
||||||
|
.fea-com-evangelio-details[open] summary { margin-bottom: .85rem; }
|
||||||
|
</style>
|
||||||
|
<?php
|
||||||
|
});
|
||||||
+54
-28
@@ -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();
|
||||||
@@ -2044,8 +2083,12 @@ add_filter('the_content', function($content) {
|
|||||||
global $post;
|
global $post;
|
||||||
if (!in_category('comentarios-al-evangelio', $post)) return $content;
|
if (!in_category('comentarios-al-evangelio', $post)) return $content;
|
||||||
|
|
||||||
$carta_id = get_post_meta($post->ID, '_carta_id', true);
|
$carta_id = function_exists('fea_evangelio_comment_meta')
|
||||||
$cita = get_post_meta($post->ID, '_cita_evangelio', true);
|
? fea_evangelio_comment_meta($post->ID, '_carta_id')
|
||||||
|
: get_post_meta($post->ID, '_carta_id', true);
|
||||||
|
$cita = function_exists('fea_evangelio_comment_meta')
|
||||||
|
? fea_evangelio_comment_meta($post->ID, '_cita_evangelio')
|
||||||
|
: get_post_meta($post->ID, '_cita_evangelio', true);
|
||||||
|
|
||||||
if (!$carta_id && !$cita) return $content;
|
if (!$carta_id && !$cita) return $content;
|
||||||
|
|
||||||
@@ -2066,23 +2109,6 @@ add_filter('the_content', function($content) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Cita del evangelio — link directo al anchor en el índice ---
|
|
||||||
if ($cita) {
|
|
||||||
$book_post_id = ['Mt' => 43908, 'Mc' => 43909, 'Lc' => 43907, 'Jn' => 43906];
|
|
||||||
$abbr = substr($cita, 0, 2);
|
|
||||||
$anchor = fea_cita_to_anchor($cita);
|
|
||||||
$index_url = isset($book_post_id[$abbr])
|
|
||||||
? esc_url(get_permalink($book_post_id[$abbr]) . '#' . $anchor)
|
|
||||||
: '';
|
|
||||||
|
|
||||||
$html .= '<div class="fea-com-row fea-com-cita">'
|
|
||||||
. '<span class="fea-com-label">Evangelio</span>'
|
|
||||||
. ($index_url
|
|
||||||
? '<a href="' . $index_url . '">' . esc_html($cita) . '</a>'
|
|
||||||
: '<span>' . esc_html($cita) . '</span>')
|
|
||||||
. '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Otros comentarios de esta misma cita ---
|
// --- Otros comentarios de esta misma cita ---
|
||||||
if ($cita) {
|
if ($cita) {
|
||||||
$others = get_posts([
|
$others = get_posts([
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
/** Propuesta/aplicación segura del backfill de _cita_evangelio. */
|
||||||
|
/** Uso: wp eval-file scripts/backfill_evangelio_meta.php [--apply] --path=/var/www/html */
|
||||||
|
|
||||||
|
// WP-CLI no reenvía de forma consistente argumentos desconocidos a eval-file.
|
||||||
|
// El modo escritura exige por tanto una intención explícita y auditable:
|
||||||
|
// FEA_EVANGELIO_APPLY=1 wp eval-file scripts/backfill_evangelio_meta.php
|
||||||
|
$apply = getenv('FEA_EVANGELIO_APPLY') === '1'
|
||||||
|
|| in_array('--apply', $_SERVER['argv'] ?? [], true);
|
||||||
|
$posts = get_posts([
|
||||||
|
'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => -1,
|
||||||
|
'category__in' => fea_evangelio_comment_categories(),
|
||||||
|
'meta_query' => [['key' => '_cita_evangelio', 'compare' => 'NOT EXISTS']],
|
||||||
|
'orderby' => 'date', 'order' => 'ASC',
|
||||||
|
]);
|
||||||
|
$out = fopen('php://output', 'w');
|
||||||
|
fputcsv($out, ['post_id', 'fecha', 'carta_id', 'cita_propuesta', 'resultado']);
|
||||||
|
foreach ($posts as $post) {
|
||||||
|
$before_carta = (int) get_post_meta($post->ID, '_carta_id', true);
|
||||||
|
// Las cartas deducidas solo se proponen: la cola histórica requiere revisión humana.
|
||||||
|
$carta_id = $before_carta ?: fea_evangelio_nearest_carta_id($post);
|
||||||
|
$evangelios = $carta_id ? fea_evangelio_from_carta($carta_id) : [];
|
||||||
|
$cita = $before_carta && count($evangelios) === 1 ? reset($evangelios) : '';
|
||||||
|
$result = $cita ? ($apply ? 'aplicado' : 'propuesta') : (!$before_carta ? 'revision_manual_carta_por_fecha' : (count($evangelios) > 1 ? 'revision_manual_multi_evangelio' : 'sin_evangelio'));
|
||||||
|
if ($apply && $cita) {
|
||||||
|
update_post_meta($post->ID, '_cita_evangelio', $cita);
|
||||||
|
if (!$before_carta) update_post_meta($post->ID, '_carta_id', $carta_id);
|
||||||
|
}
|
||||||
|
fputcsv($out, [$post->ID, $post->post_date, $carta_id ?: '', $cita, $result]);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Genera la tabla fecha -> entrada del libro (book_index 1-500) + ciclo A/B/C
|
||||||
|
para el shortcode del Evangelio de cada día, 2026-2040.
|
||||||
|
|
||||||
|
Reescritura completa (issue #20) tras retirar el commit 2713e13: aquella
|
||||||
|
version numeraba los domingos con un desfase de -1, contaba Cuaresma en
|
||||||
|
bloques de 7 desde Ceniza, evaluaba Adviento antes que Navidad (fuga el
|
||||||
|
26-31 de diciembre), y dejaba sin clave propia Bautismo/Ascension/Familia de
|
||||||
|
Nazaret/Cristo Rey, ademas de dar precedencia a fiestas fijas (Anunciacion,
|
||||||
|
San Juan Bautista...) sobre domingos y hasta sobre la Pascua misma.
|
||||||
|
|
||||||
|
Diseño distinto a proposito: en vez de una clave de texto libre (que hay que
|
||||||
|
mantener sincronizada a mano con el vocabulario del libro), cada fecha
|
||||||
|
referencia directamente el `source_index` (1-500) de
|
||||||
|
data/evangelio-diario/entradas-libro.json. Los tests de este fichero
|
||||||
|
comprueban cobertura bidireccional contra esas 500 entradas.
|
||||||
|
|
||||||
|
Fuentes:
|
||||||
|
- Computo de Pascua: algoritmo gregoriano (Meeus/Jones/Butcher), independiente
|
||||||
|
de cualquier tabla publicada.
|
||||||
|
- Normas Universales sobre el Año Litúrgico y el Calendario (1969): tabla de
|
||||||
|
precedencia (Adviento/Cuaresma/Pascua > solemnidades fijas; entre
|
||||||
|
solemnidades fijas del Señor > de un Santo) y regla especifica de traslado
|
||||||
|
de la Anunciación.
|
||||||
|
- Practica de la CEE: Ascensión y Corpus Christi trasladados al domingo.
|
||||||
|
- CEE, "Tabla temporal de las principales celebraciones del Año Litúrgico
|
||||||
|
2021-2044" (referencia general, NO la unica fuente usada para validar --
|
||||||
|
ver issue #20 para el detalle de fuentes cruzadas independientes).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
BOOK_PATH = ROOT / "data/evangelio-diario/entradas-libro.json"
|
||||||
|
OUT_PATH = ROOT / "data/evangelio-diario/calendario-liturgico-2026-2040.json"
|
||||||
|
|
||||||
|
YEAR_FROM = 2026
|
||||||
|
YEAR_TO = 2040
|
||||||
|
|
||||||
|
|
||||||
|
def easter_sunday(year: int) -> date:
|
||||||
|
a = year % 19
|
||||||
|
b = year // 100
|
||||||
|
c = year % 100
|
||||||
|
d_ = b // 4
|
||||||
|
e = b % 4
|
||||||
|
f = (b + 8) // 25
|
||||||
|
g = (b - f + 1) // 3
|
||||||
|
h = (19 * a + b - d_ - g + 15) % 30
|
||||||
|
i = c // 4
|
||||||
|
k = c % 4
|
||||||
|
l = (32 + 2 * e + 2 * i - h - k) % 7
|
||||||
|
m = (a + 11 * h + 22 * l) // 451
|
||||||
|
month = (h + l - 7 * m + 114) // 31
|
||||||
|
day = ((h + l - 7 * m + 114) % 31) + 1
|
||||||
|
return date(year, month, day)
|
||||||
|
|
||||||
|
|
||||||
|
def prev_sunday_on_or_before(d: date) -> date:
|
||||||
|
return d - timedelta(days=(d.weekday() + 1) % 7)
|
||||||
|
|
||||||
|
|
||||||
|
def next_sunday_on_or_after(d: date) -> date:
|
||||||
|
return d + timedelta(days=(6 - d.weekday()) % 7)
|
||||||
|
|
||||||
|
|
||||||
|
def cycle_for_civil_year(y: int) -> str:
|
||||||
|
return {1: "A", 2: "B", 0: "C"}[y % 3]
|
||||||
|
|
||||||
|
|
||||||
|
class YearFacts:
|
||||||
|
"""Fechas moviles/fijas derivadas de la Pascua y de Navidad para el año
|
||||||
|
civil `y`. El tramo de Adviento/Navidad de diciembre de `y` referencia el
|
||||||
|
ciclo A/B/C del año litúrgico SIGUIENTE (y+1), que es el que empieza."""
|
||||||
|
|
||||||
|
def __init__(self, y: int):
|
||||||
|
self.y = y
|
||||||
|
self.easter = easter_sunday(y)
|
||||||
|
self.ash_wed = self.easter - timedelta(days=46)
|
||||||
|
self.palm_sunday = self.easter - timedelta(days=7)
|
||||||
|
self.holy_mon = self.easter - timedelta(days=6)
|
||||||
|
self.holy_tue = self.easter - timedelta(days=5)
|
||||||
|
self.holy_wed = self.easter - timedelta(days=4)
|
||||||
|
self.holy_thu = self.easter - timedelta(days=3)
|
||||||
|
self.good_fri = self.easter - timedelta(days=2)
|
||||||
|
self.holy_sat = self.easter - timedelta(days=1)
|
||||||
|
self.pentecost = self.easter + timedelta(days=49)
|
||||||
|
self.trinity = self.pentecost + timedelta(days=7)
|
||||||
|
self.corpus = self.trinity + timedelta(days=7) # trasladado a domingo (CEE)
|
||||||
|
self.ascension = self.easter + timedelta(days=42) # trasladada al 7º domingo (CEE)
|
||||||
|
|
||||||
|
self.epiphany = date(y, 1, 6)
|
||||||
|
self.baptism = (
|
||||||
|
self.epiphany + timedelta(days=1)
|
||||||
|
if self.epiphany.weekday() == 6
|
||||||
|
else next_sunday_on_or_after(self.epiphany)
|
||||||
|
)
|
||||||
|
|
||||||
|
xmas_prev = date(y - 1, 12, 25)
|
||||||
|
self.holy_family = (
|
||||||
|
date(y - 1, 12, 30)
|
||||||
|
if xmas_prev.weekday() == 6
|
||||||
|
else next_sunday_on_or_after(xmas_prev)
|
||||||
|
)
|
||||||
|
|
||||||
|
cand = next_sunday_on_or_after(date(y, 1, 2))
|
||||||
|
self.dom2_navidad = cand if cand.day <= 5 else None
|
||||||
|
|
||||||
|
self.christmas = date(y, 12, 25)
|
||||||
|
sun_before_xmas_eve = prev_sunday_on_or_before(self.christmas - timedelta(days=1))
|
||||||
|
self.advent1 = sun_before_xmas_eve - timedelta(days=21)
|
||||||
|
self.advent2 = self.advent1 + timedelta(days=7)
|
||||||
|
self.advent3 = self.advent1 + timedelta(days=14)
|
||||||
|
self.advent4 = self.advent1 + timedelta(days=21)
|
||||||
|
self.christ_king = self.advent1 - timedelta(days=7)
|
||||||
|
|
||||||
|
# Numeracion de Tiempo Ordinario con omision de semanas (norma real:
|
||||||
|
# se cuenta hacia atras desde Cristo Rey=semana 34). L = ultima semana
|
||||||
|
# de TO alcanzada (entera o parcial) antes de Ceniza.
|
||||||
|
self.week2_sunday = self.baptism + timedelta(days=7)
|
||||||
|
self.L = 2 + (self.ash_wed - self.week2_sunday).days // 7
|
||||||
|
first_post = self.corpus + timedelta(days=7)
|
||||||
|
self.K = (self.christ_king - first_post).days // 7 + 1
|
||||||
|
self.resume_week = 35 - self.K
|
||||||
|
|
||||||
|
|
||||||
|
def lent_week_base(n: int) -> int:
|
||||||
|
return {1: 145, 2: 154, 3: 163, 4: 172, 5: 181}[n]
|
||||||
|
|
||||||
|
|
||||||
|
def ot_week_base(n: int) -> int:
|
||||||
|
"""idx base [DomA,DomB,DomC,Lun..Sab] de la semana N de TO (2..34)."""
|
||||||
|
if 2 <= n <= 9:
|
||||||
|
return 69 + (n - 2) * 9
|
||||||
|
if 10 <= n <= 33:
|
||||||
|
return 265 + (n - 10) * 9
|
||||||
|
if n == 34:
|
||||||
|
return 481
|
||||||
|
raise ValueError(f"semana de TO fuera de rango: {n}")
|
||||||
|
|
||||||
|
|
||||||
|
CYC_OFFSET = {"A": 0, "B": 1, "C": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def build_calendar(year_from: int, year_to: int) -> dict[str, dict]:
|
||||||
|
facts = {y: YearFacts(y) for y in range(year_from - 1, year_to + 2)}
|
||||||
|
out: dict[str, dict] = {}
|
||||||
|
|
||||||
|
def set_day(d: date, idx: int, cyc: str, note: str) -> None:
|
||||||
|
iso = d.isoformat()
|
||||||
|
if iso in out:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"colision interna en {iso}: ya tenia idx={out[iso]['idx']} "
|
||||||
|
f"({out[iso]['note']}), se queria poner idx={idx} ({note})"
|
||||||
|
)
|
||||||
|
out[iso] = {"idx": idx, "cycle": cyc, "note": note}
|
||||||
|
|
||||||
|
for y in range(year_from, year_to + 1):
|
||||||
|
f = facts[y]
|
||||||
|
cyc = cycle_for_civil_year(y)
|
||||||
|
|
||||||
|
# -- Tiempo Ordinario semana 1 (tras el Bautismo, sin domingo propio) --
|
||||||
|
wk1_start = f.baptism + timedelta(days=1)
|
||||||
|
for i in range(6):
|
||||||
|
set_day(wk1_start + timedelta(days=i), 63 + i, cyc, "TOwk1")
|
||||||
|
|
||||||
|
# -- Tiempo Ordinario semanas 2..L (antes de Ceniza) --
|
||||||
|
for n in range(2, f.L + 1):
|
||||||
|
base = ot_week_base(n)
|
||||||
|
sun = f.week2_sunday + timedelta(days=(n - 2) * 7)
|
||||||
|
set_day(sun, base + CYC_OFFSET[cyc], cyc, f"TOwk{n}Sun")
|
||||||
|
for i in range(6):
|
||||||
|
d = sun + timedelta(days=1 + i)
|
||||||
|
if d >= f.ash_wed:
|
||||||
|
break
|
||||||
|
set_day(d, base + 3 + i, cyc, f"TOwk{n}dia{i}")
|
||||||
|
|
||||||
|
# -- Cuaresma --
|
||||||
|
set_day(f.ash_wed, 141, cyc, "Ceniza")
|
||||||
|
for i, idx in enumerate((142, 143, 144)):
|
||||||
|
set_day(f.ash_wed + timedelta(days=1 + i), idx, cyc, "post-ceniza")
|
||||||
|
for n in range(1, 6):
|
||||||
|
base = lent_week_base(n)
|
||||||
|
sun = f.easter - timedelta(days=49 - 7 * n)
|
||||||
|
set_day(sun, base + CYC_OFFSET[cyc], cyc, f"Cuaresma{n}Dom")
|
||||||
|
for i in range(6):
|
||||||
|
set_day(sun + timedelta(days=1 + i), base + 3 + i, cyc, f"Cuaresma{n}dia{i}")
|
||||||
|
|
||||||
|
# -- Semana Santa / Triduo --
|
||||||
|
set_day(f.palm_sunday, 190 + CYC_OFFSET[cyc], cyc, "Ramos")
|
||||||
|
for d, idx in ((f.holy_mon, 193), (f.holy_tue, 194), (f.holy_wed, 195), (f.holy_thu, 196)):
|
||||||
|
set_day(d, idx, cyc, "SemanaSanta")
|
||||||
|
set_day(f.good_fri, 197, cyc, "ViernesSanto")
|
||||||
|
set_day(f.holy_sat, 198, cyc, "VigiliaPascual")
|
||||||
|
|
||||||
|
# -- Pascua --
|
||||||
|
set_day(f.easter, 199, cyc, "Resurreccion")
|
||||||
|
for i in range(6):
|
||||||
|
set_day(f.easter + timedelta(days=1 + i), 200 + i, cyc, "OctavaPascua")
|
||||||
|
set_day(f.easter + timedelta(days=7), 206, cyc, "DomIIPascua")
|
||||||
|
pascua_blocks = [(207, 213), (216, 222), (225, 231), (234, 240)]
|
||||||
|
for wi, (mon_base, sun_base) in enumerate(pascua_blocks):
|
||||||
|
prev_sun = f.easter + timedelta(days=7 * (wi + 1))
|
||||||
|
for i in range(6):
|
||||||
|
set_day(prev_sun + timedelta(days=1 + i), mon_base + i, cyc, f"Pascua{wi}")
|
||||||
|
sun = prev_sun + timedelta(days=7)
|
||||||
|
set_day(sun, sun_base + CYC_OFFSET[cyc], cyc, f"PascuaDom{wi}")
|
||||||
|
wk7_mon = f.easter + timedelta(days=7 * 5 + 1)
|
||||||
|
for i in range(6):
|
||||||
|
set_day(wk7_mon + timedelta(days=i), 243 + i, cyc, "Pascua wk7")
|
||||||
|
set_day(f.ascension, 249 + CYC_OFFSET[cyc], cyc, "Ascension")
|
||||||
|
wk8_mon = f.ascension + timedelta(days=1)
|
||||||
|
for i in range(6):
|
||||||
|
d = wk8_mon + timedelta(days=i)
|
||||||
|
if d >= f.pentecost:
|
||||||
|
break
|
||||||
|
set_day(d, 252 + i, cyc, "Pascua wk8")
|
||||||
|
set_day(f.pentecost, 258, cyc, "Pentecostes")
|
||||||
|
set_day(f.trinity, 259 + CYC_OFFSET[cyc], cyc, "Trinidad")
|
||||||
|
set_day(f.corpus, 262 + CYC_OFFSET[cyc], cyc, "Corpus")
|
||||||
|
|
||||||
|
# Las 3 semanas "tapadas" por Pentecostes/Trinidad/Corpus: el TO
|
||||||
|
# arranca el lunes despues de Pentecostes (norma real); solo el
|
||||||
|
# domingo de cada una de esas 3 semanas lo tapa la solemnidad, sus
|
||||||
|
# ferias Lun-Sab SI son de Tiempo Ordinario.
|
||||||
|
first_post = f.corpus + timedelta(days=7)
|
||||||
|
for gap_i, gap_sunday in enumerate((f.pentecost, f.trinity, f.corpus)):
|
||||||
|
gap_n = f.resume_week - 3 + gap_i
|
||||||
|
gap_base = ot_week_base(gap_n)
|
||||||
|
for i in range(6):
|
||||||
|
set_day(gap_sunday + timedelta(days=1 + i), gap_base + 3 + i, cyc, f"TOwk{gap_n}gap")
|
||||||
|
|
||||||
|
# -- Tiempo Ordinario semanas resume_week..34 --
|
||||||
|
for n in range(f.resume_week, 35):
|
||||||
|
sun = first_post + timedelta(days=(n - f.resume_week) * 7)
|
||||||
|
if n == 34:
|
||||||
|
set_day(sun, 481 + CYC_OFFSET[cyc], cyc, "CristoRey")
|
||||||
|
for i in range(6):
|
||||||
|
set_day(sun + timedelta(days=1 + i), 484 + i, cyc, "TOwk34dia")
|
||||||
|
else:
|
||||||
|
base = ot_week_base(n)
|
||||||
|
set_day(sun, base + CYC_OFFSET[cyc], cyc, f"TOwk{n}Dom-post")
|
||||||
|
for i in range(6):
|
||||||
|
set_day(sun + timedelta(days=1 + i), base + 3 + i, cyc, f"TOwk{n}dia-post")
|
||||||
|
|
||||||
|
# -- Adviento (ciclo del año litúrgico que empieza, y+1) --
|
||||||
|
cyc_next = cycle_for_civil_year(y + 1)
|
||||||
|
set_day(f.advent1, 1 + CYC_OFFSET[cyc_next], cyc_next, "Adviento1")
|
||||||
|
set_day(f.advent2, 10 + CYC_OFFSET[cyc_next], cyc_next, "Adviento2")
|
||||||
|
set_day(f.advent3, 19 + CYC_OFFSET[cyc_next], cyc_next, "Adviento3")
|
||||||
|
set_day(f.advent4, 35 + CYC_OFFSET[cyc_next], cyc_next, "Adviento4")
|
||||||
|
# ferias genericas de Adviento (semanas 1-3), consumidas dia a dia
|
||||||
|
# (no-domingo) desde adviento1+1 hasta el 16 de diciembre inclusive.
|
||||||
|
pool = [4, 5, 6, 7, 8, 9, 13, 14, 15, 16, 17, 18, 22, 23, 24, 25, 26]
|
||||||
|
pi = 0
|
||||||
|
d = f.advent1 + timedelta(days=1)
|
||||||
|
cutover = date(y, 12, 17)
|
||||||
|
while d < cutover:
|
||||||
|
if d.weekday() != 6:
|
||||||
|
if pi >= len(pool):
|
||||||
|
raise RuntimeError(f"pool de ferias de Adviento agotado en {d.isoformat()}")
|
||||||
|
set_day(d, pool[pi], cyc_next, "AdvientoFeria")
|
||||||
|
pi += 1
|
||||||
|
d += timedelta(days=1)
|
||||||
|
# 17..23 de diciembre por fecha fija.
|
||||||
|
fixed_dec = {17: 27, 18: 28, 19: 29, 20: 30, 21: 31, 22: 32, 23: 33}
|
||||||
|
for day_num, idx in fixed_dec.items():
|
||||||
|
d = date(y, 12, day_num)
|
||||||
|
if d.weekday() == 6:
|
||||||
|
continue # ya asignado como domingo de Adviento arriba
|
||||||
|
set_day(d, idx, cyc_next, f"{day_num}dic")
|
||||||
|
# 24 de diciembre: SIEMPRE Nochebuena (idx38) salvo que sea Adviento4
|
||||||
|
# (domingo). La feria generica "24 diciembre" (idx34) por tanto no se
|
||||||
|
# usa nunca con este diseño -- decision de contenido a confirmar, ver
|
||||||
|
# nota en el JSON de salida.
|
||||||
|
d24 = date(y, 12, 24)
|
||||||
|
if d24.weekday() != 6:
|
||||||
|
set_day(d24, 38, cyc_next, "Nochebuena")
|
||||||
|
set_day(date(y, 12, 25), 39, cyc_next, "Navidad")
|
||||||
|
|
||||||
|
# -- 26 dic .. Bautismo, atraviesa el limite de año civil --
|
||||||
|
for y in range(year_from - 1, year_to + 1):
|
||||||
|
cyc_adv = cycle_for_civil_year(y + 1)
|
||||||
|
fnext = facts[y + 1]
|
||||||
|
holy_family = fnext.holy_family
|
||||||
|
fixed_oct = {26: 41, 27: 42, 28: 43, 29: 44, 30: 45, 31: 46}
|
||||||
|
for day_num, idx in fixed_oct.items():
|
||||||
|
d = date(y, 12, day_num)
|
||||||
|
if d == holy_family:
|
||||||
|
set_day(d, 40, cyc_adv, "SagradaFamilia")
|
||||||
|
else:
|
||||||
|
set_day(d, idx, cyc_adv, f"{day_num}dic-octava")
|
||||||
|
|
||||||
|
ny = y + 1
|
||||||
|
if ny > year_to + 1:
|
||||||
|
continue
|
||||||
|
fb = facts[ny]
|
||||||
|
cyc_ny = cycle_for_civil_year(ny)
|
||||||
|
set_day(date(ny, 1, 1), 47, cyc_adv, "MariaMadre")
|
||||||
|
dom2 = fb.dom2_navidad
|
||||||
|
for day_num in (2, 3, 4, 5):
|
||||||
|
d = date(ny, 1, day_num)
|
||||||
|
if dom2 is not None and d == dom2:
|
||||||
|
set_day(d, 48, cyc_adv, "DomIINavidad")
|
||||||
|
else:
|
||||||
|
idx = {2: 49, 3: 50, 4: 51, 5: 52}[day_num]
|
||||||
|
set_day(d, idx, cyc_adv, f"{day_num}ene")
|
||||||
|
set_day(fb.epiphany, 53, cyc_ny, "Epifania")
|
||||||
|
set_day(fb.baptism, 54 + CYC_OFFSET[cyc_ny], cyc_ny, "Bautismo")
|
||||||
|
d = fb.epiphany + timedelta(days=1)
|
||||||
|
fixed_ene = {7: 57, 8: 58, 9: 59, 10: 60, 11: 61, 12: 62}
|
||||||
|
while d < fb.baptism:
|
||||||
|
if d.day in fixed_ene:
|
||||||
|
set_day(d, fixed_ene[d.day], cyc_ny, f"{d.day}ene")
|
||||||
|
d += timedelta(days=1)
|
||||||
|
|
||||||
|
return out, facts
|
||||||
|
|
||||||
|
|
||||||
|
def apply_fixed_feasts(out: dict, facts: dict, year_from: int, year_to: int) -> list[tuple[str, str, int]]:
|
||||||
|
"""Aplica las 11 solemnidades de fecha fija del libro (8dic..2nov) con
|
||||||
|
precedencia real: ceden ante domingos de Adviento/Cuaresma/Pascua, Ramos,
|
||||||
|
lunes-jueves de Semana Santa, Triduo, Ascension y Pentecostes (rank1), y
|
||||||
|
ante Trinidad/Corpus/Cristo Rey (rank2 "del Señor" > rank2 "de un
|
||||||
|
Santo"). En cualquier otro dia (feria ordinaria) gana la fija."""
|
||||||
|
fixed = {
|
||||||
|
(12, 8): 490, (3, 19): 491, (3, 25): 492, (6, 24): 493, (6, 29): 494,
|
||||||
|
(7, 22): 495, (7, 25): 496, (8, 6): 497, (8, 15): 498, (11, 1): 499, (11, 2): 500,
|
||||||
|
}
|
||||||
|
|
||||||
|
def rank1_days(f) -> set[date]:
|
||||||
|
s = {f.ash_wed, f.palm_sunday, f.holy_mon, f.holy_tue, f.holy_wed,
|
||||||
|
f.holy_thu, f.good_fri, f.holy_sat, f.easter, f.pentecost,
|
||||||
|
f.advent1, f.advent2, f.advent3, f.advent4}
|
||||||
|
for n in range(1, 6):
|
||||||
|
s.add(f.easter - timedelta(days=49 - 7 * n))
|
||||||
|
for i in range(6):
|
||||||
|
s.add(f.easter + timedelta(days=1 + i))
|
||||||
|
for n in range(1, 7):
|
||||||
|
s.add(f.easter + timedelta(days=7 * n))
|
||||||
|
return s
|
||||||
|
|
||||||
|
def rank2_movable_days(f) -> set[date]:
|
||||||
|
return {f.trinity, f.corpus, f.christ_king}
|
||||||
|
|
||||||
|
transferred: list[tuple[str, str, int]] = []
|
||||||
|
for y in range(year_from, year_to + 1):
|
||||||
|
f = facts[y]
|
||||||
|
r1 = rank1_days(f)
|
||||||
|
r2m = rank2_movable_days(f)
|
||||||
|
for (mo, da), idx in fixed.items():
|
||||||
|
d = date(y, mo, da)
|
||||||
|
iso = d.isoformat()
|
||||||
|
if iso not in out:
|
||||||
|
continue
|
||||||
|
if d not in r1 and d not in r2m:
|
||||||
|
out[iso] = {"idx": idx, "cycle": out[iso]["cycle"], "note": "fija-normal"}
|
||||||
|
continue
|
||||||
|
loser_note = out[iso]["note"]
|
||||||
|
cyc = out[iso]["cycle"]
|
||||||
|
if idx == 492: # Anunciacion: regla especifica documentada
|
||||||
|
if d in {f.easter} or any(d == f.easter + timedelta(days=i) for i in range(1, 7)) \
|
||||||
|
or d in {f.easter + timedelta(days=7 * n) for n in range(1, 7)} \
|
||||||
|
or d in {f.palm_sunday, f.holy_mon, f.holy_tue, f.holy_wed, f.holy_thu}:
|
||||||
|
target = f.easter + timedelta(days=8) # lunes tras el 2º domingo de Pascua
|
||||||
|
else:
|
||||||
|
target = d + timedelta(days=1) # domingo de Cuaresma -> dia siguiente
|
||||||
|
else:
|
||||||
|
target = d + timedelta(days=1) # norma general: dia siguiente libre
|
||||||
|
tries = 0
|
||||||
|
while target in r1 or target in r2m or (
|
||||||
|
target.isoformat() in out and out[target.isoformat()]["idx"] >= 490
|
||||||
|
):
|
||||||
|
target += timedelta(days=1)
|
||||||
|
tries += 1
|
||||||
|
if tries > 15:
|
||||||
|
raise RuntimeError(f"no se encontro hueco para trasladar idx={idx} desde {iso}")
|
||||||
|
set_iso = target.isoformat()
|
||||||
|
out[set_iso] = {"idx": idx, "cycle": cyc, "note": f"TRASLADADA de {iso} ({loser_note})"}
|
||||||
|
transferred.append((iso, set_iso, idx))
|
||||||
|
return transferred
|
||||||
|
|
||||||
|
|
||||||
|
def coverage_report(out: dict) -> tuple[list[int], list[int]]:
|
||||||
|
book = json.loads(BOOK_PATH.read_text(encoding="utf-8"))
|
||||||
|
used = {v["idx"] for v in out.values()}
|
||||||
|
invalid = sorted(i for i in used if i < 1 or i > len(book))
|
||||||
|
never_used = sorted(set(range(1, len(book) + 1)) - used)
|
||||||
|
return invalid, never_used
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
out, facts = build_calendar(YEAR_FROM, YEAR_TO)
|
||||||
|
transferred = apply_fixed_feasts(out, facts, YEAR_FROM, YEAR_TO)
|
||||||
|
out = {k: v for k, v in sorted(out.items()) if f"{YEAR_FROM}-01-01" <= k <= f"{YEAR_TO}-12-31"}
|
||||||
|
|
||||||
|
expected_days = (date(YEAR_TO, 12, 31) - date(YEAR_FROM, 1, 1)).days + 1
|
||||||
|
assert len(out) == expected_days, f"faltan fechas: {len(out)} != {expected_days}"
|
||||||
|
|
||||||
|
invalid, never_used = coverage_report(out)
|
||||||
|
assert not invalid, f"indices fuera de 1-500: {invalid}"
|
||||||
|
|
||||||
|
dates_out = {k: {"book_index": v["idx"], "cycle": v["cycle"]} for k, v in out.items()}
|
||||||
|
result = {
|
||||||
|
"schema": 2,
|
||||||
|
"range": {"from": f"{YEAR_FROM}-01-01", "to": f"{YEAR_TO}-12-31"},
|
||||||
|
"sources": [
|
||||||
|
"https://www.conferenciaepiscopal.es/tabla-temporal-celebraciones-ano-liturgico-2021-2044/",
|
||||||
|
"Computo de Pascua: algoritmo gregoriano (Meeus/Jones/Butcher), independiente de tablas publicadas",
|
||||||
|
"Normas Universales sobre el Año Litúrgico y el Calendario (1969): tabla de precedencia y traslados",
|
||||||
|
],
|
||||||
|
"notes": [
|
||||||
|
"Cada fecha referencia book_index (1-500) sobre entradas-libro.json, no una clave de texto libre.",
|
||||||
|
"TO: numeracion con omision de semanas segun la norma real (cuenta atras desde Cristo Rey=semana34).",
|
||||||
|
"Ascension y Corpus trasladados al domingo (CEE); Bautismo/Sagrada Familia/Cristo Rey con indice propio.",
|
||||||
|
"Solemnidades fijas con logica de precedencia real (Adviento/Cuaresma/Pascua/Ramos/Triduo/Ascension/"
|
||||||
|
"Pentecostes ganan; entre solemnidades 'del Señor' > 'de un Santo'; Anunciacion con regla especifica).",
|
||||||
|
f"idx nunca alcanzado en {YEAR_FROM}-{YEAR_TO}: {never_used} (aritmetica real del rango, ver issue #20; "
|
||||||
|
"idx34 '24 diciembre' es ademas una decision de contenido: Nochebuena(38) gana siempre el 24-dic).",
|
||||||
|
],
|
||||||
|
"dates": dates_out,
|
||||||
|
}
|
||||||
|
OUT_PATH.write_text(json.dumps(result, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
||||||
|
print(f"escritas {len(dates_out)} fechas en {OUT_PATH}")
|
||||||
|
print(f"solemnidades fijas trasladadas: {len(transferred)}")
|
||||||
|
for t in transferred:
|
||||||
|
print(" ", t)
|
||||||
|
print(f"indices del libro nunca alcanzados en {YEAR_FROM}-{YEAR_TO}: {never_used}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Migra A la fuente cada día al índice estable del libro.
|
||||||
|
*
|
||||||
|
* Uso (por defecto solo informa):
|
||||||
|
* wp eval-file scripts/import_evangelio_diario.php --path=/var/www/html
|
||||||
|
* Escritura explícita: FEA_EVANGELIO_APPLY=1 wp eval-file ...
|
||||||
|
*
|
||||||
|
* Requiere que entradas-libro.json esté junto a este script durante la ejecución.
|
||||||
|
* Nunca elimina posts: actualiza únicamente los ya identificados de categoría 14 y
|
||||||
|
* crea los índices ausentes. Es idempotente por _fea_book_index.
|
||||||
|
*/
|
||||||
|
$apply = getenv('FEA_EVANGELIO_APPLY') === '1';
|
||||||
|
$book_file = __DIR__ . '/../data/evangelio-diario/entradas-libro.json';
|
||||||
|
$calendar_file = __DIR__ . '/../data/evangelio-diario/calendario-liturgico-2026-2040.json';
|
||||||
|
if (!is_readable($book_file)) { fwrite(STDERR, "No se lee $book_file\n"); exit(2); }
|
||||||
|
if (!is_readable($calendar_file)) { fwrite(STDERR, "No se lee $calendar_file\n"); exit(2); }
|
||||||
|
$book = json_decode(file_get_contents($book_file), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
if (count($book) !== 500) { fwrite(STDERR, "El libro debe tener 500 entradas\n"); exit(2); }
|
||||||
|
|
||||||
|
$norm = static function ($text) {
|
||||||
|
$text = html_entity_decode(wp_strip_all_tags((string) $text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
return preg_replace('/\s+/u', ' ', trim(mb_strtolower($text, 'UTF-8')));
|
||||||
|
};
|
||||||
|
$by_motto = [];
|
||||||
|
foreach ($book as $i => $entry) {
|
||||||
|
$key = $norm($entry['motto']);
|
||||||
|
if ($key !== '') $by_motto[$key][] = $i + 1;
|
||||||
|
}
|
||||||
|
$existing = get_posts([
|
||||||
|
'post_type' => 'post', 'post_status' => 'any', 'posts_per_page' => -1,
|
||||||
|
'category' => 14, 'orderby' => 'ID', 'order' => 'ASC',
|
||||||
|
]);
|
||||||
|
$used = [];
|
||||||
|
$updated = $ambiguous = $unmatched = $duplicate = 0;
|
||||||
|
$to_update = [];
|
||||||
|
foreach ($existing as $post) {
|
||||||
|
$matches = [];
|
||||||
|
foreach ($by_motto as $motto => $indices) {
|
||||||
|
if (str_contains($norm($post->post_content), $motto)) $matches = array_merge($matches, $indices);
|
||||||
|
}
|
||||||
|
$matches = array_values(array_unique($matches));
|
||||||
|
if (count($matches) !== 1) {
|
||||||
|
$ambiguous += count($matches) > 1; $unmatched += count($matches) === 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isset($used[$matches[0]])) { $duplicate++; continue; }
|
||||||
|
$idx = $matches[0]; $used[$idx] = true;
|
||||||
|
$entry = $book[$idx - 1];
|
||||||
|
$to_update[] = [$post->ID, $idx, $entry];
|
||||||
|
$updated++;
|
||||||
|
}
|
||||||
|
$planned_new = count($book) - count($used);
|
||||||
|
if ($ambiguous || $unmatched || $duplicate) {
|
||||||
|
printf("DRY-RUN: existentes indexados=%d; nuevos=%d; ambiguos=%d; sin coincidencia=%d; duplicados=%d; total=%d\n",
|
||||||
|
$updated, $planned_new, $ambiguous, $unmatched, $duplicate, count($book));
|
||||||
|
fwrite(STDERR, "ABORTAR APLICACIÓN: hay posts existentes sin correspondencia única.\n");
|
||||||
|
exit(3);
|
||||||
|
}
|
||||||
|
// El JSON se instala antes de tocar contenido: el shortcode lo consume desde
|
||||||
|
// WP_CONTENT_DIR/fea-data y así código y calendario llegan como una unidad.
|
||||||
|
if ($apply) {
|
||||||
|
$calendar_target = WP_CONTENT_DIR . '/fea-data/calendario-liturgico-2026-2040.json';
|
||||||
|
if (!wp_mkdir_p(dirname($calendar_target)) || !copy($calendar_file, $calendar_target)) {
|
||||||
|
fwrite(STDERR, "No se pudo instalar el calendario en $calendar_target\n"); exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($apply) foreach ($to_update as [$post_id, $idx, $entry]) {
|
||||||
|
update_post_meta($post_id, '_fea_book_index', (string) $idx);
|
||||||
|
update_post_meta($post_id, '_fea_cita', $entry['citation']);
|
||||||
|
update_post_meta($post_id, '_fea_fuente', 'A la fuente cada día — Fray Marcos, entrada #' . $idx);
|
||||||
|
}
|
||||||
|
$created = 0;
|
||||||
|
foreach ($book as $i => $entry) {
|
||||||
|
$idx = $i + 1;
|
||||||
|
if (isset($used[$idx])) continue;
|
||||||
|
$body = '<h1>' . esc_html($entry['title']) . '</h1>'
|
||||||
|
. '<p><strong>' . esc_html($entry['citation']) . '</strong></p>'
|
||||||
|
. '<p>' . esc_html($entry['gospel']) . '</p>'
|
||||||
|
. '<p><em>' . esc_html($entry['motto']) . '</em></p>';
|
||||||
|
foreach ($entry['paragraphs'] as $paragraph) $body .= '<p>' . esc_html($paragraph) . '</p>';
|
||||||
|
if ($apply) {
|
||||||
|
$id = wp_insert_post(['post_title' => $entry['title'], 'post_content' => $body,
|
||||||
|
'post_status' => 'publish', 'post_category' => [14]], true);
|
||||||
|
if (is_wp_error($id)) { fwrite(STDERR, $id->get_error_message() . "\n"); exit(1); }
|
||||||
|
update_post_meta($id, '_fea_book_index', (string) $idx);
|
||||||
|
update_post_meta($id, '_fea_cita', $entry['citation']);
|
||||||
|
update_post_meta($id, '_fea_fuente', 'A la fuente cada día — Fray Marcos, entrada #' . $idx);
|
||||||
|
}
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
printf("%s: existentes indexados=%d; nuevos=%d; ambiguos=%d; sin coincidencia=%d; duplicados=%d; total=%d\n",
|
||||||
|
$apply ? 'APLICADO' : 'DRY-RUN', $updated, $created, $ambiguous, $unmatched, $duplicate, count($book));
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Parse the InDesign HTML export of *A la fuente cada día*.
|
||||||
|
|
||||||
|
The output is deliberately source-only: it contains no civil-date mapping.
|
||||||
|
That mapping belongs in the separate, reviewable liturgical calendar JSON.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
TITLE_SOLEMNITY = "V-T-tulo-2-l-nea"
|
||||||
|
TITLE_OR_CITATION = "V-T-tulo-L-a-S"
|
||||||
|
GOSPEL = "Sangr-a-2-de-t--independiente"
|
||||||
|
MOTTO = "V-P-rrafo-2"
|
||||||
|
PARAGRAPH = "V-P-rrafo"
|
||||||
|
CITATION_RE = re.compile(r"\((?:Mt|Mc|Lc|Jn)\b[^)]*\)", re.IGNORECASE)
|
||||||
|
MANUAL_CITATIONS = {
|
||||||
|
# Three malformed/omitted references in the InDesign export, resolved from
|
||||||
|
# the printed Gospel text in the same source entry.
|
||||||
|
"Miércoles de la 32ª semana Lc 17,11-19)": "Lc 17,11-19",
|
||||||
|
"22 de Julio": "Jn 20,1-2.11-18",
|
||||||
|
"2 de noviembre": "Jn 11,21-44",
|
||||||
|
"DOMINGO IV DE ADVIENTO (C)": "Lc 1,39-45",
|
||||||
|
"Sábado de la 7ª semana (Mc 10 13-16)": "Mc 10,13-16",
|
||||||
|
"8 de diciembre": "Lc 1,26-38",
|
||||||
|
"1 de noviembre": "Mt 5,1-12a",
|
||||||
|
"DOMINGO DE RAMOS (A)": "Mt 26,14-27,66",
|
||||||
|
"DOMINGO DE RAMOS (B)": "Mc 14,1-15,47",
|
||||||
|
"DOMINGO DE RAMOS (C)": "Lc 22,14-23,56",
|
||||||
|
"VIERNES SANTO": "Jn 18,1-19,42",
|
||||||
|
"DOMINGO IV TIEMPO ORDINARIO (A)": "Mt 5,1-12a",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def text(fragment: str) -> str:
|
||||||
|
"""Return normalized text from the small, well-formed HTML fragments."""
|
||||||
|
fragment = re.sub(r"<[^>]+>", "", fragment)
|
||||||
|
return re.sub(r"\s+", " ", html.unescape(fragment).replace("\xa0", " ")).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def css_base(class_name: str) -> str:
|
||||||
|
return class_name.split(" ", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def is_citation(value: str) -> bool:
|
||||||
|
return bool(CITATION_RE.fullmatch(value.strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def clean_citation(value: str) -> str:
|
||||||
|
match = CITATION_RE.search(value)
|
||||||
|
return (match.group(0) if match else value).strip().removeprefix("(").removesuffix(")").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def new_entry(title: str, kind: str) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"source_index": 0,
|
||||||
|
"kind": kind,
|
||||||
|
"title": title,
|
||||||
|
"citation": "",
|
||||||
|
"gospel": "",
|
||||||
|
"motto": "",
|
||||||
|
"paragraphs": [],
|
||||||
|
"editorial_notes": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_citation_from(value: str, entry: dict[str, object]) -> None:
|
||||||
|
"""Capture a reference embedded in a weekday or feast-title line."""
|
||||||
|
if entry["citation"]:
|
||||||
|
return
|
||||||
|
match = CITATION_RE.search(value)
|
||||||
|
if match:
|
||||||
|
entry["citation"] = clean_citation(match.group(0))
|
||||||
|
elif "pasión según" in value.lower():
|
||||||
|
entry["citation"] = value.strip().removeprefix("(").removesuffix(")")
|
||||||
|
|
||||||
|
|
||||||
|
def parse(source: Path) -> list[dict[str, object]]:
|
||||||
|
raw = source.read_text(encoding="utf-8")
|
||||||
|
blocks = re.findall(r'<p class="([^"]+)"[^>]*>(.*?)</p>', raw, re.S)
|
||||||
|
entries: list[dict[str, object]] = []
|
||||||
|
current: dict[str, object] | None = None
|
||||||
|
|
||||||
|
def finish() -> None:
|
||||||
|
nonlocal current
|
||||||
|
if current is not None:
|
||||||
|
current["source_index"] = len(entries) + 1
|
||||||
|
entries.append(current)
|
||||||
|
current = None
|
||||||
|
|
||||||
|
for class_name, fragment in blocks:
|
||||||
|
role, value = css_base(class_name), text(fragment)
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == TITLE_SOLEMNITY:
|
||||||
|
finish()
|
||||||
|
current = new_entry(value, "solemnity")
|
||||||
|
set_citation_from(value, current)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == TITLE_OR_CITATION and not is_citation(value):
|
||||||
|
# This class is overloaded. After an already complete entry it is
|
||||||
|
# the next weekday title (and also the Holy Family title). Right
|
||||||
|
# after a large title it is instead a rubric such as "INMACULADA"
|
||||||
|
# or "Pasión según Mt", so it must remain part of that entry.
|
||||||
|
if current is None or current["motto"] or current["paragraphs"]:
|
||||||
|
finish()
|
||||||
|
current = new_entry(value, "weekday")
|
||||||
|
set_citation_from(value, current)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if current is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == TITLE_OR_CITATION and is_citation(value):
|
||||||
|
current["citation"] = clean_citation(value)
|
||||||
|
elif role == TITLE_OR_CITATION:
|
||||||
|
set_citation_from(value, current)
|
||||||
|
elif role == GOSPEL:
|
||||||
|
current["gospel"] = value
|
||||||
|
elif role == MOTTO:
|
||||||
|
current["motto"] = value
|
||||||
|
elif role == PARAGRAPH:
|
||||||
|
if not current["gospel"] and not current["motto"] and value.startswith("Si toca alguna semana anterior"):
|
||||||
|
current["editorial_notes"].append(value)
|
||||||
|
continue
|
||||||
|
# One source inconsistency (Easter VI, cycle A) styles the Gospel as
|
||||||
|
# a normal paragraph. Before the motto, that position is unambiguous.
|
||||||
|
if not current["gospel"] and not current["motto"]:
|
||||||
|
current["gospel"] = value
|
||||||
|
else:
|
||||||
|
current["paragraphs"].append(value)
|
||||||
|
|
||||||
|
finish()
|
||||||
|
for entry in entries:
|
||||||
|
if entry["title"] in MANUAL_CITATIONS:
|
||||||
|
entry["citation"] = MANUAL_CITATIONS[entry["title"]]
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def audit(entries: list[dict[str, object]]) -> list[str]:
|
||||||
|
issues: list[str] = []
|
||||||
|
for entry in entries:
|
||||||
|
missing = [field for field in ("citation", "gospel", "motto") if not entry[field]]
|
||||||
|
if not entry["paragraphs"]:
|
||||||
|
missing.append("paragraphs")
|
||||||
|
if missing:
|
||||||
|
issues.append(f"#{entry['source_index']} {entry['title']}: missing {', '.join(missing)}")
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("source", type=Path)
|
||||||
|
parser.add_argument("output", type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
entries = parse(args.source)
|
||||||
|
issues = audit(entries)
|
||||||
|
# The earlier exploratory JSON had 504 records, but four were artificial
|
||||||
|
# citation-only splits (Holy Family and Baptism A/B/C). The source has 500
|
||||||
|
# complete, independently publishable comments.
|
||||||
|
if len(entries) != 500:
|
||||||
|
raise SystemExit(f"expected 500 complete entries; got {len(entries)}")
|
||||||
|
if issues:
|
||||||
|
raise SystemExit("source audit failed:\n" + "\n".join(issues))
|
||||||
|
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.output.write_text(json.dumps(entries, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print(f"wrote {len(entries)} complete entries to {args.output}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+115
@@ -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. ==="
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ def _system_prompt(lang: str) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def translate_text(text: str, lang: str, *, is_title: bool = False, context: str = "") -> str:
|
def translate_text(text: str, lang: str, *, is_title: bool = False) -> str:
|
||||||
text = text.strip()
|
text = text.strip()
|
||||||
if not text:
|
if not text:
|
||||||
return ""
|
return ""
|
||||||
@@ -185,13 +185,6 @@ def translate_text(text: str, lang: str, *, is_title: bool = False, context: str
|
|||||||
return out
|
return out
|
||||||
user = f"<<<INI>>>{text}<<<FIN>>>"
|
user = f"<<<INI>>>{text}<<<FIN>>>"
|
||||||
if is_title:
|
if is_title:
|
||||||
context_block = ""
|
|
||||||
if context:
|
|
||||||
context_block = (
|
|
||||||
"\nBODY CONTEXT (for meaning only; do not translate or include it):\n"
|
|
||||||
f"<<<CONTEXT>>>{context}<<<ENDCONTEXT>>>\n"
|
|
||||||
)
|
|
||||||
user = context_block + user
|
|
||||||
kind = "el TÍTULO"
|
kind = "el TÍTULO"
|
||||||
task = (
|
task = (
|
||||||
f"Traduce {kind} que va entre las marcas.\n"
|
f"Traduce {kind} que va entre las marcas.\n"
|
||||||
@@ -199,7 +192,6 @@ def translate_text(text: str, lang: str, *, is_title: bool = False, context: str
|
|||||||
f"No lo dejes en inglés salvo que el original ya sea un nombre propio o una marca.\n"
|
f"No lo dejes en inglés salvo que el original ya sea un nombre propio o una marca.\n"
|
||||||
f"Responde solo con el título traducido entre las marcas:\n{user}"
|
f"Responde solo con el título traducido entre las marcas:\n{user}"
|
||||||
)
|
)
|
||||||
task += "\nSi es un modismo, interpreta su sentido segun el contexto, no lo calques."
|
|
||||||
else:
|
else:
|
||||||
kind = "el texto"
|
kind = "el texto"
|
||||||
task = f"Traduce {kind} que va entre las marcas:\n{user}"
|
task = f"Traduce {kind} que va entre las marcas:\n{user}"
|
||||||
@@ -234,13 +226,6 @@ def translate_html(html: str, lang: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
# ── Datos / estado ───────────────────────────────────────────────────────────
|
# ── Datos / estado ───────────────────────────────────────────────────────────
|
||||||
def title_context(html: str) -> str:
|
|
||||||
"""First paragraph gives title idioms enough meaning without bloating the prompt."""
|
|
||||||
first = re.search(r"<p\b[^>]*>.*?</p>", html, flags=re.IGNORECASE | re.DOTALL)
|
|
||||||
context = first.group(0) if first else html
|
|
||||||
return re.sub(r"\s+", " ", context).strip()[:2000]
|
|
||||||
|
|
||||||
|
|
||||||
def read_post(post_id: int) -> dict:
|
def read_post(post_id: int) -> dict:
|
||||||
return json.loads(php_helper("read", str(post_id)))
|
return json.loads(php_helper("read", str(post_id)))
|
||||||
|
|
||||||
@@ -305,8 +290,8 @@ def process_post(post_id: int, langs: list[str], status: str, force: bool, state
|
|||||||
log(f" {lang}: --force, eliminada traducción previa #{existing}")
|
log(f" {lang}: --force, eliminada traducción previa #{existing}")
|
||||||
try:
|
try:
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
|
title = translate_text(src["title"], lang, is_title=True)
|
||||||
content = translate_html(src["content"], lang)
|
content = translate_html(src["content"], lang)
|
||||||
title = translate_text(src["title"], lang, is_title=True, context=title_context(src["content"]))
|
|
||||||
new_id = create_translation(post_id, lang, title, content, status)
|
new_id = create_translation(post_id, lang, title, content, status)
|
||||||
dt = time.time() - t0
|
dt = time.time() - t0
|
||||||
log(f" {lang}: creado #{new_id} ({dt:.0f}s) → «{title[:50]}»")
|
log(f" {lang}: creado #{new_id} ({dt:.0f}s) → «{title[:50]}»")
|
||||||
|
|||||||
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+69
@@ -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,155 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Test de aceptación del calendario litúrgico 2026-2040 (issue #20).
|
||||||
|
|
||||||
|
Criterio acordado entre Codix/Opix/Claudix: cobertura bidireccional contra
|
||||||
|
las 500 entradas del libro, más un puñado de fechas móviles verificadas de
|
||||||
|
forma independiente (cómputo de Pascua propio, no la fuente del generador).
|
||||||
|
|
||||||
|
Uso: python3 tests/test_liturgical_calendar_coverage.py
|
||||||
|
Sale con exit code != 0 si algo falla.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
CAL_PATH = ROOT / "data/evangelio-diario/calendario-liturgico-2026-2040.json"
|
||||||
|
BOOK_PATH = ROOT / "data/evangelio-diario/entradas-libro.json"
|
||||||
|
|
||||||
|
|
||||||
|
def easter_sunday(year: int) -> date:
|
||||||
|
a = year % 19
|
||||||
|
b = year // 100
|
||||||
|
c = year % 100
|
||||||
|
d_ = b // 4
|
||||||
|
e = b % 4
|
||||||
|
f = (b + 8) // 25
|
||||||
|
g = (b - f + 1) // 3
|
||||||
|
h = (19 * a + b - d_ - g + 15) % 30
|
||||||
|
i = c // 4
|
||||||
|
k = c % 4
|
||||||
|
l = (32 + 2 * e + 2 * i - h - k) % 7
|
||||||
|
m = (a + 11 * h + 22 * l) // 451
|
||||||
|
month = (h + l - 7 * m + 114) // 31
|
||||||
|
day = ((h + l - 7 * m + 114) % 31) + 1
|
||||||
|
return date(year, month, day)
|
||||||
|
|
||||||
|
|
||||||
|
def fail(msg: str, errors: list[str]) -> None:
|
||||||
|
errors.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
errors: list[str] = []
|
||||||
|
cal = json.loads(CAL_PATH.read_text(encoding="utf-8"))
|
||||||
|
book = json.loads(BOOK_PATH.read_text(encoding="utf-8"))
|
||||||
|
dates = cal["dates"]
|
||||||
|
n_book = len(book)
|
||||||
|
|
||||||
|
# 1) toda fecha del rango existe y tiene book_index valido
|
||||||
|
d = date.fromisoformat(cal["range"]["from"])
|
||||||
|
end = date.fromisoformat(cal["range"]["to"])
|
||||||
|
n_dates = 0
|
||||||
|
while d <= end:
|
||||||
|
iso = d.isoformat()
|
||||||
|
if iso not in dates:
|
||||||
|
fail(f"fecha ausente: {iso}", errors)
|
||||||
|
else:
|
||||||
|
idx = dates[iso]["book_index"]
|
||||||
|
if not (1 <= idx <= n_book):
|
||||||
|
fail(f"{iso}: book_index fuera de rango: {idx}", errors)
|
||||||
|
n_dates += 1
|
||||||
|
d += timedelta(days=1)
|
||||||
|
|
||||||
|
# 2) toda entrada del libro es alcanzable en algun año del rango
|
||||||
|
used = {v["book_index"] for v in dates.values()}
|
||||||
|
never_used = sorted(set(range(1, n_book + 1)) - used)
|
||||||
|
# las 3 excepciones conocidas y documentadas en el propio JSON (ver "notes")
|
||||||
|
known_gaps = {34, 62, 134}
|
||||||
|
unexpected_gaps = [i for i in never_used if i not in known_gaps]
|
||||||
|
if unexpected_gaps:
|
||||||
|
fail(f"entradas del libro nunca alcanzadas (no documentadas): {unexpected_gaps}", errors)
|
||||||
|
|
||||||
|
# 3) fechas moviles clave, computadas de forma independiente (no depende
|
||||||
|
# del propio generador ni de su fuente CEE)
|
||||||
|
checks_ok = 0
|
||||||
|
for year in range(2026, 2041):
|
||||||
|
easter = easter_sunday(year)
|
||||||
|
expects = [
|
||||||
|
(easter, 199, "Pascua"),
|
||||||
|
(easter - timedelta(days=46), 141, "Ceniza"),
|
||||||
|
(easter + timedelta(days=49), 258, "Pentecostes"),
|
||||||
|
]
|
||||||
|
for dt, expected_idx, label in expects:
|
||||||
|
v = dates.get(dt.isoformat())
|
||||||
|
got = v["book_index"] if v else None
|
||||||
|
if got != expected_idx:
|
||||||
|
fail(f"{year} {label} {dt}: esperado idx={expected_idx}, obtenido={got}", errors)
|
||||||
|
else:
|
||||||
|
checks_ok += 1
|
||||||
|
palm = easter - timedelta(days=7)
|
||||||
|
v = dates.get(palm.isoformat())
|
||||||
|
if not (v and 190 <= v["book_index"] <= 192):
|
||||||
|
fail(f"{year} Ramos {palm}: obtenido={v}", errors)
|
||||||
|
else:
|
||||||
|
checks_ok += 1
|
||||||
|
trinity = easter + timedelta(days=56)
|
||||||
|
v = dates.get(trinity.isoformat())
|
||||||
|
if not (v and 259 <= v["book_index"] <= 261):
|
||||||
|
fail(f"{year} Trinidad {trinity}: obtenido={v}", errors)
|
||||||
|
else:
|
||||||
|
checks_ok += 1
|
||||||
|
|
||||||
|
# 4) solemnidades con clave propia, todos los años (no diluidas en un
|
||||||
|
# domingo/feria generico) -- el bug central del commit retirado 2713e13
|
||||||
|
for year in range(2026, 2041):
|
||||||
|
ep = date(year, 1, 6)
|
||||||
|
baptism = (ep + timedelta(days=1)) if ep.weekday() == 6 else (
|
||||||
|
ep + timedelta(days=(6 - ep.weekday()) % 7)
|
||||||
|
)
|
||||||
|
v = dates.get(baptism.isoformat())
|
||||||
|
if not (v and 54 <= v["book_index"] <= 56):
|
||||||
|
fail(f"{year} Bautismo {baptism}: sin clave propia ({v})", errors)
|
||||||
|
|
||||||
|
easter = easter_sunday(year)
|
||||||
|
ascension = easter + timedelta(days=42)
|
||||||
|
v = dates.get(ascension.isoformat())
|
||||||
|
if not (v and 249 <= v["book_index"] <= 251):
|
||||||
|
fail(f"{year} Ascension {ascension}: sin clave propia ({v})", errors)
|
||||||
|
|
||||||
|
corpus = easter + timedelta(days=63)
|
||||||
|
v = dates.get(corpus.isoformat())
|
||||||
|
if not (v and 262 <= v["book_index"] <= 264):
|
||||||
|
fail(f"{year} Corpus {corpus}: sin clave propia o no en domingo ({v})", errors)
|
||||||
|
|
||||||
|
years_with_ck = {k[:4] for k, v in dates.items() if 481 <= v["book_index"] <= 483}
|
||||||
|
if len(years_with_ck) != 15:
|
||||||
|
fail(f"Cristo Rey ausente en algun año: {sorted(years_with_ck)}", errors)
|
||||||
|
|
||||||
|
years_with_family = {k[:4] for k, v in dates.items() if v["book_index"] == 40}
|
||||||
|
if len(years_with_family) != 15:
|
||||||
|
fail(f"Sagrada Familia ausente en algun año: {sorted(years_with_family)}", errors)
|
||||||
|
|
||||||
|
# 5) Adviento no debe seguir contando tras Navidad (fuga del commit retirado)
|
||||||
|
leak = [k for k, v in dates.items()
|
||||||
|
if k[5:7] == "12" and int(k[8:10]) >= 26 and v["book_index"] <= 37]
|
||||||
|
if leak:
|
||||||
|
fail(f"fuga de Adviento tras Navidad: {leak[:10]}", errors)
|
||||||
|
|
||||||
|
print(f"fechas verificadas: {n_dates}")
|
||||||
|
print(f"book_index distintos usados: {len(used)}/{n_book}")
|
||||||
|
print(f"chequeos de fechas moviles ok: {checks_ok}")
|
||||||
|
if errors:
|
||||||
|
print(f"\nFALLOS ({len(errors)}):")
|
||||||
|
for e in errors:
|
||||||
|
print(" -", e)
|
||||||
|
return 1
|
||||||
|
print("\nTodo OK.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user