Compare commits

..

23 Commits

Author SHA1 Message Date
rafa 37dd1153a8 fix(tts): sync_audio_to_prod.py respeta fea_audio_skip (issue #190)
sync_one() solo comprobaba fea_audio_done, ignorando fea_audio_skip. El
guard de #190 se anadio a tts_produce.py (generacion) pero nunca a este
script (subida a prod), asi que cualquier post con audio local obsoleto
de antes del hotfix (ej. #18036, pagina de cuentas/donaciones) volvia a
publicarse en cuanto entrara en el cluster de una carta. Paso hoy con la
carta 741: sync_audio_to_prod.py --carta 56290 restauro el mp3 de 3h27
en produccion. Revertido manualmente; este commit cierra el hueco.

Tambien documenta el alcance correcto del TTS (solo ES, un mp3 por
articulo, nunca multimedia) en la guia de Inma.

Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-26 14:16:07 -04:00
rafa 1333a2bb64 Merge pull request 'sync_audio_bulk.sh: lote de sincronizacion de audio TTS con parada en primer fallo' (#215) from feat/issue-sync-bulk-script into main 2026-08-24 19:28:21 +00:00
rafa 654c3d3c35 sync_audio_to_prod.py: local_meta() propaga fallos de docker exec
Mismo patrón que carta_article_ids() (commit anterior): si el docker exec
contra el contenedor WP local fallaba (contenedor caído/inexistente),
local_meta() devolvía stdout vacío en vez de comprobar returncode.
sync_one() interpretaba la cadena vacía como "sin-audio-local" (skip), no
como error, así que un lote entero podía acabar "ok=0/skip=N" tras perder
Docker en vez de abortar.

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

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

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

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

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

Documentado en README con ejemplo de uso y variables de entorno.
2026-08-24 15:20:27 -04:00
rafa 2260343ec4 Merge pull request 'Verify complete audio assets before browser playback' (#213) from feat/accesibilidad-196 into main 2026-08-21 11:10:49 +00:00
inma bbae0571ae fixtures: .doc originales de Fray Marcos (#209)
Los 30 .doc tal cual llegan por correo, de la carta 713 a la 740, para poder
reproducir el borrado silencioso por <w:smartTag> y diffear contra lo publicado.
Pedidos por Rafa en el #209, donde el barrido quedó bloqueado sin ellos.
2026-08-19 09:06:08 +00:00
inma a806cf9cae doc_convert.py: conversor .doc->.docx de Fray Marcos, con el arreglo de los smartTag (#209) 2026-08-18 09:24:55 +00:00
rafa 696934016f Merge pull request 'Backfill evangelio: modo apply explícito vía variable de entorno' (#208) from fix/backfill-apply-mode into main 2026-08-12 13:57:12 +00:00
rafa 06d7057e76 fix: support explicit backfill apply mode
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-12 09:56:10 -04:00
rafa ef2978fc21 Merge pull request 'Mostrar el evangelio también en Comentario editorial (no solo Comentarios al evangelio)' (#207) from feat/evangelio-editorial into main 2026-08-12 13:53:41 +00:00
rafa 587b10c2b3 Merge pull request 'Modo oscuro: toggle duplicado, evangelio, navegación, visited, colaboradores' (#206) from fix/theme-button-duplicate into main 2026-08-12 13:53:37 +00:00
rafa 6b966a3b5f fix: style contributor cards in dark mode
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-12 09:33:58 -04:00
rafa 754915b91f feat: show gospel in editorial comments
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-12 09:30:43 -04:00
rafa 75c0f07eab fix: keep visited links readable in dark mode
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-12 09:20:34 -04:00
rafa f0946be1fc fix: cover navigation and gallery dark states
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-12 09:16:43 -04:00
rafa d836297c89 fix: cover editorial components in dark mode
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-12 09:15:47 -04:00
rafa d9ff5e891c fix: prevent duplicate theme toggle
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-12 09:04:15 -04:00
rafa 5cf588ffe3 Merge pull request 'Accesibilidad: Atkinson Hyperlegible + modo oscuro (#196)' (#205) from fix/dark-article-menu into main 2026-08-12 12:47:23 +00:00
rafa a734ffc933 Merge pull request 'Comentarios al evangelio: cita + pasaje plegable (#199)' (#201) from feat/issue-199-evangelio into main 2026-08-12 12:47:09 +00:00
rafa b3a2ae682b fix: style author archives in dark mode
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-12 07:05:57 -04:00
rafa 825434a0b7 fix: preserve dark menu background in articles
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-12 06:11:06 -04:00
rafa 908188ddf2 Añadir evangelio plegable a comentarios
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-11 07:48:28 -04:00
41 changed files with 663 additions and 20 deletions
+32
View File
@@ -32,8 +32,40 @@ scripts/ — scripts Python/PHP de migración, traducción, TTS y mantenimi
| `translate_haiku.py` | Traducción automática vía Claude Haiku |
| `tts_produce.py` | Generación de audio TTS (MiniMax) |
| `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 |
## 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
WordPress corre en Docker local, accesible vía Tailscale:
+9
View File
@@ -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}` |
| 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`),
`date` (ISO 8601 para programar), `categories` (array de IDs), `featured_media` (ID de adjunto).
+9
View File
@@ -87,6 +87,15 @@ FEA_TTS_CARTAS="<ID_CARTA>" python3 scripts/tts_produce.py
Reanudable (no repite lo ya hecho, meta `fea_audio_done`) y con freno automático si la cuota de
MiniMax se agota (para tras fallos seguidos, no se queda colgado).
**Alcance del TTS — qué NO llevar a audio (confusión real en la carta 741, 2026-08-26):**
- **Solo el artículo original en español.** Las traducciones (EN/FR/IT/PT) nunca llevan audio
propio — no meter los posts de traducción en la cola (`FEA_TTS_IDS`/`FEA_TTS_CARTAS`).
- **Un audio por artículo, nunca uno combinado de toda la carta.** `tts_produce.py` es correcto
(genera un mp3 por post). No usar `scripts/tts_carta.py` (legacy, concatena todos los artículos
de la carta en un único audio) — no es el flujo que se usa hoy.
- **Nunca la categoría `multimedia`** (término `multimedia`, id 1649 — ver
`scripts/aplicar_clasificacion_a_bd.py`). Esas piezas no se locutan.
**Publicar el audio en prod** (el paso anterior solo escribe en el WordPress local):
```bash
python3 scripts/sync_audio_to_prod.py --carta <ID_CARTA>
+140 -1
View File
@@ -98,7 +98,15 @@ add_action('wp_head', function () {
background-color: #0d0d0d !important;
color: #ececec !important;
}
html[data-fea-theme="dark"] a { color: <?php echo esc_html($accent_dark); ?>; }
/* 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;
@@ -161,6 +169,133 @@ add_action('wp_head', function () {
}
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,
@@ -217,6 +352,10 @@ add_action('wp_footer', function () {
?>
<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';
+163
View File
@@ -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
});
+6 -19
View File
@@ -2044,8 +2044,12 @@ add_filter('the_content', function($content) {
global $post;
if (!in_category('comentarios-al-evangelio', $post)) return $content;
$carta_id = get_post_meta($post->ID, '_carta_id', true);
$cita = get_post_meta($post->ID, '_cita_evangelio', true);
$carta_id = function_exists('fea_evangelio_comment_meta')
? 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;
@@ -2066,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 ---
if ($cita) {
$others = get_posts([
+30
View File
@@ -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]);
}
+79
View File
@@ -0,0 +1,79 @@
"""doc_convert.py — convierte un .doc viejo a .docx usando Microsoft Word (COM).
Necesario para Marcos (marroro@telefonica.net), que envía .doc (Word antiguo) y
python-docx no sabe leer .doc. El contenido ya viene en EML (lo etiqueta Inma al
revisar); aquí solo cambiamos el FORMATO de archivo.
Requiere Microsoft Word instalado + pywin32 (igual que marcos_review).
API: doc_to_docx(path, force=False) -> Path | None
"""
from __future__ import annotations
from pathlib import Path
WD_FORMAT_DOCX = 16 # wdFormatDocumentDefault (.docx)
def doc_to_docx(path, force: bool = False):
"""Convierte un .doc a .docx con Word COM. Idempotente: si el .docx ya existe y
no force, lo devuelve sin reconvertir. Devuelve la ruta .docx, o None si falla."""
path = Path(path)
if path.suffix.lower() != ".doc":
return None
out = path.with_suffix(".docx")
if out.exists() and not force:
return out
try:
import win32com.client # type: ignore
except ImportError:
return None
word = win32com.client.Dispatch("Word.Application")
word.Visible = False
try:
d = word.Documents.Open(str(path))
d.SaveAs(str(out), FileFormat=WD_FORMAT_DOCX)
d.Close(SaveChanges=False)
finally:
word.Quit()
if out.exists():
quitar_smarttags(out)
return out if out.exists() else None
def quitar_smarttags(docx_path) -> int:
"""Desenvuelve los <w:smartTag> del .docx. Devuelve cuántos ha quitado.
⚠️ CRÍTICO, no es cosmético. Los .doc de Fray Marcos traen `smartTag` (el viejo
reconocimiento de nombres de Word) envolviendo expresiones como "la Iglesia" o
"la Cananea". **python-docx solo lee los <w:r> que cuelgan directamente del párrafo,
así que el texto de dentro del smartTag DESAPARECE sin dar error**: se publicaron
frases mutiladas en las cartas 736, 737 y 739 ("...no va dirigida a los apóstoles,
sino a dureza de la respuesta..."). Detectado el 12-ago-2026.
"""
import re, shutil, zipfile
from pathlib import Path
docx_path = Path(docx_path)
with zipfile.ZipFile(docx_path) as z:
piezas = {n: z.read(n) for n in z.namelist()}
xml = piezas.get("word/document.xml", b"").decode("utf-8", "replace")
nuevo, n = re.subn(r"</?w:smartTag(?:Pr)?\b[^>]*>|<w:attr\b[^>]*/>", "", xml)
if not n:
return 0
piezas["word/document.xml"] = nuevo.encode("utf-8")
tmp = docx_path.with_suffix(".docx.tmp")
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as z:
for nombre, datos in piezas.items():
z.writestr(nombre, datos)
shutil.move(str(tmp), str(docx_path))
return n
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("uso: python doc_convert.py path/al/archivo.doc", file=sys.stderr)
sys.exit(2)
res = doc_to_docx(Path(sys.argv[1]), force="--force" in sys.argv)
print(f"OK: {res}" if res else "FALLO (¿Word/pywin32?)")
sys.exit(0 if res else 1)
+115
View File
@@ -0,0 +1,115 @@
#!/bin/bash
# sync_audio_bulk.sh — Sincroniza a producción, uno por uno, un lote de posts con
# audio TTS ya generado en local. Envoltorio de sync_audio_to_prod.py que añade
# "parar en el primer fallo" (el script Python no lo hace: recorre toda la
# lista y acumula errores, ver #212/#213).
#
# Motivo del wrapper en bash con mapfile: un `while read id; do ...; done <
# fichero` normal comparte el file descriptor de stdin con cualquier `ssh`
# lanzado dentro del bucle. sync_audio_to_prod.py llama a ssh internamente; si
# el bucle no está protegido, ssh se come el resto del fichero de IDs y el
# bucle termina "sin error" tras procesar solo el primero. Detectado y
# documentado en memoria (feedback-bash-while-read-ssh-stdin) el 2026-08-24
# tras un lote real que solo sincronizó 1 de 184 posts sin ningún error visible.
#
# Uso:
# scripts/sync_audio_bulk.sh --ids 55519,55520,822
# scripts/sync_audio_bulk.sh --file /tmp/ids.txt # un ID por línea
# scripts/sync_audio_bulk.sh --carta 54254 # cola completa de la carta
# scripts/sync_audio_bulk.sh --ids 55519,55520 --dry-run # solo plan, no toca prod
#
# Requiere las mismas variables de entorno que sync_audio_to_prod.py para
# apuntar a producción (ver ese script): FEA_PROD_SSH_HOST,
# FEA_PROD_DOCKER_CONTAINER. Ejemplo:
# export FEA_PROD_SSH_HOST="rafa@188.40.120.157"
# export FEA_PROD_DOCKER_CONTAINER="wordpress-r2ssjifwj0r0ghyoqd528uaa"
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SYNC_PY="$SCRIPT_DIR/sync_audio_to_prod.py"
usage() {
echo "Uso: $0 (--ids id1,id2,... | --file ruta | --carta post_id) [--dry-run]" >&2
exit 2
}
ids_csv=""
ids_file=""
carta=""
dry_run=""
while [ $# -gt 0 ]; do
case "$1" in
--ids) ids_csv="$2"; shift 2 ;;
--file) ids_file="$2"; shift 2 ;;
--carta) carta="$2"; shift 2 ;;
--dry-run) dry_run="--dry-run"; shift ;;
*) usage ;;
esac
done
# Carga explícita de IDs en un array ANTES de cualquier llamada a ssh — es la
# parte que evita el bug de stdin. No usar `while read ... < fichero`.
ids=()
if [ -n "$ids_csv" ]; then
IFS=',' read -r -a ids <<< "$ids_csv"
elif [ -n "$ids_file" ]; then
mapfile -t ids < "$ids_file"
elif [ -n "$carta" ]; then
# No usar `mapfile < <(cmd)`: la sustitución de procesos no deja comprobar
# de forma fiable el exit code de `cmd` (mapfile ve su propio $?, no el de
# la tubería). Si la consulta falla, un array vacío se confundiría con
# "la carta no tiene artículos" y el lote saldría "éxito" sin sincronizar
# nada. Se resuelve a fichero temporal y se comprueba el rc de python
# explícitamente antes de poblar el array.
carta_tmpfile="$(mktemp)"
python3 - "$carta" "$SCRIPT_DIR" > "$carta_tmpfile" <<'PYEOF'
import sys
sys.path.insert(0, sys.argv[2])
from sync_audio_to_prod import carta_article_ids
for pid in carta_article_ids(int(sys.argv[1])):
print(pid)
PYEOF
py_rc=$?
if [ $py_rc -ne 0 ]; then
echo "ERROR: fallo consultando la cola de la carta $carta (exit $py_rc), no se sincroniza nada." >&2
rm -f "$carta_tmpfile"
exit 1
fi
mapfile -t ids < "$carta_tmpfile"
rm -f "$carta_tmpfile"
else
usage
fi
total=${#ids[@]}
if [ "$total" -eq 0 ]; then
echo "Nada que sincronizar (lista de IDs vacía)." >&2
exit 0
fi
ok=0
skip=0
n=0
for id in "${ids[@]}"; do
id="$(echo "$id" | tr -d '[:space:]')"
[ -z "$id" ] && continue
n=$((n + 1))
# stdin del hijo explícitamente en /dev/null: cinturón y tirantes además del
# array en memoria, por si algún día se cambia el bucle.
out="$(python3 "$SYNC_PY" --ids "$id" $dry_run < /dev/null)"
rc=$?
echo "$out"
if [ $rc -ne 0 ]; then
echo "=== ABORT: fallo en #$id ($n/$total procesados, $ok ok). Deteniendo lote, sin reintentar. ==="
exit 1
fi
if echo "$out" | grep -q "PLAN: subiría\|#$id: ok"; then
ok=$((ok + 1))
else
skip=$((skip + 1))
fi
done
echo "=== FIN LOTE. ok=$ok skip=$skip de $total candidatos. ==="
+11
View File
@@ -75,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],
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()
@@ -86,6 +88,8 @@ def carta_article_ids(carta_id: int) -> list[int]:
DB_NAME, "-N", "-e", q],
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()]
@@ -183,6 +187,13 @@ def save_state(state: dict) -> None:
# ── Sync ─────────────────────────────────────────────────────────────────────
def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
# issue #190: páginas de cuentas/donaciones (ej. #18036 "numeros") llevan
# fea_audio_skip=1 desde el hotfix del 3-ago, pero pueden conservar un
# fea_audio_done=1 y un mp3 locales obsoletos de antes del guard. Sin este
# chequeo, cualquier --carta cuyo cluster las incluya vuelve a publicar el
# audio improcedente en prod (nos pasó el 26-ago con la carta 741).
if local_meta(post_id, "fea_audio_skip") == "1":
return "excluido (fea_audio_skip)"
if local_meta(post_id, "fea_audio_done") != "1":
return "sin-audio-local"
if not (LOCAL_TTS_DIR / f"{post_id}.mp3").exists():
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+69
View File
@@ -0,0 +1,69 @@
# `.doc` originales de Fray Marcos
Los 30 ficheros `.doc` (Word binario antiguo) tal y como los envía Fray Marcos por correo a
`ediciones@feadulta.com`, sin tocar. Subidos a petición de Rafa en el **issue #209**, para poder
reproducir y diffear desde este lado el problema de los `<w:smartTag>`.
## Por qué están aquí
`python-docx` **no lee el texto que va dentro de un `<w:smartTag>`**: lo salta sin dar error. Los `.doc`
de Fray Marcos arrastran esas etiquetas heredadas de versiones viejas de Word envolviendo expresiones
como «la Iglesia» o «la Cananea», así que al convertirlos se publicaban frases con palabras borradas en
silencio. Está corregido en `scripts/doc_convert.py` con `quitar_smarttags()`.
Ojo: la cadena `smartTag` **no aparece en el binario `.doc`**; surge al convertirlo a `.docx`, que es
formato OOXML. Para verlas hay que convertir primero.
## Cómo reproducir
```bash
python scripts/doc_convert.py "tests/fixtures/fray-marcos/739_8r_Marcos_d 20 la Cananea.doc"
```
Requiere Word instalado (la conversión va por COM). `doc_convert.py` desenvuelve los smartTag y hace el
reemplazo con reintentos, porque Dropbox bloquea el fichero unos segundos justo después de que Word lo
cree (WinError 1224).
## Alcance del problema (barrido cerrado en el #209)
De los 30 `.doc` revisados, 20 traían smartTag y sumaban 46 fragmentos envueltos, pero solo **3
artículos publicados quedaron dañados** (posts `53981`, `55078` y `55659`), ya reparados en producción.
Los comentarios de febrero a junio están intactos porque se publicaron por la vía de la migración de
Joomla, no desde el `.docx`; solo quedó expuesto lo publicado por Mixbot desde julio.
## Inventario
El prefijo numérico es el número de carta; el resto es el nombre original del fichero.
| carta | semana | fichero original |
|---:|---|---|
| 713 | semana_VI_TO | `713_8r_Marcos_d 06 plenitud de ley.doc` |
| 714 | semana_I_Cuaresma | `714_8r_Marcos_Cuar 1 tentaciones.doc` |
| 715 | semana_II_Cuaresma | `715_8r_Marcos_Cuar 2 Transfigurac.doc` |
| 716 | semana_III_Cuaresma | `716_8r_Marcos_Cuar 3 Samaritana.doc` |
| 717 | semana_IV_Cuaresma | `717_8r_Marcos_Cuar 4 ciego de naci.doc` |
| 718 | semana_V_Cuaresma | `718_8r_Marcos_Cuar 5 Lázaro.doc` |
| 719 | semana_Domingo_Ramos | `719_8r_Marcos_Cuar 6 Ramos.doc` |
| 720 | semana_Santa | `720_8r_Marcos_JUEVES SANTO.doc` |
| 720 | semana_Santa | `720_8r_Marcos_Pasc 1 resucitó.doc` |
| 720 | semana_Santa | `720_8r_Marcos_VIERNES SANTO.doc` |
| 721 | semana_II_Pascua | `721_8r_Marcos_Pasc 2 Tomás.doc` |
| 722 | semana_III_Pascua | `722_8r_Marcos_Pasc 3 Emaús.doc` |
| 723 | semana_IV_Pascua | `723_8r_Marcos_Pasc 4 pastor modelo.doc` |
| 724 | semana_V_Pascua | `724_8r_Marcos_Pasc 5 camino,verdad,vida.doc` |
| 725 | semana_VI_Pascua | `725_8r_Marcos_Pasc 6 El Paráclito.doc` |
| 726 | semana_VII_Pascua | `726_8r_Marcos_Pasc 7 ascensión.doc` |
| 727 | semana_Pentecostes | `727_8r_Marcos_PENTECOSTÉS.doc` |
| 728 | semana_Trinidad | `728_8r_Marcos_TRINIDAD.doc` |
| 729 | semana_Corpus | `729_8r_Marcos_CORPUS.doc` |
| 730 | semana_11_TO | `730_8r_Marcos_d 11 misión de los 12.doc` |
| 731 | semana_12_TO | `731_8r_Marcos_d 12 no tengas miedo.doc` |
| 732 | semana_13_TO | `732_8r_Marcos_d 13 amor a Dios o família.doc` |
| 733 | semana_14_TO | `733_8r_Marcos_d 14 sencillez.doc` |
| 734 | semana_15_TO | `734_8r_Marcos_d 15 sembrador.doc` |
| 735 | semana_16_TO | `735_8r_Marcos_d 16 cizaña.doc` |
| 736 | semana_17_TO | `736_8r_Marcos_d 17 tesoro y perla.doc` |
| 737 | semana_18_TO | `737_8r_Marcos_d 18 panes y peces.doc` |
| 738 | semana_19_TO | `738_8r_Marcos_d 19 Qué poca fe.doc` |
| 739 | semana_20_TO | `739_8r_Marcos_d 20 la Cananea.doc` |
| 740 | semana_21_TO | `740_8r_Marcos_d 21 quién es Jesús.doc` |