Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14b94be5ea | |||
| 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 |
|
||||||
|
|||||||
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.
@@ -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
|
||||||
|
});
|
||||||
@@ -924,58 +924,6 @@ function fea_is_front_page(): bool {
|
|||||||
return $id && in_array($id, fea_front_page_ids(), true);
|
return $id && in_array($id, fea_front_page_ids(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** URL de la búsqueda avanzada en el idioma actual, con fallback al español. */
|
|
||||||
function fea_home_advanced_search_url(): string {
|
|
||||||
$url = home_url('/buscar/');
|
|
||||||
if (!function_exists('pll_current_language') || !function_exists('pll_get_post')) return $url;
|
|
||||||
|
|
||||||
$lang = pll_current_language();
|
|
||||||
$default = function_exists('pll_default_language') ? pll_default_language() : 'es';
|
|
||||||
if (!$lang || $lang === $default) return $url;
|
|
||||||
|
|
||||||
$page = get_page_by_path('buscar');
|
|
||||||
if (!$page) return $url;
|
|
||||||
|
|
||||||
$translated_id = pll_get_post($page->ID, $lang);
|
|
||||||
return $translated_id ? get_permalink($translated_id) : $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
// En desktop, una lupa sin texto abre la búsqueda avanzada en vez de activar la
|
|
||||||
// validación nativa del campo requerido del bloque Search de WordPress.
|
|
||||||
add_action('wp_footer', function() {
|
|
||||||
if (!fea_is_front_page()) return;
|
|
||||||
$advanced_url = wp_json_encode(fea_home_advanced_search_url());
|
|
||||||
?>
|
|
||||||
<script>
|
|
||||||
(function() {
|
|
||||||
function configureDesktopSearch() {
|
|
||||||
if (!window.matchMedia('(min-width: 601px)').matches) return;
|
|
||||||
|
|
||||||
document.querySelectorAll('form.wp-block-search, .wp-block-search form').forEach(function(form) {
|
|
||||||
var input = form.querySelector('input[type="search"], input[name="s"]');
|
|
||||||
if (!input || form.dataset.feaEmptySearchReady) return;
|
|
||||||
|
|
||||||
form.dataset.feaEmptySearchReady = 'true';
|
|
||||||
form.noValidate = true;
|
|
||||||
input.removeAttribute('required');
|
|
||||||
form.addEventListener('submit', function(event) {
|
|
||||||
if (input.value.trim() !== '') return;
|
|
||||||
event.preventDefault();
|
|
||||||
window.location.assign(<?php echo $advanced_url; ?>);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', configureDesktopSearch);
|
|
||||||
} else {
|
|
||||||
configureDesktopSearch();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<?php
|
|
||||||
});
|
|
||||||
|
|
||||||
/** True para la landing de Escuela, que no muestra título visible. */
|
/** True para la landing de Escuela, que no muestra título visible. */
|
||||||
function fea_is_escuela_page(): bool {
|
function fea_is_escuela_page(): bool {
|
||||||
if (!is_page()) return false;
|
if (!is_page()) return false;
|
||||||
@@ -2096,8 +2044,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;
|
||||||
|
|
||||||
@@ -2118,23 +2070,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,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,182 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Parse the InDesign HTML export of *A la fuente cada día*.
|
||||||
|
|
||||||
|
The output is deliberately source-only: it contains no civil-date mapping.
|
||||||
|
That mapping belongs in the separate, reviewable liturgical calendar JSON.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
TITLE_SOLEMNITY = "V-T-tulo-2-l-nea"
|
||||||
|
TITLE_OR_CITATION = "V-T-tulo-L-a-S"
|
||||||
|
GOSPEL = "Sangr-a-2-de-t--independiente"
|
||||||
|
MOTTO = "V-P-rrafo-2"
|
||||||
|
PARAGRAPH = "V-P-rrafo"
|
||||||
|
CITATION_RE = re.compile(r"\((?:Mt|Mc|Lc|Jn)\b[^)]*\)", re.IGNORECASE)
|
||||||
|
MANUAL_CITATIONS = {
|
||||||
|
# Three malformed/omitted references in the InDesign export, resolved from
|
||||||
|
# the printed Gospel text in the same source entry.
|
||||||
|
"Miércoles de la 32ª semana Lc 17,11-19)": "Lc 17,11-19",
|
||||||
|
"22 de Julio": "Jn 20,1-2.11-18",
|
||||||
|
"2 de noviembre": "Jn 11,21-44",
|
||||||
|
"DOMINGO IV DE ADVIENTO (C)": "Lc 1,39-45",
|
||||||
|
"Sábado de la 7ª semana (Mc 10 13-16)": "Mc 10,13-16",
|
||||||
|
"8 de diciembre": "Lc 1,26-38",
|
||||||
|
"1 de noviembre": "Mt 5,1-12a",
|
||||||
|
"DOMINGO DE RAMOS (A)": "Mt 26,14-27,66",
|
||||||
|
"DOMINGO DE RAMOS (B)": "Mc 14,1-15,47",
|
||||||
|
"DOMINGO DE RAMOS (C)": "Lc 22,14-23,56",
|
||||||
|
"VIERNES SANTO": "Jn 18,1-19,42",
|
||||||
|
"DOMINGO IV TIEMPO ORDINARIO (A)": "Mt 5,1-12a",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def text(fragment: str) -> str:
|
||||||
|
"""Return normalized text from the small, well-formed HTML fragments."""
|
||||||
|
fragment = re.sub(r"<[^>]+>", "", fragment)
|
||||||
|
return re.sub(r"\s+", " ", html.unescape(fragment).replace("\xa0", " ")).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def css_base(class_name: str) -> str:
|
||||||
|
return class_name.split(" ", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def is_citation(value: str) -> bool:
|
||||||
|
return bool(CITATION_RE.fullmatch(value.strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def clean_citation(value: str) -> str:
|
||||||
|
match = CITATION_RE.search(value)
|
||||||
|
return (match.group(0) if match else value).strip().removeprefix("(").removesuffix(")").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def new_entry(title: str, kind: str) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"source_index": 0,
|
||||||
|
"kind": kind,
|
||||||
|
"title": title,
|
||||||
|
"citation": "",
|
||||||
|
"gospel": "",
|
||||||
|
"motto": "",
|
||||||
|
"paragraphs": [],
|
||||||
|
"editorial_notes": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_citation_from(value: str, entry: dict[str, object]) -> None:
|
||||||
|
"""Capture a reference embedded in a weekday or feast-title line."""
|
||||||
|
if entry["citation"]:
|
||||||
|
return
|
||||||
|
match = CITATION_RE.search(value)
|
||||||
|
if match:
|
||||||
|
entry["citation"] = clean_citation(match.group(0))
|
||||||
|
elif "pasión según" in value.lower():
|
||||||
|
entry["citation"] = value.strip().removeprefix("(").removesuffix(")")
|
||||||
|
|
||||||
|
|
||||||
|
def parse(source: Path) -> list[dict[str, object]]:
|
||||||
|
raw = source.read_text(encoding="utf-8")
|
||||||
|
blocks = re.findall(r'<p class="([^"]+)"[^>]*>(.*?)</p>', raw, re.S)
|
||||||
|
entries: list[dict[str, object]] = []
|
||||||
|
current: dict[str, object] | None = None
|
||||||
|
|
||||||
|
def finish() -> None:
|
||||||
|
nonlocal current
|
||||||
|
if current is not None:
|
||||||
|
current["source_index"] = len(entries) + 1
|
||||||
|
entries.append(current)
|
||||||
|
current = None
|
||||||
|
|
||||||
|
for class_name, fragment in blocks:
|
||||||
|
role, value = css_base(class_name), text(fragment)
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == TITLE_SOLEMNITY:
|
||||||
|
finish()
|
||||||
|
current = new_entry(value, "solemnity")
|
||||||
|
set_citation_from(value, current)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == TITLE_OR_CITATION and not is_citation(value):
|
||||||
|
# This class is overloaded. After an already complete entry it is
|
||||||
|
# the next weekday title (and also the Holy Family title). Right
|
||||||
|
# after a large title it is instead a rubric such as "INMACULADA"
|
||||||
|
# or "Pasión según Mt", so it must remain part of that entry.
|
||||||
|
if current is None or current["motto"] or current["paragraphs"]:
|
||||||
|
finish()
|
||||||
|
current = new_entry(value, "weekday")
|
||||||
|
set_citation_from(value, current)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if current is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == TITLE_OR_CITATION and is_citation(value):
|
||||||
|
current["citation"] = clean_citation(value)
|
||||||
|
elif role == TITLE_OR_CITATION:
|
||||||
|
set_citation_from(value, current)
|
||||||
|
elif role == GOSPEL:
|
||||||
|
current["gospel"] = value
|
||||||
|
elif role == MOTTO:
|
||||||
|
current["motto"] = value
|
||||||
|
elif role == PARAGRAPH:
|
||||||
|
if not current["gospel"] and not current["motto"] and value.startswith("Si toca alguna semana anterior"):
|
||||||
|
current["editorial_notes"].append(value)
|
||||||
|
continue
|
||||||
|
# One source inconsistency (Easter VI, cycle A) styles the Gospel as
|
||||||
|
# a normal paragraph. Before the motto, that position is unambiguous.
|
||||||
|
if not current["gospel"] and not current["motto"]:
|
||||||
|
current["gospel"] = value
|
||||||
|
else:
|
||||||
|
current["paragraphs"].append(value)
|
||||||
|
|
||||||
|
finish()
|
||||||
|
for entry in entries:
|
||||||
|
if entry["title"] in MANUAL_CITATIONS:
|
||||||
|
entry["citation"] = MANUAL_CITATIONS[entry["title"]]
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def audit(entries: list[dict[str, object]]) -> list[str]:
|
||||||
|
issues: list[str] = []
|
||||||
|
for entry in entries:
|
||||||
|
missing = [field for field in ("citation", "gospel", "motto") if not entry[field]]
|
||||||
|
if not entry["paragraphs"]:
|
||||||
|
missing.append("paragraphs")
|
||||||
|
if missing:
|
||||||
|
issues.append(f"#{entry['source_index']} {entry['title']}: missing {', '.join(missing)}")
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("source", type=Path)
|
||||||
|
parser.add_argument("output", type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
entries = parse(args.source)
|
||||||
|
issues = audit(entries)
|
||||||
|
# The earlier exploratory JSON had 504 records, but four were artificial
|
||||||
|
# citation-only splits (Holy Family and Baptism A/B/C). The source has 500
|
||||||
|
# complete, independently publishable comments.
|
||||||
|
if len(entries) != 500:
|
||||||
|
raise SystemExit(f"expected 500 complete entries; got {len(entries)}")
|
||||||
|
if issues:
|
||||||
|
raise SystemExit("source audit failed:\n" + "\n".join(issues))
|
||||||
|
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.output.write_text(json.dumps(entries, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print(f"wrote {len(entries)} complete entries to {args.output}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,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