Compare commits

..

27 Commits

Author SHA1 Message Date
rafa 2260343ec4 Merge pull request 'Verify complete audio assets before browser playback' (#213) from feat/accesibilidad-196 into main 2026-08-21 11:10:49 +00:00
rafa bc3af3cbb9 Verify complete audio assets before browser playback 2026-08-20 11:50:00 -04:00
inma bbae0571ae fixtures: .doc originales de Fray Marcos (#209)
Los 30 .doc tal cual llegan por correo, de la carta 713 a la 740, para poder
reproducir el borrado silencioso por <w:smartTag> y diffear contra lo publicado.
Pedidos por Rafa en el #209, donde el barrido quedó bloqueado sin ellos.
2026-08-19 09:06:08 +00:00
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
rafa 68b95aae89 fix: extend accessible typography
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-09 10:50:56 -04:00
rafa 82c352aa15 fix: refine dark search states
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-09 01:01:26 -04:00
rafa 51ae723c5a fix: correct dark mode search and slider
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-09 00:39:32 -04:00
rafa 7a623e8bb9 accesibilidad: Atkinson Hyperlegible + toggle claro/oscuro (#196)
Mu-plugin fea-accesibilidad.php:
- Atkinson Hyperlegible (OFL) self-hosted, subset latin regular+bold,
  sustituye la tipografia global del cuerpo sin selector.
- Toggle claro/oscuro persistente (localStorage), anti-flash en wp_head,
  boton inyectado junto al selector de idioma en el header.
- color-scheme: dark para controles nativos (audio, inputs) + overrides
  puntuales donde el sitio fija colores claros a mano (hero, tarjetas,
  selector de idioma, buscador movil, beta feedback, cookie consent).
- Acento mas claro (#e8899e) solo en modo oscuro: el carmesi de marca
  (#8b1a2e) no llega a 2:1 de contraste sobre el fondo casi negro.

QA local (docker wordpress-web, capturas Playwright en tools/e2e/out/):
portada, tarjetas y single verificados en claro y oscuro.
2026-08-09 00:15:45 -04:00
rafa 5055e92143 Rescatar 10 docs/ del main local que nunca llegaron a Gitea (issue #194)
main local y origin/main son dos historias sin ancestro comun (ver #194).
Analisis del agente: de las 46 commits solo-en-local, el unico contenido que
no esta ya reflejado en origin/main via el snapshot de julio (PR #179) son
estos 10 ficheros de docs/ (guias, handoffs, planes, benchmark, revisiones de
issues). Copiados tal cual desde main con "git show main:docs/X > docs/X".

Redactada una password SSH y una password cPanel/FTP en texto plano que
tenia handoff-carta-46956-2026-06-17.md (servidor CDMON antiguo) antes de
subirlo -- ver .env, nunca en docs versionados.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 08:39:06 -04:00
rafa 638f5b70c7 Apuntar sync_translations/audio/carta_from_prod.py al Hetzner nuevo
Los tres scripts asumian el CDMON viejo (134.0.10.170, sin Docker): host y
password hardcodeados, y el helper PHP ejecutandose directamente en el host.
Desde el cutover a Hetzner (Coolify/Docker, 2026-08-03) eso ya no aplica.

- FEA_PROD_HOST/FEA_PROD_PASS pasan a FEA_PROD_SSH_HOST/FEA_PROD_SSH_PASS
  (mismo nombre en los tres scripts; sync_carta_from_prod.py ya los usaba).
- Nueva FEA_PROD_DOCKER_CONTAINER: si esta definida, el helper se sube y se
  ejecuta con "docker exec -i" dentro del contenedor en vez de en el host.
- Auth por clave (ed25519 claude-code@feadulta): sshpass solo se usa si
  FEA_PROD_SSH_PASS no esta vacia.
- Fix del gotcha de "docker exec CONTENEDOR cmd < ruta": esa redireccion la
  resuelve el HOST, no el contenedor. El wrapping va dentro de "sh -c" para
  que la redireccion se resuelva en el sitio correcto (afecta a la subida
  del helper y a la verificacion de tamano del mp3 en audio).

Verificado: dry-run de los tres contra el WP local, y una lectura real de
prod (sync_carta_from_prod.py --carta 55487 --dry-run) que recupero
correctamente el cluster de la carta 738 y el contenido de un post concreto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 07:32:38 -04:00
rafa 15d3d72c70 feat(api): endpoint para subir avatar de autor (#175) 2026-07-16 07:06:55 -04:00
rafa 809d4d9b81 Merge pull request 'Sincronizar mu-plugins/ y scripts/ con el estado real del sitio (2026-07-16)' (#179) from sync-flat-2026-07-16 into main 2026-07-16 00:40:49 +00:00
56 changed files with 2952 additions and 63 deletions
+1
View File
@@ -17,6 +17,7 @@ scripts/ — scripts Python/PHP de migración, traducción, TTS y mantenimi
| `fea-homepage.php` | Tarjetas de portada |
| `fea-share.php` | Sección "Comparte FeAdulta" (Facebook, Instagram, Imprimir) |
| `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-search.php` | Buscador móvil nativo |
| `fea-pensamientos.php` | Widget de pensamientos |
@@ -0,0 +1,269 @@
# Actualizar WordPress local desde Joomla produccion
Runbook para refrescar la copia local migrada a WordPress con los ultimos
articulos visibles en Joomla produccion.
## Contexto
- Repo local: `/home/rafa/joomla-migration`
- WordPress local: `https://farmer.taild3aaf6.ts.net/fea/`
- WordPress DB local: contenedor `wordpress-mysql`, base `wordpress_db`
- WordPress web local: contenedor `wordpress-web`
- Joomla produccion esta detras de Cloudflare. Para leer el origen se uso:
```bash
curl --resolve www.feadulta.com:443:134.0.10.170 -k -L \
-A "Mozilla/5.0 Codex Feadulta" https://www.feadulta.com/es/
```
El acceso SSH/MySQL de produccion documentado en scripts antiguos puede estar
rotado. Si falla, no asumir que los scripts historicos funcionan contra DB real.
## Flujo preferente
Si hay credenciales actuales o dump de produccion, usar una sincronizacion desde
base de datos. Los scripts historicos que documentan el modelo son:
- `scripts/import_new_k2_items.py`
- `scripts/import_new_cartas.py`
- `scripts/import_new_content.py`
- `scripts/fix_imported_k2_metas.py`
- `scripts/regenerar_clasificacion_csv.py`
- `scripts/aplicar_clasificacion_a_bd.py`
Ojo con `scripts/import_new_k2_items.py`: la version historica lee
`LAST_INSERT_ID()` en otra conexion, asi que puede devolver `0`. En el delta de
2026 se corrigieron metadatos con el offset `wp_id = k2_id + 26040`.
## Flujo de contingencia: HTML publico
Cuando no hay acceso a la DB de Joomla produccion, usar:
```bash
python3 scripts/import_public_joomla_delta.py
python3 scripts/import_public_joomla_delta.py --apply
```
El primer comando es dry-run. El segundo escribe en WordPress local.
Este importador:
- lee las cartas visibles configuradas en `CARTAS`;
- recorre los enlaces de esas cartas;
- importa solo IDs superiores al maximo ya presente en WordPress;
- conserva `_fgj2wp_old_k2_id` y `_fgj2wp_old_content_id`;
- asigna `Idioma=1`;
- asigna `_carta_id` a los K2 importados;
- clasifica por seccion de la carta:
`lecturas-biblicas`, `comentario-editorial`,
`comentarios-al-evangelio`, `eucaristia`, `multimedia`, `articulos`.
Limitacion importante: este flujo solo ve contenido enlazado desde las cartas
publicas actuales. No detecta articulos ocultos, no publicados o no enlazados.
## Preparar una semana nueva
1. Localizar las cartas visibles en produccion:
```bash
curl --resolve www.feadulta.com:443:134.0.10.170 -k -L \
-A "Mozilla/5.0 Codex Feadulta" \
https://www.feadulta.com/es/ayuda/esta-semana.html
curl --resolve www.feadulta.com:443:134.0.10.170 -k -L \
-A "Mozilla/5.0 Codex Feadulta" \
https://www.feadulta.com/es/ayuda/semana-pasada.html
curl --resolve www.feadulta.com:443:134.0.10.170 -k -L \
-A "Mozilla/5.0 Codex Feadulta" \
https://www.feadulta.com/es/ayuda/otras-semanas.html
```
2. Actualizar `CARTAS` en `scripts/import_public_joomla_delta.py` con:
- `content_id`
- URL relativa
- fecha de publicacion
- categorias de carta:
- actual: `[TERM_CARTA_SEMANA, TERM_CARTAS_OTRAS, TERM_FEADULTA]`
- semana pasada: `[TERM_CARTAS_OTRAS, TERM_CARTA_PASADA, TERM_FEADULTA]`
- otras semanas: `[TERM_CARTAS_OTRAS, TERM_FEADULTA]`
3. Ejecutar dry-run y comprobar conteos:
```bash
python3 scripts/import_public_joomla_delta.py
```
4. Aplicar:
```bash
python3 scripts/import_public_joomla_delta.py --apply
```
## Verificaciones
Maximos importados:
```bash
docker exec wordpress-mysql mysql \
-u wordpress_user -pwordpress_pass wordpress_db \
--default-character-set=utf8mb4 -B -e "
SELECT meta_key,
MIN(CAST(meta_value AS UNSIGNED)) min_id,
MAX(CAST(meta_value AS UNSIGNED)) max_id,
COUNT(*) n
FROM wp_postmeta
WHERE meta_key IN ('_fgj2wp_old_k2_id','_fgj2wp_old_content_id')
GROUP BY meta_key;"
```
Delta exacto para un corte dado:
```bash
docker exec wordpress-mysql mysql \
-u wordpress_user -pwordpress_pass wordpress_db \
--default-character-set=utf8mb4 -B -e "
SELECT pm.meta_key, COUNT(DISTINCT pm.post_id) n
FROM wp_postmeta pm
WHERE (pm.meta_key='_fgj2wp_old_k2_id'
AND CAST(pm.meta_value AS UNSIGNED) > 18102)
OR (pm.meta_key='_fgj2wp_old_content_id'
AND CAST(pm.meta_value AS UNSIGNED) > 9133)
GROUP BY pm.meta_key;"
```
Portada:
```bash
curl -k -L --max-time 20 -sS \
https://farmer.taild3aaf6.ts.net/fea/ \
| rg -n "La puerta pequeña|Carta de la semana"
```
## Carrusel de portada
El carrusel no depende de imagen destacada de posts. Se sincroniza desde:
```text
wordpress/wp-content/uploads/home/
```
El plugin responsable es:
```text
wordpress/wp-content/mu-plugins/fea-slider-sync.php
```
Ese plugin replica el modelo Joomla `images/home/` y sincroniza Smart Slider 3
slider `2`.
Detectar imagenes actuales en produccion:
```bash
curl --resolve www.feadulta.com:443:134.0.10.170 -k -L \
--max-time 20 -sS -A "Mozilla/5.0 Codex Feadulta" \
https://www.feadulta.com/es/ \
| rg -o 'images/home/[^"'"'"') ]+'
```
Procedimiento:
1. Mover las imagenes antiguas fuera de `uploads/home/`.
2. Descargar las nuevas desde `https://www.feadulta.com/images/home/...`.
3. Copiarlas al contenedor si el host no tiene permisos de escritura:
```bash
docker cp /tmp/pausa_999999000944.jpg \
wordpress-web:/var/www/html/wp-content/uploads/home/pausa_999999000944.jpg
```
4. Ajustar propietario:
```bash
docker exec wordpress-web chown www-data:www-data \
/var/www/html/wp-content/uploads/home/pausa_999999000944.jpg
```
5. Forzar resync:
```bash
docker exec wordpress-web \
wp eval "var_export(fea_slider_home_sync_now(true));" --allow-root
```
6. Verificar Smart Slider:
```bash
docker exec wordpress-mysql mysql \
-u wordpress_user -pwordpress_pass wordpress_db \
--default-character-set=utf8mb4 -B -e "
SELECT id,title,thumbnail,params
FROM wp_nextend2_smartslider3_slides
WHERE slider=2
ORDER BY ordering;"
```
## Acceso wp-admin local
URL:
```text
https://farmer.taild3aaf6.ts.net/fea/wp-admin
```
Administradores vistos en la copia local:
- `calvo`
- `eqpyk`
- `icalvotorre`
- `josek`
- `pabloarias`
- `andrey`
Si no se conoce la clave, resetear una cuenta local:
```bash
docker exec wordpress-web \
wp user update calvo --user_pass='FeAdulta2024!' --allow-root
```
Comprobar rol:
```bash
docker exec wordpress-web \
wp user get calvo --fields=ID,user_login,user_email,roles,display_name \
--allow-root
```
Si el login sigue fallando despues del reset, probar ventana privada o borrar
cookies de `farmer.taild3aaf6.ts.net`.
## Estado del delta 2026-06-14
En la actualizacion del 14 de junio de 2026 se importaron:
- `46` items K2, de `18103` a `18161`;
- `14` articulos `content`, de `9134` a `9150`;
- cartas:
- `9136` / `Uno y Trino`
- `9143` / `20 años de fe adulta`
- `9150` / `La puerta pequeña`
Carrusel actualizado de:
- `pausa_999999000941.jpg`
- `pausa_999999000942.jpg`
- `pausa_999999000943.jpg`
a:
- `pausa_999999000944.jpg`
- `pausa_999999000945.jpg`
- `pausa_999999000946.jpg`
Las imagenes antiguas quedaron en:
```text
wordpress/wp-content/uploads/home-old-20260614/
```
+99
View File
@@ -0,0 +1,99 @@
# Benchmark Gemma 4 12B vs Gemma 4 4B
## Objetivo
Comparar si Gemma 4 12B mejora de forma material el flujo real de Feadulta frente a Gemma 4 4B sin ejecutar todavía una batería grande.
Este benchmark propone una sola prueba pequeña pero representativa: traducción + preparación para TTS.
## Condiciones fijas
Usar exactamente la misma configuración para ambos modelos:
- mismo prompt
- misma temperatura (`0.2` recomendada)
- mismo max tokens
- misma context window
- sin herramientas externas
- misma máquina / mismo backend de LM Studio
## Caso de uso elegido
Feadulta suele necesitar:
- traducciones ES ↔ EN
- preservar tono espiritual/catequético
- mantener nombres propios y citas bíblicas correctas
- dejar el texto listo para TTS sin frases raras
## Prueba
### Input sugerido
Tomar un fragmento real de Feadulta de 600 a 900 palabras en español con:
- tono espiritual o doctrinal
- al menos 2 citas bíblicas
- nombres propios o referencias litúrgicas
- frases largas y algún matiz teológico
### Prompt
```text
Actúa como editor bilingüe para Feadulta.
Tarea:
1. Traduce al inglés el texto español adjunto.
2. Mantén el tono espiritual, cercano y claro; no lo vuelvas académico ni robótico.
3. Conserva correctamente citas bíblicas, nombres propios, referencias litúrgicas y términos doctrinales.
4. Después de la traducción, genera una segunda versión "TTS-ready" del mismo texto en inglés:
- frases algo más cortas
- puntuación natural para locución
- sin enumeraciones artificiales
- sin emojis
- sin notas del traductor
5. No inventes contenido ni expliques decisiones.
Devuelve exactamente este formato:
[EN_TRANSLATION]
...
[TTS_READY_EN]
...
```
[PEGAR TEXTO FUENTE AQUÍ]
```
## Qué evaluar
Puntuar 15 en cada eje:
1. Fidelidad al original
- no omite matices importantes
- no simplifica demasiado
- no añade ideas
2. Naturalidad del inglés
- suena humano
- no parece traducción literal
- mantiene buen ritmo
3. Precisión religiosa / terminológica
- citas bíblicas bien mantenidas
- términos doctrinales consistentes
- nombres y referencias correctos
4. Calidad TTS-ready
- frases respirables
- puntuación ayuda a narración
- no hay giros torpes para voz
5. Necesidad de edición manual
- 5 = casi publicar
- 1 = rehacer bastante
## Señales de que gana el 12B
- conserva mejor el matiz del original
- hace menos traducción literal rara
- entrega una versión TTS más fluida
- requiere menos retoques antes de publicar
## Regla práctica de decisión
Si el 12B gana claramente en 3 o más ejes y la latencia extra sigue siendo tolerable, merece ser el modelo por defecto para traducción/TTS de Feadulta.
Si la mejora es pequeña, el 4B puede seguir siendo suficiente para borradores rápidos.
+420
View File
@@ -0,0 +1,420 @@
# Guía para publicar la carta semanal en Fe Adulta (WordPress)
> **Para quién es este documento:** para el asistente (Claude Code / Cowork) que ayuda a
> **Inma** a publicar la carta semanal y los artículos en el WordPress nuevo de Fe Adulta.
>
> **Reparto de responsabilidades:**
> - **Inma** aporta el contenido editorial: cómo se redacta y compone la carta y los artículos.
> - **Este documento** aporta la infraestructura y la estructura del sitio: cómo conectarse,
> dónde va cada cosa, qué categorías usar, qué se publica automáticamente y qué hay que
> hacer a mano.
> - **Inma sube siempre en ESPAÑOL.** Las traducciones (EN/FR/IT/PT) y el audio (TTS) los
> genera un proceso automático en el servidor (ver §7). No hay que traducir a mano.
---
## 1. El sitio y cómo conectarse
### 1.1 Entornos
| Entorno | URL | Estado |
|---------|-----|--------|
| **Producción** | `https://www.feadulta.com` | El sitio en vivo, WordPress. Cutover completado el 2026-07-08 |
| Legado Joomla | `https://antiguo.feadulta.com` | Solo contenido histórico no migrado. No se publica aquí |
**El cutover de dominio ya se hizo (2026-07-08):** `www.feadulta.com` es WordPress y es donde
se publica siempre. `wp-nuevo.feadulta.com` (el subdominio de Beta) ha dejado de servir nada
(el directorio que usaba se vació al mover los ficheros a la raíz del hosting) — si algún
enlace o script antiguo todavía lo menciona, hay que cambiarlo a `www.feadulta.com`.
### 1.2 Acceso recomendado: API REST de WordPress + contraseña de aplicación
No hace falta SSH ni tocar la base de datos. WordPress trae una API REST y un sistema de
**"Contraseñas de aplicación"** (Application Passwords) pensado exactamente para esto.
**Cómo obtener la credencial (lo hace Inma una sola vez):**
Inma **ya tiene usuario con rol Editor** en el sitio (suficiente para crear, editar,
publicar y programar entradas). Con ese usuario:
1. Entrar en `https://www.feadulta.com/wp-admin`.
2. Ir a **Usuarios → Perfil** → bajar hasta **"Contraseñas de aplicación"**.
3. Escribir un nombre (p. ej. `cowork-cartas`) y pulsar **Añadir**.
4. WordPress muestra una contraseña de 24 caracteres con espacios
(formato `xxxx xxxx xxxx xxxx xxxx xxxx`). **Se copia y se guarda**: solo se ve una vez.
**Cómo la usa el asistente:** autenticación HTTP Basic sobre HTTPS, con
`usuario:contraseña_de_aplicación`. Ejemplo de prueba (debe responder con los datos del
usuario):
```bash
curl -s -u "USUARIO:xxxx xxxx xxxx xxxx xxxx xxxx" \
https://www.feadulta.com/wp-json/wp/v2/users/me
```
Base de la API para todo lo demás: `https://www.feadulta.com/wp-json/wp/v2/`
> **⚠️ Pendiente de configurar en Cloudflare (bloqueante).** Comprobado el **27/06/2026**: el
> sitio está tras Cloudflare y devuelve **403 "Attention Required"** a **cualquier** petición
> que no sea un navegador real con JavaScript — afecta a la API REST, a la home y a
> `wp-login.php`, incluso enviando un User-Agent de navegador. Es el reto de navegador de
> Cloudflare, **no** un problema de la credencial.
>
> Para que el asistente pueda usar la API hay que crear una **excepción en el panel de
> Cloudflare** (lo hace Inma). Opciones, de más simple a más limpia:
> 1. **Allowlist por IP de origen:** WAF → Tools → permitir la IP pública desde la que se
> conecta el asistente. Simple, pero requiere IP fija/conocida.
> 2. **Regla WAF con token secreto:** una Custom Rule que haga *Skip / Bypass* cuando la
> petición vaya a `/wp-json/*` **y** lleve una cabecera secreta (p. ej.
> `X-FEA-Key: <secreto>`). El asistente añade esa cabecera en cada llamada.
> 3. **Subdominio "solo DNS"** (sin proxy naranja), p. ej. `api.feadulta.com`, apuntando al
> mismo origen para servir la API sin pasar por el reto de Cloudflare.
>
> Hasta que se aplique una de estas, las llamadas a la API fallarán con 403. Coordinar con
> Rafa/Inma cuál se elige.
---
## 2. La idea clave: **la carta de la semana ES la portada**
Esto es lo más importante de entender. La portada de Fe Adulta **no** muestra "los últimos
artículos por categoría". Muestra **exactamente lo que enlaza la carta de la semana actual**,
agrupado por las secciones internas de esa carta.
Es decir: la carta semanal es un artículo largo en HTML, con secciones marcadas por
encabezados, y dentro de cada sección hay enlaces a otros artículos. La portada lee la carta
vigente, detecta esas secciones y coloca cada grupo de enlaces en su bloque correspondiente.
**Consecuencia práctica:** para que la portada se rellene bien, la carta tiene que estar
escrita con unos **encabezados de sección concretos** (§5) y enlazar a artículos que ya
existan en el sitio (§4).
---
## 3. Categorías del sitio
Las categorías se asignan por su **ID numérico** vía API (campo `categories: [..]`).
### 3.1 Categorías de la carta (estado de cada carta)
| ID | Nombre | Slug | Significado |
|----|--------|------|-------------|
| **6** | Carta de la semana | `cartasemana` | La carta **vigente**. Debe estar solo la actual. |
| **22** | La semana pasada | `carta-semana-pasada` | La carta de la semana anterior. Debe estar solo una. |
| **21** | Cartas de otras semanas | `cartas-de-otras-semanas` | Histórico **acumulativo**: TODAS las cartas publicadas alguna vez, incluidas la vigente (6) y la anterior (22). No se quita nunca, solo se añade. Así el listado de "Otras Semanas" sirve para navegar por cualquier carta pasada sin tener que ir carta por carta. |
### 3.2 Categorías temáticas (para los artículos de dentro de la carta)
| ID | Nombre | Uso |
|----|--------|-----|
| 1645 | Lecturas bíblicas | Lecturas del domingo |
| 1646 | Comentario editorial | Comentario editorial de la semana |
| 1647 | Comentarios al evangelio | Artículos que comentan el evangelio |
| 1648 | Eucaristía | Material para la eucaristía |
| 1649 | Multimedia | Vídeos / audios / material multimedia |
| 1650 | Artículos | Artículos generales seleccionados |
> Cada artículo de la semana va en su categoría temática. **La carta** (el texto largo) va
> en la categoría **6**.
---
## 4. Cómo se publica una carta nueva (flujo completo)
Una "carta" no es un solo artículo: es **un artículo-carta que enlaza a varios artículos**.
El orden correcto es: primero los artículos, luego la carta que los enlaza.
### Paso 1 — Crear los artículos de la semana
Cada pieza de contenido (cada comentario al evangelio, cada artículo seleccionado, etc.) es
un **post** propio. Se crean vía API y conviene crearlos primero **como borrador** y, cuando
estén listos, publicarlos.
```bash
curl -s -u "USUARIO:APP_PASSWORD" \
-X POST https://www.feadulta.com/wp-json/wp/v2/posts \
-H "Content-Type: application/json" \
-d '{
"title": "Título del artículo",
"content": "<p>Cuerpo en HTML…</p>",
"status": "draft",
"categories": [1647]
}'
```
De la respuesta JSON interesan dos campos:
- `id` → identificador del post.
- `link` → la **URL pública** del artículo. **Guardar esta URL**: es la que se usa para
enlazar desde la carta.
Repetir para cada artículo, usando la categoría temática que corresponda (§3.2).
> ⚠️ **No adivinéis la URL a partir del título — usad siempre el `link` real de la API,
> y solo DESPUÉS de publicar.** En la carta 734 los artículos se quedaron en `draft` con el
> título prefijado `[PRUEBA]`, y la carta se compuso enlazando slugs *adivinados* a mano
> (`.../prueba-silencio/`, etc.) a partir de ese título provisional. Al publicar de verdad
> con el título limpio, WordPress genera el slug desde el título final y **le añade un
> sufijo (`-2`, `-4`…) si ya existe otro post con ese mismo slug** — algo frecuente en
> temas recurrentes ("Silencio", "La palabra", "Te encontré"…). Resultado: 21 de 22
> enlaces de la carta apuntaban a una URL que nunca existió. Regla: **publicad cada
> artículo (quitando `[PRUEBA]` del título) en cuanto esté listo**, y componed los
> enlaces de la carta con el `link` que devuelve la API **tras ese publish**, no antes.
### Paso 1.5 — Asignar `_carta_id` a cada artículo de la semana (IMPORTANTE)
Cada artículo de la semana debe llevar el meta `_carta_id` = ID del post de la carta a la
que pertenece. **Sin esto, el sistema no puede localizar "todos los artículos de esta
carta" para publicarlos/traducirlos/locutarlos en bloque.**
`_carta_id` es un meta interno y **no se puede asignar por el endpoint estándar**
`/wp/v2/posts/<id>` (WordPress lo bloquea por empezar por `_`). Para esto hay un
**endpoint REST propio**, desplegado el 2026-07-07:
```bash
# Leer el _carta_id actual de un artículo
curl -s -u "USUARIO:APP_PASSWORD" \
https://www.feadulta.com/wp-json/fea/v1/carta-id/ID_DEL_ARTICULO
# Asignar (o corregir) el _carta_id de un artículo
curl -s -u "USUARIO:APP_PASSWORD" \
-X POST https://www.feadulta.com/wp-json/fea/v1/carta-id/ID_DEL_ARTICULO \
-H "Content-Type: application/json" \
-d '{"carta_id": ID_DE_LA_CARTA}'
# Borrar el _carta_id (si os habéis equivocado de artículo)
curl -s -u "USUARIO:APP_PASSWORD" \
-X DELETE https://www.feadulta.com/wp-json/fea/v1/carta-id/ID_DEL_ARTICULO
```
Respuesta en los tres casos: `{"post_id": ID, "carta_id": ID|null}`.
Notas:
- Solo podéis asignar `_carta_id` a artículos **que ya podéis editar** (mismo criterio que
el resto de la API: vuestro usuario Editor).
- `carta_id` tiene que ser el ID de **un post que exista** (normalmente el de la carta,
aunque en el momento de llamar a este endpoint la carta puede que aún esté en borrador —
eso no importa, solo tiene que existir el post).
- Podéis llamarlo justo después de crear cada artículo (Paso 1) o al final, después de tener
el ID de la carta (Paso 3) — el orden entre pasos 1 y 1.5 y 3 no importa mientras al acabar
todos los artículos de la semana tengan el `_carta_id` correcto.
- Código fuente: `wp-content/mu-plugins/fea-carta-id-api.php` (mu-plugin, activo siempre).
### Paso 2 — Componer el artículo-carta
La carta es un post HTML con:
1. Texto editorial libre (introducción).
2. Una sección por cada bloque, **encabezada con una de las frases exactas de §5**.
3. Dentro de cada sección, **enlaces (`<a href>`) a las URLs** de los artículos del Paso 1.
Ver §5 para el formato exacto de los encabezados y un ejemplo completo.
### Paso 3 — Publicar (o programar) la carta
La carta va en la categoría **6** (`cartasemana`) **y también en la 21** (`cartas-de-otras-semanas`)
desde el primer momento — ver §3.1: la 21 es un histórico acumulativo que no se quita nunca, así
que toda carta la lleva ya desde que se crea, no solo cuando se "degrada" en el Paso 4.
- **Publicar ya:** `"status": "publish"`.
- **Programar:** `"status": "future"` + `"date"` con la fecha/hora local del sitio en
formato ISO 8601, p. ej. `"2026-06-29T08:00:00"`. WordPress la publicará sola a esa hora.
```bash
curl -s -u "USUARIO:APP_PASSWORD" \
-X POST https://www.feadulta.com/wp-json/wp/v2/posts \
-H "Content-Type: application/json" \
-d '{
"title": "Carta de la semana — 29 de junio",
"content": "<p>…cuerpo de la carta con sus secciones y enlaces…</p>",
"status": "future",
"date": "2026-06-29T08:00:00",
"categories": [6, 21]
}'
```
### Paso 4 — Rotar la carta anterior (IMPORTANTE, manual)
Al entrar una carta nueva en la categoría **6**, hay que **degradar la anterior** para que la
categoría "Carta de la semana" contenga **solo una** carta. Esto es solo mover el marcador de
**estado** (6 → 22 → ninguno): la categoría **21** ("Otras semanas") **no se toca en la
rotación**, porque es acumulativa y ya la lleva cada carta desde que se publicó (Paso 3). Nunca
se quita la 21 a una carta.
1. A la carta que **dejaba** de ser actual: quitarle la categoría **6** y ponerle la **22**
("La semana pasada"). Mantiene la **21** que ya tenía.
2. A la que estaba en **22**: quitarle la **22** sin más. Mantiene la **21** que ya tenía —
así queda solo en el histórico "Otras semanas", visible igual que todas las demás.
Vía API se actualiza el array `categories` del post (sustituye al anterior, así que hay que
**incluir siempre la 21** en el array nuevo o se perdería):
```bash
# 1. La carta que deja de ser actual: 6 → 22 (conserva 21)
curl -s -u "USUARIO:APP_PASSWORD" \
-X POST https://www.feadulta.com/wp-json/wp/v2/posts/ID_DE_LA_CARTA_VIEJA \
-H "Content-Type: application/json" \
-d '{"categories": [22, 21]}'
# 2. La que estaba en 22: se queda solo con 21 (y las que no sean de estado, p.ej. 71)
curl -s -u "USUARIO:APP_PASSWORD" \
-X POST https://www.feadulta.com/wp-json/wp/v2/posts/ID_DE_LA_CARTA_MAS_VIEJA \
-H "Content-Type: application/json" \
-d '{"categories": [21]}'
```
> ⚠️ El endpoint `POST /wp/v2/posts/ID` con `categories` **sustituye** el array completo, no
> añade. Antes de rotar, comprobar con `GET /wp-json/wp/v2/posts/ID_DE_LA_CARTA?_fields=categories`
> qué categorías tiene ya el post (p. ej. si tiene además la 71 "Feadulta") e incluirlas todas
> en el array nuevo — si no, se pierden categorías sin querer (esto pasó en la carta 733/734,
> ver issue #159 y #161).
>
> Si se programa la carta nueva con `future`, esta rotación puede hacerse el mismo día en que
> se publique. Si surge duda sobre qué carta está en qué categoría, consultar:
> `GET /wp-json/wp/v2/posts?categories=6` (debe devolver solo una).
---
## 5. Formato de la carta para que la portada la "entienda"
La portada parsea el HTML de la carta buscando **encabezados con estas frases** (no distingue
mayúsculas/acentos, pero el texto debe coincidir). Cada encabezado abre una sección; todos los
enlaces que vayan **después** de ese encabezado (hasta el siguiente) se muestran en el bloque
de portada indicado:
| Frase del encabezado en la carta | Bloque de portada |
|----------------------------------|-------------------|
| **Evangelio y comentarios al Evangelio** | Evangelio |
| **Artículos seleccionados para la semana** | Artículos de la semana |
| **Para unas eucaristías más participativas y actuales** | Eucaristía |
| **Material multimedia** | Multimedia |
| **Escuela EFFA** | EFFA |
**Reglas:**
- Los encabezados se suelen marcar en rojo/negrita (es el estilo histórico), pero lo que
importa es que **el texto coincida** con la frase de la tabla.
- Los enlaces dentro de cada sección deben apuntar a **artículos que existan** en el sitio
(las URLs del Paso 1). Enlaces a páginas externas se ignoran.
- Si una sección no tiene enlaces, ese bloque de la portada queda vacío.
- **"Noticias de alcance" NO se enlaza a mano en el cuerpo de la carta.** Si la semana
incluye una noticia de este tipo, el artículo va categorizado en la categoría **41**
("Noticias de alcance") y **eso basta**: la portada tiene un bloque de footer aparte que
se alimenta solo de esa categoría. Si además se enlaza esa noticia dentro de "Artículos
seleccionados para la semana", **sale duplicada** en la portada (pasó en la carta 734,
ver issue #159).
**Ejemplo mínimo de cuerpo de carta:**
```html
<p>Texto editorial de introducción de la semana…</p>
<p><strong style="color:red">Evangelio y comentarios al Evangelio</strong></p>
<ul>
<li><a href="https://www.feadulta.com/comentario-evangelio-domingo/">Comentario al evangelio</a></li>
</ul>
<p><strong style="color:red">Artículos seleccionados para la semana</strong></p>
<ul>
<li><a href="https://www.feadulta.com/articulo-uno/">Primer artículo</a></li>
<li><a href="https://www.feadulta.com/articulo-dos/">Segundo artículo</a></li>
</ul>
<p><strong style="color:red">Para unas eucaristías más participativas y actuales</strong></p>
<ul>
<li><a href="https://www.feadulta.com/eucaristia-domingo/">Material para la eucaristía</a></li>
</ul>
<p><strong style="color:red">Material multimedia</strong></p>
<ul>
<li><a href="https://www.feadulta.com/video-semana/">Vídeo de la semana</a></li>
</ul>
```
> La portada cachea el resultado **15 minutos**. Al guardar/editar la carta se refresca sola,
> pero si un cambio no se ve al momento, esperar unos minutos.
---
## 6. Qué es automático y qué hay que tocar a mano
### Automático — NO hay que hacer nada
- **Portada (home):** se construye sola a partir de la carta vigente (la más reciente en la
categoría 6) y sus secciones (§5).
- **Página "Carta de la semana"** (`/carta-de-la-semana/`): redirige automáticamente a la
carta más reciente de la categoría 6.
- **Página "La semana pasada"** (`/la-semana-pasada/`): redirige a la carta más reciente de la
categoría 22.
- **Menús, avatares de autores, multiidioma, etc.:** gestionados por el tema/plugins.
- **Traducciones (EN/FR/IT/PT) y audio (TTS):** las genera el proceso del servidor (§7).
### Manual — lo que hace Inma / su asistente
- Crear los **artículos** de la semana (§4, Paso 1).
- Componer y **publicar/programar la carta** (§4, Pasos 2-3).
- **Rotar** la carta anterior de categoría (§4, Paso 4).
- Subir **imagen destacada** del artículo si se quiere (campo `featured_media` con el ID de
un adjunto previamente subido a la biblioteca de medios).
---
## 7. Traducción y audio (lado servidor — ⚠️ hoy es MANUAL, no automático)
**Corregido 2026-07-12: esto todavía NO es automático.** El cron que traduciría y generaría
audio solo al publicar (issue [#23](https://gitea.feadulta.com/rafa/feadulta/issues/23)) sigue
sin implementar — es una propuesta abierta, no algo que ya corra. Versiones anteriores de esta
guía decían "hay (o habrá) un proceso programado" dando a entender que ya estaba activo o a
punto; no lo está. **Inma NO tiene que traducir ni generar audio ella misma**, pero sí tiene que
**pedirlo** — no llega solo.
**Cómo pedirlo hoy:** escribir al grupo de WhatsApp mencionando "Hermes" (o por Telegram),
indicando la carta/artículo. Hermes ejecuta los scripts correspondientes en el servidor de Rafa.
Ver el runbook completo (motores de traducción, voces TTS por autor, tiempos, qué hacer si
Hermes no responde) en `docs/guia-tts-traduccion-inma.md`.
**Por eso sigue siendo importante subir siempre el contenido en español** — la traducción parte
siempre del ES, pero hay que pedirla, no asumir que "ya llegará". Si una carta urgente necesita
traducción inmediata, avisar a Rafa directamente además de pedírselo a Hermes.
> *Este punto es responsabilidad de Rafa (infraestructura). Se incluye aquí para que el
> asistente de Inma sepa que el trabajo de traducir/locutar no lo tiene que hacer él mismo,
> pero sí que tiene que solicitarlo activamente.*
---
## 8. Resumen rápido (checklist por carta)
- [ ] Crear cada artículo de la semana y **publicarlo** (no dejarlo en `draft`/`[PRUEBA]`).
- [ ] Guardar la URL (`link`) de cada artículo **después** de publicarlo, no antes (el slug
puede cambiar por colisión con otro post del mismo título).
- [ ] Asignar `_carta_id` a cada artículo con el endpoint de §1.5 (`fea/v1/carta-id/{id}`).
- [ ] Componer la carta en HTML con los **encabezados exactos** de §5 y los enlaces a esos artículos.
- [ ] Publicar o programar la carta en las categorías **6 y 21** (§3.1, §4 Paso 3).
- [ ] Rotar la carta anterior: quitar 6, poner 22 (conservando su 21). A la que estaba en 22,
quitarle solo la 22 (conservando su 21 — nunca se quita la 21 a nadie, §4 Paso 4).
- [ ] Comprobar que la portada muestra las secciones (esperar hasta 15 min si hace falta).
- [ ] No traducir ni generar audio a mano — pero SÍ pedirlo a Hermes (WhatsApp/Telegram, ver §7 y `docs/guia-tts-traduccion-inma.md`). No es automático todavía.
---
## 9. Referencia rápida de la API
| Acción | Método y endpoint |
|--------|-------------------|
| Probar credencial | `GET /wp-json/wp/v2/users/me` |
| Listar categorías | `GET /wp-json/wp/v2/categories?per_page=100` |
| Crear artículo/carta | `POST /wp-json/wp/v2/posts` |
| Editar artículo/carta | `POST /wp-json/wp/v2/posts/{id}` |
| Ver carta vigente | `GET /wp-json/wp/v2/posts?categories=6` |
| Subir imagen | `POST /wp-json/wp/v2/media` (cabecera `Content-Disposition`) |
| Leer `_carta_id` de un artículo | `GET /wp-json/fea/v1/carta-id/{id}` |
| 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).
+121
View File
@@ -0,0 +1,121 @@
# Guía de traducción y audio (TTS) para Inma / Mixbot — feadulta.com
> **Para quién es este documento:** para Inma y su asistente (Mixbot / Cowork), y como
> referencia para Hermes cuando se le pide que traduzca o locute un artículo/carta.
>
> **Estado real a 2026-07-12 (importante, corrige la guía de publicación §7 de versiones
> anteriores): esto NO es automático.** No hay ningún cron corriendo hoy que traduzca o genere
> audio solo al publicar — esa automatización es la propuesta abierta
> [issue #23](https://gitea.feadulta.com/rafa/feadulta/issues/23), sin implementar. Todo lo de
> abajo es un proceso que **hay que pedir**, hoy solo ejecutable en el servidor/PC de Rafa.
---
## 1. Quién puede hacer qué, hoy
| Tarea | ¿Quién puede hacerla sin Rafa presente? |
|---|---|
| Publicar carta/artículos en español | **Sí, Inma/Mixbot solos** — API REST ya funciona (ver `docs/guia-publicacion-carta-inma.md`). No depende del PC de Rafa. |
| Pedir traducción o TTS | **Solo indirectamente**: hay que pedírselo a **Hermes** (WhatsApp/Telegram). Los scripts que traducen y locutan viven únicamente en el PC/servidor de Rafa (Docker local + credenciales locales) — Mixbot no tiene acceso directo a ellos. |
| Traducir/locutar si Hermes tampoco está disponible | **Hoy, no.** Es la limitación real que hay que conocer: si el PC de Rafa está apagado o Hermes está caído, ni Inma ni Mixbot pueden disparar esto por su cuenta. Ver §5 (qué falta para que esto no dependa de Hermes). |
## 2. Cómo pedir una traducción o un audio (mientras Hermes esté disponible)
Escribir al grupo de WhatsApp `Feadulta_webmaster` mencionando "Hermes" (o por Telegram),
indicando qué carta/artículo (ID de WordPress o título+fecha si no se tiene el ID) y qué se
necesita: traducción, audio, o ambos. Ejemplos:
> "Hermes, tradúceme la carta 54XXX a los 4 idiomas"
> "Hermes, genera el audio de los artículos de la carta de esta semana"
Hermes ejecuta los scripts de abajo en el servidor de Rafa. No hace falta que Inma/Mixbot sepan
los nombres de los scripts ni los IDs internos — es información para cuando Hermes (o Rafa)
necesite el detalle técnico.
## 3. Traducción — motores disponibles
`scripts/translate_post.py` (repo `joomla-migration`) soporta tres motores via `FEA_ENGINE`:
| Motor | Coste | Cuándo usarlo |
|---|---|---|
| **`gemma`** (por defecto) | Gratis (modelo local, LM Studio en el PC de Rafa) | Opción por defecto. Requiere que el PC/GPU de Rafa esté encendido. |
| **`minimax`** | De pago, acotado (misma cuenta que el TTS) | Alternativa cuando Gemma no está disponible o la calidad no basta. |
| **`haiku`** | ⚠️ **De pago vía API directa de Anthropic** (no es cuota de sesión) | **No usar por defecto ni de forma autónoma.** Choca con la política de no gastar API de pago sin que Rafa confirme cada vez. Reservado para cuando Rafa lo ejecuta él mismo o da autorización puntual. |
Comando (lo ejecuta Hermes o Rafa, no Inma/Mixbot directamente):
```bash
cd /home/rafa/joomla-migration
python3 scripts/translate_post.py --carta <ID_CARTA> --langs en,fr,it,pt --status draft
# o para un solo artículo:
python3 scripts/translate_post.py --post-id <ID_POST> --langs en,fr,it,pt --status draft
```
`--status draft` dejar en borrador para revisión; `--status publish` publica directo. Tras
traducir, hace falta el paso de enlaces internos (`scripts/fix_carta_joomla_links.php`) y, si se
publica, degradar la carta anterior (`scripts/demote_old_cartasemana.php`) — Hermes ya conoce
este flujo (ver skill `feadulta-webmaster`, `references/procedures.md`).
## 4. Audio (TTS) — voces por autor
`scripts/minimax_tts.py` + `scripts/tts_produce.py` (genera y escribe en WP local) +
`scripts/sync_audio_to_prod.py` (sube a prod, soporta `--rollback` para deshacer). Modelo MiniMax
`speech-2.8-hd`.
**Voz por defecto:** `NicoFeadulta2026` (todos los autores sin voz clonada).
**Voces clonadas por autor** (issue #152 — solo estos 4 autores usan su propia voz, el resto cae
a Nico):
| Autor | WP user_id | voice_id |
|---|---|---|
| Fray Marcos | 382 | `FrayMarcosFeadulta2026` |
| José Antonio Pagola | 383 | `PagolaFeadulta2026` |
| José Luis Sicre | 774 | `SicreFeadulta2026` |
| José Arregi | 386 | `ArregiFeadulta2026` |
Añadir un autor nuevo a esta lista requiere clonar su voz primero (grabación limpia 2-5 min, sin
música/ruido de fondo — verificar con espectrograma antes de clonar, ver memoria
`feadulta-tts-voz-fraymarcos-202607` para el procedimiento y los descartes por música colada) y
añadirlo a `AUTHOR_VOICES` en `scripts/minimax_tts.py`. Esto sí requiere que Rafa (o alguien con
acceso al repo y a MiniMax) lo haga — no es autoservicio para Inma/Mixbot hoy.
**Generar audio de una carta concreta** (por defecto `tts_produce.py` procesa una cola larga de
cartas pendientes — para priorizar una carta concreta, sobreescribir la cola):
```bash
cd /home/rafa/joomla-migration
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).
**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>
# deshacer si algo suena mal:
python3 scripts/sync_audio_to_prod.py --rollback --carta <ID_CARTA>
```
Runbook de rollback ya documentado para que Hermes lo ejecute sin Rafa presente: issue #163.
## 5. La API key de MiniMax — decisión pendiente (de Rafa, no resuelta en este documento)
Hoy la key vive en un fichero local de Rafa, usada para TTS (y podría usarse para traducción
`FEA_ENGINE=minimax`). Para que Inma/Mixbot puedan disparar esto sin pasar por Hermes, harían
falta tanto acceso a esta key como acceso al entorno donde corren los scripts (Docker local del
PC de Rafa) — hoy ninguna de las dos cosas es cierta. Ver el issue maestro
[#172](https://gitea.feadulta.com/rafa/feadulta/issues/172) para las opciones que se están
valorando (key separada para Inma, gestor de secretos compartido, o mantener todo detrás de
Hermes). Mientras no se decida, la vía real es §2: pedírselo a Hermes.
## 6. Qué falta para que esto sea de verdad independiente de Hermes/Rafa
Siendo honestos: hoy, si el PC de Rafa está apagado (viaje, avería, lo que sea) y Hermes no
responde, **no hay forma de que Inma/Mixbot generen traducción o audio por su cuenta** — los
scripts y el WordPress local que usan como paso intermedio solo existen ahí. Para que esto
cambiara de verdad haría falta uno de:
- Mover el pipeline de traducción/TTS a un sitio alcanzable por Mixbot directamente (ej. correr
contra prod en vez de contra el WordPress local, y alojar los scripts en un servidor
accesible, no en el PC personal de Rafa).
- O implementar de una vez el cron automático (issue #23) para que ni siquiera haga falta
pedirlo — se dispara solo al publicar en español.
Ninguna de las dos está hecha. Documentado aquí para que la decisión de priorizarlo (o no) sea
consciente, no un descuido.
+86
View File
@@ -0,0 +1,86 @@
# Handoff — Carta 46956 «Entre todos» (sesión 2026-06-17)
Estado para que **Codex** continúe. Se hizo TODO en **local** (Docker `wordpress-web`/`wordpress-mysql`). **Nada está en prod todavía.** El despliegue a prod quedó **pendiente y SIN hacer** (Rafa quiere ir por partes).
Repo: `/home/rafa/joomla-migration` (= remoto Gitea `rafa/feadulta`). Ver también wiki «Ciclo carta nueva» y memoria del ciclo.
---
## 1. Qué se hizo en LOCAL (todo verificado)
### Carta y artículos
- **Carta 46956 «Entre todos»** (ES). Estaba en estado `future` por desfase de zona horaria (server US detrás de Madrid) → **publicada** (`post_status=publish`, fecha 2026-06-18). **Issue #87 (cerrado).**
- **19 artículos** con `_carta_id=46956` (IDs 4693746955). De ellos **15 son ES** (4693746951) y 4 ya venían en otros idiomas desde Joomla.
- **Cluster multiidioma** 4695146955 = el MISMO artículo en 5 idiomas; **enlazado** en Polylang: `{es:46951, fr:46952, en:46953, it:46954, pt:46955}` (no se tradujo, ya estaba).
### Traducciones (motor Haiku)
- **Carta + 14 artículos ES → EN/FR/IT/PT = 60 posts** (IDs **4695947018**), `publish`.
- Motor: **`FEA_ENGINE=haiku`** (nuevo flag en `scripts/translate_post.py`) → usa Claude Haiku 4.5 vía `translate_haiku.py`. API key en `/home/rafa/portfolio-tracker/.env`. Coste ~0.60.8 €/carta.
- Comando: `FEA_ENGINE=haiku python3 scripts/translate_post.py --carta 46956 --langs en,fr,it,pt --status draft` (luego publicado).
### Evangelio / lecturas bíblicas (**issue #88**)
- El pasaje **ES `MATEO 10, 26-33` (post 2682)** solo existía en ES. Política: **DESCARGAR la Biblia oficial, NO traducir con LLM**.
- Descargado de **bolls.life** y creados 4 posts (IDs **4707947082**), enlazados Polylang a 2682, cat "Lecturas bíblicas" mapeada, `publish`:
- EN=**Douay-Rheims** (católica), PT=**CNBB** (católica), IT=**Nuova Riveduta 2006** (única moderna IT), FR=**Bible du Semeur** (no hay católica libre en bolls).
- Scripts: `scripts/fetch_lectura_bolls.py` (descarga) + `scripts/create_lecturas.php` (crea posts).
### Enlaces internos de las cartas
Las cartas traían enlaces Joomla legacy `es/buscadoravanzado/item/<k2_id>-<slug>.html` (rotos) y luego enlaces al pasaje ES. Arreglado en 2 pasos:
1. `scripts/fix_carta_joomla_links.php` — mapea `item/<id>` por meta `_fgj2wp_old_k2_id` → permalink WP del artículo en el idioma de cada carta.
2. `scripts/repoint_carta_links.php` — repunta enlaces ya-permalink que apuntaban al ES → a la traducción del idioma de la carta (p.ej. el evangelio).
- `APPLY=1 CARTA=46956 docker exec ... php /tmp/<script>.php`. Sin mapear (se dejan): `tablon-de-anuncios`, `noticias-de-alcance`, ítem `18151 el-buen-humor` (fuera del delta), multimedia `9155`.
### Categorías "carta de la semana"
- `scripts/demote_old_cartasemana.php` — deja en `cartasemana` (term 6) **solo la carta nueva** en CADA idioma (count=1 → el menú redirige directo al single). Movía las cartas anteriores a "cartas de otras semanas". Aplicado: count=1 en es/en/fr/it/pt.
### Verificación local (vía Caddy, NO localhost:8081)
- Cartas: `/fea/entre-todos/`, `/fea/en/among-all/`, `/fea/fr/entre-tous/`, `/fea/it/tra-tutti/`, `/fea/pt/entre-todos-2/` — todas publish.
- Menú "carta de la semana" por idioma → 302 al single de cada idioma.
- Evangelio enlaza al pasaje en su idioma (4707947082), todos HTTP 200.
---
## 2. Estado de PROD (verificado read-only 2026-06-17)
SSH correcto: **`sshpass -p "$FEA_PROD_SSH_PASS" ssh feadulta@134.0.10.170`** (la pass de cPanel/FTP que dio Rafa NO sirve para SSH — son credenciales distintas; ambas en `.env`, no aquí). wp-cli en `/web/wp-nuevo`. NO scp (usar `ssh 'cat > ruta'`).
- **MAX post ID en prod = 46809.** Los IDs locales nuevos (4693747082) están LIBRES en prod.
- **La carta de esta semana NO está en prod.** La "carta de la semana" actual en prod es la **anterior: 45018 «La puerta pequeña»** (2026-06-13).
- El pasaje ES **2682 `MATEO 10,26-33` SÍ está** en prod (post viejo). Faltan sus 4 traducciones.
---
## 3. PENDIENTE: desplegar la carta 46956 a prod (lo que paró Rafa)
Objetivo: que la carta de esta semana se vea bien en prod en los 5 idiomas. Implica el delta ES + traducciones + evangelios + estructura. **Rafa quiere ir por partes** (NO hacer todo de golpe sin su OK).
### ⚠️ Problema de coincidencia de IDs (clave)
La regla "IDs ES coinciden local↔prod" se ha ROTO: prod va por detrás (max 46809) mientras local hizo el delta cuando su max era 46936. Una reimportación fresca desde Joomla en prod asignaría IDs ~46810, **distintos de los locales**, y rompería el casado de traducciones/enlaces.
- **Enfoque recomendado:** replicar los posts locales a prod **preservando los IDs** (4693747082 + nada de 2682 que ya está), ya que están libres en prod. Es decir, NO reimportar desde Joomla: volcar el contenido local (ES + traducciones + evangelios) a prod con `wp_insert_post`/inserción con ID explícito, reconstruir Polylang + categorías + metas (`_carta_id`, `_fgj2wp_old_k2_id`), y aplicar los mismos arreglos de enlaces y degradado de categoría.
- Revisar `scripts/sync_translations_to_prod.py` (opción A, ya validada para traducciones reusando texto local) y adaptarlo/extenderlo para incluir el delta ES y los pasajes del evangelio con ID preservado.
### Checklist prod (por partes, con OK de Rafa en cada salto)
1. [ ] Delta ES a prod: carta 46956 + artículos 4693746955 con IDs preservados, metas (`_carta_id`, `_fgj2wp_old_k2_id`), categorías, idioma ES. Revisar.
2. [ ] Cluster multiidioma 4695146955: enlazar Polylang en prod (los 5 ya existirían tras paso 1).
3. [ ] Traducciones EN/FR/IT/PT (carta + artículos): 4695947018 con IDs preservados, Polylang, categorías.
4. [ ] Evangelio: crear en prod las 4 traducciones del 2682 (4707947082) enlazadas a 2682 (que ya está en prod). Reusar `fetch_lectura_bolls.py` + `create_lecturas.php` adaptado a `FEA_WP_LOAD=/web/wp-nuevo/wp-load.php`.
5. [ ] Enlaces internos en prod: `fix_carta_joomla_links.php` + `repoint_carta_links.php` (con `FEA_WP_LOAD` de prod).
6. [ ] Degradar carta anterior en prod: `demote_old_cartasemana.php CARTA=46956` (mueve 45018 y traducciones a "otras semanas", deja count=1 por idioma).
7. [ ] Verificar vía Cloudflare/web: cartas y evangelio en los 5 idiomas (NB: en prod Cloudflare bloquea curl; verificar con `wp eval` o navegador).
8. [ ] (Después) TTS de la carta nueva — paso 5 del ciclo, prioritario sobre el gap.
---
## 4. Scripts nuevos de esta sesión (en `scripts/`)
- `fix_carta_joomla_links.php` — enlaces Joomla `.html` → permalink WP por idioma.
- `repoint_carta_links.php` — repunta enlaces ya-permalink al idioma de la carta.
- `publish_carta.php` — draft→publish de carta+artículos+traducciones, corrige fechas futuras (timezone).
- `demote_old_cartasemana.php` — deja count=1 en `cartasemana` por idioma.
- `fetch_lectura_bolls.py` + `create_lecturas.php` — descarga e inserta pasajes bíblicos (issue #88).
- `translate_post.py` — añadido flag `FEA_ENGINE=haiku`.
## 5. Issues Gitea relacionados
- **#84** — Migrar delta carta nueva (prerequisito scripts; ya corregidos).
- **#87** — Carta no aparecía (estado `future` por timezone). **CERRADO.**
- **#88** — Lecturas bíblicas solo en ES → descargar EN/FR/IT/PT. **ABIERTO** (esta semana hecha en local; backfill histórico pendiente).
- Crear/usar el issue de **despliegue a prod** (este handoff) para el seguimiento.
@@ -0,0 +1,180 @@
# Handoff técnico para Rafa — issues #174 y #175
## Alcance
Preparación técnica solamente. **No aplicar en producción desde aquí.**
---
## Issue #174 — cierre autónomo de la carta (publicar + rotar)
### Regla funcional confirmada por Inma/Mixbot
La rotación que ya funcionó hoy manualmente por WP REST API para la carta 735 es esta, y **solo para el post ES**:
1. **nueva carta** → añadir cat **6** (`carta actual`)
2. **la que estaba en cat 6** → quitar cat 6, añadir cat **22** (`semana pasada`), manteniendo cat **21** (`archivo acumulativo`)
3. **la que estaba en cat 22** → quitar cat 22, dejar solo cat **21**
4. **no tocar traducciones** en este paso
### Decisión pendiente para Rafa
Hay dos caminos válidos:
#### Opción A — adaptar `rotate_cartas.php` a ES-only
Pros:
- reutiliza lógica ya existente y conocida en el repo
- más coherente con el toolkit actual de scripts
- fácil dejar `DRY-RUN` + `APPLY=1`
Contras:
- hay que modificar el script actual porque hoy rota todos los idiomas
- si Mixbot publica por REST y la rotación la hace otro script aparte, el flujo queda repartido en dos piezas
#### Opción B — que Mixbot haga la rotación por WP REST API
Pros:
- ya está validado en vivo hoy
- mantiene toda la operación editorial en el mismo sitio donde ya se publica la carta
- evita acoplar la decisión editorial a un script server-side adicional
Contras:
- la lógica queda fuera del repo de scripts PHP si no se documenta bien
- conviene dejarla muy explícita para no divergirse del comportamiento esperado
### Recomendación técnica
Mi lectura: **cualquiera vale**, pero elegiría según quién vaya a mantenerlo:
- si Rafa quiere la lógica **versionada y centralizada en el repo**, mejor **A: `rotate_cartas.php` ES-only**
- si Inma/Mixbot ya tienen el flujo sólido y prefieren autonomía total desde su lado, mejor **B: REST API desde Mixbot**, con la lógica documentada en el issue/runbook
### Si Rafa elige A (script ES-only), propuesta concreta
Crear una variante mínima o adaptar `rotate_cartas.php` con estas reglas:
- entrada: `CARTA=<es_id>`
- lookup solo en idioma `es`
- localizar:
- `actual_es` = post con cat 6
- `pasada_es` = post con cat 22
- `nueva_es` = `CARTA`
- operaciones:
- `pasada_es` pierde 22, conserva 21
- `actual_es` pierde 6, gana 22, conserva 21
- `nueva_es` gana 6 y conserva/gana 21
- **nunca tocar** posts EN/FR/IT/PT
- mantener modo `DRY-RUN`
### Dry-run deseable para Rafa
Salida ideal del dry-run:
- `nueva_es=#54495 -> +6 (+21 si faltaba)`
- `actual_es=#54254 -> -6 +22 (mantiene 21)`
- `pasada_es=#53984 -> -22 (mantiene 21)`
- `translations untouched`
### Decisión funcional que conviene dejar escrita
Para evitar ambigüedad futura, dejar explícito en el issue o en el script:
- `21` es acumulativa y la conservan todas las cartas archivables
- `6` y `22` son mutuamente excluyentes
- la rotación editorial inicial afecta **solo al ES**
- las traducciones rotan/publican en un paso posterior independiente
---
## Issue #175 — endpoint `subir-avatar`
### Base ya existente
El sistema actual ya tiene casi todo:
- frontend lee `user_meta('foto_perfil')`
- `face_crop_avatar.py` ya resuelve recorte/centrado de cara
- `regen_avatars.php` ya existe para regeneración/ajustes
- caso real listo para probar: **Silvia Martínez Cano**, `user_id=1112`, `slug=silvia-mtnz`
### Contrato recomendado
Yo le propondría a Rafa este contrato por simplicidad operativa:
#### Endpoint
`POST /wp-json/fea/v1/subir-avatar`
#### Auth
La misma Application Password que ya usa `crear-autor` (`#166`), con el mismo criterio de permisos.
#### Identificador
**`user_id`** mejor que `slug`.
Motivo:
- Mixbot ya lo tiene en el roster
- evita ambigüedades por slug cambiado / transliteraciones / colisiones
- simplifica el lookup
#### Formato de entrada
**multipart/form-data** mejor que base64.
Motivo:
- más natural para subir imágenes
- menos overhead
- encaja mejor con `media_handle_sideload` / manejo típico WP
- más fácil de depurar que base64
#### Campo esperado
- `user_id` (obligatorio)
- `file` (obligatorio)
Opcionalmente:
- `crop=server|client` si Rafa quiere dejar ambas puertas abiertas, aunque probablemente es overkill de entrada
### Recomendación de procesamiento
Mi recomendación clara: **que el endpoint NO haga face-crop complejo** en la primera versión.
Mejor v1:
- Mixbot/Inma mandan la imagen **ya cuadrada**
- estándar objetivo: **1254x1254** (el que ya usáis)
- el endpoint solo:
1. valida usuario
2. guarda attachment
3. actualiza `foto_perfil`
4. devuelve `attachment_id`, `user_id`, `url`
Pros:
- mucho menos riesgo y menos dependencias server-side
- evita meter OpenCV/Pillow/lógica pesada dentro del endpoint
- más fácil de testear e idempotente
- si mañana queréis face-crop automático, se añade en v2
### Comportamiento recomendado del endpoint
- requiere login + permiso tipo `edit_others_posts` (igual filosofía que `crear-autor`)
- valida que `user_id` exista
- acepta solo imagen (`jpg`, `jpeg`, `png`, `webp` si WP lo admite ahí)
- crea attachment en Media Library
- hace `update_user_meta($user_id, 'foto_perfil', $attachment_id)`
- respuesta JSON:
- `user_id`
- `display_name`
- `attachment_id`
- `url`
- `updated: true`
### Idempotencia mínima útil
No hace falta obsesionarse en v1, pero sí conviene:
- si se vuelve a subir otra foto para el mismo `user_id`, simplemente reemplazar el `foto_perfil` al nuevo attachment
- opcional: si el hash del fichero coincide con el ya asignado, devolver `updated:false`
### Caso piloto sugerido
Primera prueba con:
- `user_id=1112`
- `slug=silvia-mtnz`
- imagen ya recortada por Inma/Mixbot a **1254x1254**
Así la prueba valida solo el endpoint, no el pipeline de crop.
---
## Cierres administrativos
Por estado funcional, salvo sorpresa:
- `#166` → cerrable
- `#168` → cerrable
- `#170` → cerrable
---
## Recomendación final para Rafa
Si quiere minimizar trabajo y riesgo:
- **#174**: usar la lógica que ya validó Mixbot hoy, y solo portar al repo si él prefiere centralizar
- **#175**: endpoint mínimo con `user_id` + multipart + imagen ya cuadrada a 1254x1254
Eso deja la autonomía mucho más cerca sin meterse en una refactor rara.
+99
View File
@@ -0,0 +1,99 @@
# Plan — Buscador avanzado NATIVO (feadulta #8, sin Typesense)
> **Para el agente ejecutor (Sonnet).** Replica el «Buscador avanzado» del Joomla viejo
> con WordPress nativo + MySQL FULLTEXT. Corre en el hosting actual (sin Docker/servicios).
> Typesense queda como mejora futura opcional (`docs/plan-buscador-typesense.md`).
## ⛔ Restricciones DURAS
- **Trabaja SOLO en LOCAL** (contenedor Docker `wordpress-web`, navegable en `http://localhost:8081/`;
equivalente Tailscale `https://farmer.taild3aaf6.ts.net/fea/`).
- **NO toques producción.** No abras SSH a `feadulta@134.0.10.170`. No despliegues nada.
- **NO commitees.** Deja el working tree listo; Rafa verifica antes de subir.
- wp-cli: usa `docker exec wordpress-web wp eval '<php>' --allow-root` y `wp eval-file`.
**`wp db query` NO funciona** en este contenedor (sin binario mysql) → para SQL usa
`global $wpdb; $wpdb->query(...)` dentro de `wp eval`.
- No metas bloques Gutenberg (`<!-- wp:... -->`) en `post_content` vía CLI (se guardan literales).
- Sigue el estilo de los mu-plugins existentes (`wordpress/wp-content/mu-plugins/fea-*.php`).
## Contexto ya hecho (fase 1, MVP nativo, en prod y local)
- `mu-plugins/fea-search.php`: barra de búsqueda visible en móvil; action a la raíz del
idioma (Polylang). En desktop se usa el buscador del menú.
- Template FSE `search` (creado con `scripts/set_search_template.php`) que muestra los
resultados en **rejilla de tarjetas** reutilizando las clases `fea-archive-grid` /
`fea-archive-card` (las del `archive`, #63). En local es el wp_template post 53826.
- La búsqueda nativa `/?s=` funciona.
## Qué tenía el «Buscador avanzado» K2 (a replicar)
Cinco modos: **palabra**, **autor**, **tema (categoría)**, **cita bíblica**, **fecha**.
## Implementación
### 1. Motor: MySQL FULLTEXT
- Verifica engine/versión: `SELECT VERSION()`, engine de `wp_posts` (esperado InnoDB, MySQL ≥5.6).
- Añade índice (idempotente; comprueba antes con `SHOW INDEX ... WHERE Index_type='FULLTEXT'`):
`ALTER TABLE wp_posts ADD FULLTEXT fea_ft (post_title, post_content);`
- mu-plugin **`fea-search-fulltext.php`**: en `is_search() && is_main_query() && !is_admin()`
y con términos, sustituye el `LIKE` por
`MATCH(wp_posts.post_title, wp_posts.post_content) AGAINST ('<term>*' IN BOOLEAN MODE)`
(filtros `posts_search` + `posts_search_orderby`/`posts_clauses`), ordenando por relevancia
cuando no se pida otro orden. Sanitiza el término. Si falla/no aplica, deja el comportamiento
nativo (degradación elegante). Debe **convivir** con los filtros del punto 3 (autor, cat,
date, meta) sin romper el WHERE.
### 2. Formulario de búsqueda avanzada
- mu-plugin **`fea-search-advanced.php`** que renderiza un formulario (method=get, action a la
raíz del idioma como en `fea-search.php`) con:
- texto `s` (palabra/frase),
- `<select name="fea_author">` (autores con ≥30 posts, excluyendo `FEA_AUTORES_EXCLUIR`
= [1,890,1049,1540], orden por `display_name`),
- `<select name="fea_cat">` (categorías TEMA: 1650 Artículos, 1647 Comentarios al evangelio,
1648 Eucaristía, 1649 Multimedia, 1645 Lecturas, 1646 Comentario editorial, 63 EFFA — usa
slugs/ids reales; excluye las de carta 6/21/22),
- texto `fea_cita` (cita bíblica, ej. «Jn 3» o «Mt»),
- fechas `fea_date_from` / `fea_date_to` (o año desde/hasta).
- Muéstralo en la página de resultados (template search) arriba, con los valores seleccionados
persistentes. Añade un enlace «Búsqueda avanzada» desde la barra `fea-search`.
- Considera una página dedicada `/buscar` (page slug `buscar`) que muestre el formulario aunque
no haya consulta aún (opcional pero recomendable como destino del enlace).
### 3. Aplicar filtros — `pre_get_posts`
En el mismo mu-plugin, registra las query vars (`query_vars` filter) y en `pre_get_posts`
(`is_search`, main query, !admin):
- `fea_author``$q->set('author', (int))`.
- `fea_cat``$q->set('cat', (int))`.
- `fea_cita``meta_query` `[['key'=>'_cita_evangelio','value'=>$cita,'compare'=>'LIKE']]`
(el sitio ya tiene 4.290 metas `_cita_evangelio`).
- `fea_date_from`/`fea_date_to``date_query`.
Combinables entre sí y con la palabra (FULLTEXT). Si solo se pasan filtros sin `s`, debe
funcionar igual (listado filtrado).
### 4. UI de resultados
- Reutiliza el template `search` (rejilla `fea-archive-card`). Añade el **autor** en cada
tarjeta (byline corto) y, arriba, el formulario del punto 2 + un «N resultados» + chips de
filtros activos. Mantén la estética del sitio (carmesí #8b1a2e).
### 5. Multiidioma
- Polylang filtra por idioma (no fuerces `lang`). Action del form a la raíz del idioma actual.
- Categorías: usa las traducidas vía Polylang cuando el idioma ≠ es. Autores: los mismos.
- Textos de la UI (labels, placeholder) por idioma (array es/en/fr/it/pt; al menos es/en).
## Verificación (en local, OBLIGATORIA antes de entregar)
Usa la suite Playwright (`tools/e2e`, scripts `shot_*.cjs`; ejecuta con
`NODE_PATH=tools/e2e/node_modules node tools/e2e/<script>.cjs`, apuntando a `http://localhost:8081`).
Comprueba y captura:
1. FULLTEXT activo: una consulta (`/?s=oración`) ordena por relevancia y responde rápido.
2. Filtro **autor**: `/?s=&fea_author=<ID>` devuelve solo de ese autor.
3. Filtro **tema**: `/?fea_cat=1650`.
4. Filtro **cita bíblica**: `/?fea_cita=Jn` devuelve posts con `_cita_evangelio` que empieza por «Jn».
5. Filtro **fecha**: rango acota por fechas.
6. **Combinado**: palabra + autor.
7. **Multiidioma**: en `/en/?s=love` la UI y resultados salen en inglés.
8. Formulario visible y usable en **desktop y móvil** (capturas).
## Entregable (reporta al terminar)
- Lista de ficheros creados/modificados (mu-plugins/ y scripts/).
- Capturas de la verificación (ruta).
- Resumen de qué funciona y limitaciones.
- **Checklist de despliegue a prod** (para que lo haga Rafa después): subir los mu-plugins a
`/web/wp-nuevo/wp-content/mu-plugins/`, aplicar el `ALTER TABLE ... ADD FULLTEXT` en la BD de
prod, recrear el template/página si aplica. NO lo ejecutes tú.
+88
View File
@@ -0,0 +1,88 @@
# Plan de implementación — Buscador con Typesense (feadulta #8, fase 2)
> **Estado:** plan para ejecutar (pensado para que lo ejecute un agente Sonnet).
> **Fase 1 (MVP nativo) ya está en producción** — ver `feadulta-buscador-8` / issue #8:
> barra de búsqueda visible en móvil (`mu-plugins/fea-search.php`) + resultados en
> rejilla (`scripts/set_search_template.php`). Esta fase 2 sustituye el **motor** por
> Typesense manteniendo/мejorando esa UI, y añade **búsqueda por autor** (faceta).
>
> **DECISIÓN PENDIENTE (Rafa) — dónde corre Typesense en producción.** Todo lo demás
> se puede construir y validar en **local** sin esa decisión (Typesense en Docker local).
> Opciones de despliegue público (decidir luego): (a) PC + Cloudflare Tunnel
> `search.feadulta.com` (coste 0, atado al PC encendido, fallback al buscador nativo);
> (b) VPS pequeño dedicado ~4-6€/mes (fiable); (c) Typesense Cloud (gestionado).
> El hosting actual (cPanel compartido, sin Docker/systemd/root) **no sirve** para el motor.
## Decisiones ya tomadas
- **Integración:** Custom InstantSearch (indexador propio + UI InstantSearch.js), NO plugin.
- **Motor:** Typesense self-host en Docker.
- **Fallback:** si Typesense no responde, mantener el buscador nativo (`/?s=`) ya desplegado.
## Arquitectura
```
Navegador del usuario
└── InstantSearch.js (typesense-instantsearch-adapter) ── HTTPS ──► Typesense
(página /buscar en WP, search-only API key) (Docker)
WP (wp-nuevo) ── indexador (wp-cli/PHP) admin API key ──────────────────────┘
└── hook publish_post / cron: reindexa incremental
```
## Pasos (ejecutables por Sonnet)
### A. Typesense local de desarrollo (no depende de la decisión de infra)
1. Añadir servicio `typesense` a `docker-compose.yml` (imagen `typesense/typesense:27.x`),
volumen de datos, `--api-key` admin de desarrollo, puerto 8108 **solo en localhost**.
2. Levantar y verificar salud: `GET http://localhost:8108/health``{"ok":true}`.
### B. Schema de la colección `feadulta_posts`
Campos: `id`(post_id), `title`, `content` (texto plano, sin HTML), `excerpt`,
`author_id`(int32, facet), `author_name`(string, facet), `date`(int64, sort),
`url`, `lang`(string, facet: es/en/fr/it/pt), `categories`(string[], facet),
`thumbnail`(opcional). `default_sorting_field`: `date`.
### C. Indexador (`scripts/typesense_index.php`, wp-cli `wp eval-file`)
1. Recorre posts `publish` (`post`), excluye los template/feedback CPT.
2. Por cada post construye el documento (content = `wp_strip_all_tags`, author_name =
`display_name` del `post_author`, lang vía Polylang `pll_get_post_language`,
categories por slugs). Sube en **lotes** (import JSONL, action=upsert).
3. Idempotente, reanudable, con `--since` para incremental. ~24.778 posts.
4. Reindexado en caliente: hook `publish_post`/`save_post` → upsert de ese documento;
`before_delete_post` → delete. (Encolar para no penalizar el guardado.)
### D. UI InstantSearch (sustituye/mejora la página de resultados)
1. Página `/buscar` (o el propio template `search`) con InstantSearch.js +
`typesense-instantsearch-adapter`, apuntando a la URL pública de Typesense y la
**search-only** key (nunca la admin) — ambas configurables (constantes en un
mu-plugin `fea-search-typesense.php`, vacías hasta decidir la infra → fallback nativo).
2. Widgets: searchbox, hits (tarjetas con la estética actual `fea-archive-card`),
**refinement por autor** (faceta `author_name`), por idioma y por categoría,
paginación. Resaltado de coincidencias.
3. Multiidioma: filtrar `lang` = idioma actual por defecto; textos de UI por idioma.
4. Mantener la barra móvil de fase 1 como entrada; en desktop, integrar con el buscador
del menú.
### E. Seguridad de claves
- **Admin API key**: solo en el servidor (indexador), nunca en el front.
- **Search-only key**: restringida a la colección, embebida en el front (es segura por diseño).
- CORS de Typesense limitado al dominio del sitio.
### F. Exposición pública (BLOQUEADO por la decisión de infra)
- Opción PC+Tunnel: `cloudflared``search.feadulta.com``localhost:8108`.
- Opción VPS: Typesense en el VPS + Caddy/HTTPS en `search.feadulta.com`.
- Opción Cloud: usar el endpoint y keys que da Typesense Cloud.
- En los tres casos, la UI solo necesita la **URL pública + search-only key**.
## Verificación
- Local: `/health` ok; `typesense_index.php` indexa N docs; la página `/buscar` devuelve
resultados relevantes y la faceta de autor filtra (probar «González Amaro», «Pagola»).
- Comparar relevancia vs buscador nativo en consultas reales del feedback
(«reflexiones», búsqueda por autor).
- Prod: tras exponer el motor, repetir desde un navegador (Cloudflare bloquea headless).
## Riesgos / notas
- **Disponibilidad** si corre en el PC: caídas → debe degradar al buscador nativo, no a error.
- **RAM**: Typesense mantiene el índice en memoria; 24.778 posts de texto entran de sobra
en <1 GB, pero dimensionar el host en consecuencia.
- **Reindexado masivo inicial** puede tardar; hacerlo por lotes y fuera de hora punta.
- No exponer nunca la admin key; CORS restringido.
@@ -0,0 +1,55 @@
# API de subida de avatar (#175) — Plan de implementación
> **For Hermes:** implementar por pasos pequeños, con prueba de integración local antes de tocar el código de producción.
**Objetivo:** permitir que Mixbot/Inma asigne o reemplace de forma segura la foto de un autor mediante `POST /wp-json/fea/v1/subir-avatar`, sin depender del wp-admin ni de Rafa.
**Arquitectura:** un mu-plugin nuevo y autónomo en `mu-plugins/`, paralelo a `fea-crear-autor-api.php`. Reutiliza el mismo modelo de autenticación (Application Password autenticada + capacidad `edit_others_posts`). Recibe un `multipart/form-data` con `user_id` exacto e imagen en el campo `avatar`; valida, normaliza a un cuadrado de 512 px, crea el attachment de WordPress y actualiza únicamente el meta ACF `foto_perfil`.
**Decisiones deliberadas de contrato:**
- **Multipart**, no base64: evita codificación innecesaria y usa el manejo seguro nativo de uploads de WordPress.
- **`user_id` obligatorio**, no slug: evita ambigüedades al asignar una imagen a una persona.
- **JPEG, PNG y WebP; máximo 5 MB; mínimo 512×512 px; máximo 4096 px por lado y 16 megapíxeles.**
- Se conserva el attachment previo; la respuesta devuelve `previous_attachment_id` para rollback manual. No se borra ningún avatar anterior.
- La imagen se recorta centrada y se normaliza a **512×512 px** en el servidor.
**Alcance de seguridad:** el endpoint no permite elegir meta, ruta de filesystem, MIME arbitrario ni otro usuario distinto del `user_id` explícito. Requiere autenticación y permiso de editor o superior.
---
### Task 1: Añadir prueba de integración local para el contrato vacío
**Files:**
- Create: `tests/integration/test_subir_avatar_api.sh`
**Step 1:** probar contra el WordPress Docker local que la ruta no está disponible antes de cargar el nuevo mu-plugin.
**Step 2:** el script debe cubrir, tras cargar el plugin: falta de autenticación (401), falta de `user_id` (400), usuario inexistente (404), falta de fichero (400) y una subida correcta a un usuario temporal.
**Step 3:** el caso correcto debe verificar respuesta, attachment creado, meta `foto_perfil` actualizado y que el attachment anterior no se elimina. Debe limpiar el usuario/attachment temporal al terminar.
### Task 2: Implementar el mu-plugin mínimo
**Files:**
- Create: `mu-plugins/fea-subir-avatar-api.php`
**Step 1:** registrar `POST /fea/v1/subir-avatar` y reutilizar una callback de autorización equivalente a la de `crear-autor`.
**Step 2:** validar `user_id` y el fichero `avatar` antes de persistir nada.
**Step 3:** procesar la imagen con APIs nativas de WordPress, normalizarla a 512×512 y crear su attachment con metadata.
**Step 4:** actualizar exclusivamente `foto_perfil` y devolver ID del usuario, attachment nuevo, attachment anterior y URL.
### Task 3: Ejecutar integración local y revisión de seguridad
**Files:**
- Modify only if a test demuestra una necesidad real.
**Step 1:** copiar temporalmente el mu-plugin y el script de prueba a la instalación WordPress Docker local. No tocar producción.
**Step 2:** ejecutar la prueba y verificar que falla de forma esperada antes de implementar, y pasa después.
**Step 3:** ejecutar `php -l` sobre el mu-plugin y revisar `git diff --check` / `git diff`.
**Step 4:** dejar los cambios solo en la rama aislada `feat/subir-avatar-175`; no hacer push, PR ni despliegue sin aprobación explícita de Rafa.
+94
View File
@@ -0,0 +1,94 @@
# Revisión rápida de issues mencionados por Inma/Rafa — 2026-07-15
## Alcance
Revisión en modo **diagnóstico + preparación**. **Sin cambios en producción**.
## Observación importante sobre numeración
En el repo actual de Gitea `rafa/feadulta` los issues llegan hoy hasta **#146**. Por tanto, las referencias del mensaje (`#166`, `#168`, `#170`, `#174`, `#175`) no existen en el tracker actual y probablemente pertenecen a una numeración anterior o a notas habladas.
Para no quedarnos bloqueados, revisé los issues **actuales y más cercanos al tema de autonomía / operación**:
- `#144` Cron servidor: auto-traducción (Haiku) + TTS (MiniMax)
- `#145` Habilitar acceso a la REST API para publicar cartas
- `#146` Propuesta: arquitectura multiagente con Ringer, Goose, OB1, Hermes y OpenClaw
- además `#126` y el histórico `#121` / `#125` por dependencia operativa
## Hallazgos verificados
### 1) Issue #126 — seguridad login post-cutover
Verificación server-side en prod (`/web`) hecha por SSH + `wp eval`:
- `llar_active=1`
- `mu_exists=1`
- mu-plugin presente: `fea-cloudflare-realip.php`
Conclusión:
- **La parte operativa del checklist parece ya presente en prod.**
- El issue probablemente necesita **actualización/cierre**, no trabajo técnico urgente.
### 2) Gap antiguo de repo sobre `fea-cloudflare-realip.php`
El comentario viejo de `#126` decía que el mu-plugin no estaba trackeado en git.
Estado actual en el repo local:
- Sí existe: `wordpress/wp-content/mu-plugins/fea-cloudflare-realip.php`
Conclusión:
- Ese comentario ya quedó **desactualizado**.
### 3) Issue #144 — base técnica para autonomía traducción/TTS
Comprobado en el repo: existen los bloques principales mencionados por el issue:
- `scripts/detect_untranslated.php`
- `scripts/translate_post.py`
- `scripts/fix_carta_joomla_links.php`
- `scripts/demote_old_cartasemana.php`
- `scripts/minimax_tts.py`
- `scripts/sync_audio_to_prod.py`
Además ejecuté el detector en el **WordPress local Docker** (no en prod):
#### Ejecución local
`docker exec wordpress-web php /tmp/detect_untranslated.php 0.12 draft`
- Resultado: **0 ofensores draft**
`docker exec wordpress-web php /tmp/detect_untranslated.php 0.12 any`
- Resultado: **12 ofensores sospechosos** sobre publicados/cualquier estado
- Resumen por idioma:
- `en: 4/1148`
- `fr: 3/1148`
- `it: 3/1147`
- `pt: 2/1147`
IDs señalados por el detector:
- `47978, 47981, 47980, 47979`
- `47756, 47153`
- `54304, 47285, 54307, 43278, 54306, 54305`
Notas útiles:
- El script **no corre bien desde host** porque exige `/var/www/html/wp-load.php`; hay que lanzarlo dentro del contenedor.
- Esto es buena pista para futura automatización en `#144`: ya hay piezas, pero conviene empaquetarlas en un wrapper reproducible y con contexto de ejecución claro.
### 4) Issue #145 — REST API para Inma
No hice verificación externa definitiva porque eso requiere:
- credencial real de aplicación, y
- prueba end-to-end frente a Cloudflare
Estado documental actual:
- la skill y la documentación operativa indican que la REST API **ya fue habilitada para Inma**, pero no he revalidado hoy ese punto desde fuera.
Conclusión:
- Antes del viaje a Madrid conviene hacer un **smoke test real** (`/wp-json/wp/v2/users/me`) desde fuera del server.
### 5) Issue #146 — autonomía / multiagente
Lo revisado aquí es principalmente discusión/arquitectura; no detecté una acción local obvia de código en este repo que mereciera tocar hoy sin alinear primero el objetivo.
## Recomendación práctica
1. **Aclarar la numeración** de `#166/#168/#170/#174/#175` para no revisar los issues equivocados.
2. Si los “nuevos de autonomía” eran realmente los del tracker actual, yo priorizaría así:
- `#145`: verificar de verdad que Inma puede publicar sin Rafa delante.
- `#144`: encapsular el flujo traducción/TTS en un wrapper/cron operativo.
- `#146`: dejarlo como diseño/roadmap, no como siguiente cambio técnico directo.
3. `#126` huele a **issue de limpieza/cierre** si nadie ve un fleco pendiente.
## Qué NO hice
- No apliqué cambios en producción.
- No toqué Joomla legacy.
- No abrí/cerré/edité issues en Gitea.
- No modifiqué código del repo en esta revisión.
@@ -0,0 +1,155 @@
# Segunda pasada — issues correctos en Gitea vivo (`gitea.feadulta.com`) — 2026-07-15
## Alcance
Revisión en modo **diagnóstico + preparación** sobre la instancia viva:
- Repo: `https://gitea.feadulta.com/rafa/feadulta`
- Issues revisados: `#166`, `#168`, `#170`, `#174`, `#175`
- **Sin cambios en producción**
- **Sin edición de issues**
## Corrección del desajuste anterior
La pasada previa consultó el Gitea local archivado (`localhost:3000`), que se queda en `#146`. La fuente correcta para el trabajo actual es **`gitea.feadulta.com`**, tal como ya reflejan:
- la skill `feadulta-editorial-workflows`
- `feadulta/webmaster/references/environment.md`
## Estado por issue
### `#166` — Alta de autores / `crear-autor`
**Estado funcional real:** resuelto e integrado.
Verificado en el issue vivo:
- Rafa comentó que decidió la **opción B** (endpoint acotado).
- Rafa comentó después: **"Desplegado en prod y verificado en vivo"**.
- Inma comentó el 14-jul que ya está **integrado en Mixbot**.
Verificado además en el repo local:
- existe `wordpress/wp-content/mu-plugins/fea-crear-autor-api.php`
- registra `POST /wp-json/fea/v1/crear-autor`
- fuerza rol fijo `author`
- es **idempotente** si el slug ya existe
Conclusión:
- **No hay trabajo técnico pendiente aquí**.
- Si queréis limpieza de tablero, este issue ya está para **cerrar** cuando Rafa quiera.
---
### `#168` — Bots en Joomla viejo / Cloudflare
**Estado funcional real:** resuelto lado Cloudflare, aunque el issue sigue abierto.
Verificado en el issue vivo:
- el body ya documenta la parte técnica previa (cache Joomla activada y diagnóstico del fatal)
- Inma añadió comentario 14-jul: **"HECHO y verificado"** ajustando una regla existente de Cloudflare por el límite de 5 custom rules del plan free
Conclusión:
- Operativamente está **resuelto**.
- Si no queda ningún fleco de observabilidad, este issue también está para **cerrar**.
---
### `#170` — Crawlers sociales / preview Facebook
**Estado funcional real:** resuelto lado Cloudflare, aunque el issue sigue abierto.
Verificado en el issue vivo:
- Inma comentó 14-jul que ya existía una regla **"Permitir Facebook"** y que el crawler de Facebook ya pasa con **200**
Conclusión:
- Operativamente está **resuelto**.
- Igual que `#168`, parece issue de **cierre administrativo**, no técnico.
---
### `#174` — Cierre autónomo de la carta (publicar + rotar)
**Estado funcional real:** abierto, esperando OK de Rafa. Aquí sí hay trabajo útil preparado, pero no conviene aplicar nada aún.
Lo importante que he verificado localmente:
#### 1) Ya existe lógica de rotación en scripts
En el repo local existe:
- `scripts/rotate_cartas.php`
- `scripts/demote_old_cartasemana.php`
`rotate_cartas.php` implementa esta cascada:
1. la que estaba en "semana pasada" → queda solo en "otras semanas"
2. la que estaba en "semana actual" → pasa a "semana pasada"
3. la nueva → pasa a "semana actual"
#### 2) Pero la implementación actual rota **todos los idiomas**, no solo ES
Esto es importante porque en `#174` justo se pregunta si:
- **solo se rota ES al cerrar la carta**, y
- las traducciones **no se rotan** hasta que estén publicadas
El script actual **no sigue ese criterio**: deriva y actúa sobre `es/en/fr/it/pt`.
#### 3) Dry-run local ejecutado
He ejecutado `rotate_cartas.php` en el WordPress local Docker en modo dry-run.
Resultado:
- el script **funciona** como dry-run
- pero el espejo local **no está alineado con el caso exacto de la carta 735 en ES** (el mirror local va por otro estado / otra numeración viva en esa parte)
- por tanto, **sirve para validar la mecánica del script**, pero **no para afirmar que ya resuelva `#174` tal cual**
Conclusión técnica:
- `#174` **no es greenfield**: ya hay base.
- Pero **hay que adaptar** la lógica si la decisión final es "rotar solo ES y dejar derivados/traducciones para después".
- No he preparado parche porque todavía falta el **OK funcional de Rafa**, y sin eso sería fácil codificar la lógica equivocada.
Recomendación:
- cuando Rafa confirme la regla exacta, el siguiente paso bueno es preparar un **wrapper ES-only + dry-run** en local, en vez de reaprovechar `rotate_cartas.php` tal cual.
---
### `#175` — Endpoint `subir-avatar`
**Estado funcional real:** abierto, esperando OK de Rafa. También tiene muy buena base técnica ya hecha.
Lo verificado localmente:
#### 1) El frontend ya usa `foto_perfil`
En `wordpress/wp-content/mu-plugins/fea-homepage.php`:
- el avatar del autor usa el meta de usuario **`foto_perfil`**
- ese meta guarda un attachment ID
#### 2) Ya existen las piezas de procesamiento
En el repo hay:
- `scripts/face_crop_avatar.py`
- `scripts/regen_avatars.php`
O sea: el flujo de recorte / regeneración **ya existe**; faltaría encapsularlo detrás de un endpoint seguro.
#### 3) El caso concreto mencionado en el issue existe y está sin foto en local
Verificado en el WP local:
- usuario `1112` existe
- login: `silvia-mtnz`
- display: `Silvia Martínez Cano`
- `foto_perfil` está vacío
Conclusión técnica:
- `#175` tampoco es greenfield.
- El endpoint encaja bien como hermano de `fea/v1/crear-autor` (`#166`).
- La decisión pendiente no es "si se puede", sino **qué contrato exacto quiere Rafa**:
- `user_id` vs `slug`
- multipart vs base64
- si el endpoint recorta o exige imagen ya preparada
Recomendación:
- en cuanto Rafa dé el OK de contrato, el trabajo lógico es clonar el patrón de `fea-crear-autor-api.php` y dejar un endpoint **mínimo, acotado e idempotente**.
## Resumen ejecutivo
- `#166`: **hecho**
- `#168`: **hecho** ✅ pero sigue abierto
- `#170`: **hecho** ✅ pero sigue abierto
- `#174`: **esperando OK funcional de Rafa**; hay base técnica, pero la actual rota todos los idiomas
- `#175`: **esperando OK funcional de Rafa**; hay base técnica muy clara y el caso Silvia está identificado
## Qué hice en esta pasada
- Reapunté la revisión a `gitea.feadulta.com`
- Leí los issues correctos por API pública
- Verifiqué localmente la existencia del endpoint `crear-autor`
- Ejecuté un **dry-run** de la lógica de rotación existente
- Verifiqué en local el caso `Silvia Martínez Cano / 1112 / foto_perfil vacío`
## Qué NO hice
- No toqué producción
- No cerré issues
- No comenté en Gitea
- No modifiqué código del repo
+415
View File
@@ -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);
+62 -12
View File
@@ -13,11 +13,23 @@ function fea_audio_player_html(): string {
$url = get_post_meta($id, 'fea_audio_url', true);
if (!$url) return '';
$voice = get_post_meta($id, 'fea_audio_voice', true) ?: 'NicoFeadulta2026';
return '<div class="fea-audio">'
. '<span class="fea-audio-label">'
$sha256 = get_post_meta($id, 'fea_audio_sha256', true);
$label = '<span class="fea-audio-label">'
. '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">'
. '<path fill="currentColor" d="M3 10v4h4l5 5V5L7 10H3zm13.5 2a4.5 4.5 0 0 0-2.5-4.03v8.06A4.5 4.5 0 0 0 16.5 12zM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54z"/>'
. '</svg> Escucha</span>'
. '</svg> Escucha</span>';
// Para un asset con hash registrado, descarga y verifica el fichero completo
// antes de delegar playback al navegador. Evita que un stream Range mezclado
// por una caché reproduzca fragmentos de otro MP3.
if (preg_match('/^[a-f0-9]{64}$/i', $sha256)) {
return '<div class="fea-audio">' . $label
. '<button type="button" class="fea-audio-verified-play" data-fea-audio-verified-play'
. ' data-src="' . esc_url($url) . '" data-sha256="' . esc_attr(strtolower($sha256)) . '"'
. ' data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '">Reproducir audio</button>'
. '<audio controls preload="none" hidden data-fea-audio-track data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '"></audio>'
. '<span class="fea-audio-status" role="status" aria-live="polite"></span></div>';
}
return '<div class="fea-audio">' . $label
. '<audio controls preload="none" src="' . esc_url($url) . '"'
. ' data-fea-audio-track data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '"></audio>'
. '</div>';
@@ -56,6 +68,9 @@ add_action('wp_head', function () {
.fea-audio-label{display:inline-flex;align-items:center;gap:.35rem;font-size:.78rem;
font-weight:600;color:#8b1a2e;white-space:nowrap;line-height:1}
.fea-audio audio{height:32px;width:230px;max-width:44vw}
.fea-audio-verified-play{border:1px solid #8b1a2e;border-radius:6px;background:#fff;color:#8b1a2e;padding:.35rem .6rem;font:inherit;font-size:.82rem;font-weight:600;cursor:pointer}
.fea-audio-verified-play:disabled{opacity:.65;cursor:wait}
.fea-audio-status{font-size:.76rem;color:#6b5c60}
@media(max-width:600px){
.fea-audio{margin-left:0;width:100%;margin-top:.5rem}
.fea-audio audio{flex:1 1 auto;width:auto;max-width:none}
@@ -73,17 +88,52 @@ add_action('wp_footer', function () {
if (!get_post_meta(get_queried_object_id(), 'fea_audio_url', true)) return;
?>
<script>
document.querySelectorAll('audio[data-fea-audio-track]').forEach(function (audio) {
var fired = false;
audio.addEventListener('play', function () {
if (fired || typeof gtag !== 'function') return;
fired = true;
gtag('event', 'audio_play', {
post_id: audio.dataset.postId,
voice: audio.dataset.voice
(function () {
function hex(buffer) {
return Array.from(new Uint8Array(buffer)).map(function (n) {
return n.toString(16).padStart(2, '0');
}).join('');
}
document.querySelectorAll('[data-fea-audio-verified-play]').forEach(function (button) {
button.addEventListener('click', async function () {
var wrap = button.closest('.fea-audio');
var audio = wrap.querySelector('audio[data-fea-audio-track]');
var status = wrap.querySelector('.fea-audio-status');
button.disabled = true;
status.textContent = 'Verificando audio…';
try {
// Se descarga el objeto entero una vez y se valida su hash. Sólo
// entonces se crea un blob local: el <audio> no hará Range contra
// CDN y jamás podrá mezclar fragmentos de otro MP3.
var response = await fetch(button.dataset.src, {cache: 'no-store'});
if (!response.ok) throw new Error('HTTP ' + response.status);
var bytes = await response.arrayBuffer();
var digest = hex(await crypto.subtle.digest('SHA-256', bytes));
if (digest !== button.dataset.sha256) throw new Error('La copia recibida no coincide con el audio verificado');
audio.src = URL.createObjectURL(new Blob([bytes], {type: 'audio/mpeg'}));
audio.hidden = false;
button.hidden = true;
status.textContent = '';
await audio.play();
} catch (error) {
status.textContent = 'No se pudo verificar el audio. Inténtalo de nuevo.';
button.disabled = false;
console.error('Fe Adulta audio verification failed:', error);
}
});
});
});
document.querySelectorAll('audio[data-fea-audio-track]').forEach(function (audio) {
var fired = false;
audio.addEventListener('play', function () {
if (fired || typeof gtag !== 'function') return;
fired = true;
gtag('event', 'audio_play', {
post_id: audio.dataset.postId,
voice: audio.dataset.voice
});
});
});
}());
</script>
<?php
});
+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([
+247
View File
@@ -0,0 +1,247 @@
<?php
/**
* Plugin Name: Fe Adulta — API subir avatar
* Description: Endpoint REST acotado para asignar la foto de perfil de un autor.
* Version: 1.0
*
* POST /wp-json/fea/v1/subir-avatar
* multipart/form-data: user_id=<id>, avatar=<imagen JPEG|PNG|WebP>
*
* Ver issue gitea.feadulta.com/rafa/feadulta#175.
*/
if (!defined('ABSPATH')) exit;
const FEA_AVATAR_MAX_BYTES = 5242880; // 5 MiB.
const FEA_AVATAR_SIZE = 512;
const FEA_AVATAR_MIN_SIZE = 512;
const FEA_AVATAR_MAX_DIMENSION = 4096;
const FEA_AVATAR_MAX_PIXELS = 16777216; // 16 megapíxeles.
add_action('rest_api_init', function () {
register_rest_route('fea/v1', '/subir-avatar', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'fea_subir_avatar_handle',
'permission_callback' => 'fea_subir_avatar_can_call',
// Se valida dentro del handler, después del permission_callback: una
// llamada sin autenticar siempre recibe 401, incluso si omite user_id.
'args' => [
'user_id' => [
'sanitize_callback' => 'absint',
],
],
]);
});
/** Editor o superior: mismo nivel que el endpoint /crear-autor. */
function fea_subir_avatar_can_call(WP_REST_Request $request) {
if (!is_user_logged_in()) {
return new WP_Error(
'fea_subir_avatar_not_authenticated',
'Debes autenticarte para asignar un avatar.',
['status' => 401]
);
}
if (!current_user_can('edit_others_posts')) {
return new WP_Error(
'fea_subir_avatar_forbidden',
'No tienes permiso para asignar avatares.',
['status' => 403]
);
}
return true;
}
function fea_subir_avatar_handle(WP_REST_Request $request) {
$user_id = absint($request->get_param('user_id'));
if (!$user_id) {
return new WP_Error(
'fea_subir_avatar_invalid_user',
'user_id es obligatorio.',
['status' => 400]
);
}
$user = get_userdata($user_id);
if (!$user) {
return new WP_Error(
'fea_subir_avatar_user_not_found',
'No existe el usuario indicado.',
['status' => 404]
);
}
$files = $request->get_file_params();
$file = $files['avatar'] ?? null;
$validation = fea_subir_avatar_validate_file($file);
if (is_wp_error($validation)) return $validation;
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
$uploaded = wp_handle_upload($file, [
'test_form' => false,
'mimes' => [
'jpg|jpeg|jpe' => 'image/jpeg',
'png' => 'image/png',
'webp' => 'image/webp',
],
]);
if (!empty($uploaded['error'])) {
return new WP_Error(
'fea_subir_avatar_upload_failed',
'No se pudo guardar la imagen: ' . $uploaded['error'],
['status' => 400]
);
}
$normalized = fea_subir_avatar_normalize($uploaded['file']);
if (is_wp_error($normalized)) {
wp_delete_file($uploaded['file']);
return $normalized;
}
$attachment_id = wp_insert_attachment([
'post_mime_type' => $normalized['mime-type'],
'post_title' => 'Avatar — ' . $user->display_name,
'post_status' => 'inherit',
], $normalized['path'], 0, true);
if (is_wp_error($attachment_id)) {
wp_delete_file($normalized['path']);
return new WP_Error(
'fea_subir_avatar_attachment_failed',
'No se pudo crear el attachment del avatar.',
['status' => 500]
);
}
$metadata = wp_generate_attachment_metadata($attachment_id, $normalized['path']);
if (is_wp_error($metadata) || !is_array($metadata) || !$metadata) {
wp_delete_attachment($attachment_id, true);
return new WP_Error(
'fea_subir_avatar_metadata_failed',
'No se pudo generar la metadata del avatar.',
['status' => 500]
);
}
wp_update_attachment_metadata($attachment_id, $metadata);
if (!wp_get_attachment_metadata($attachment_id)) {
wp_delete_attachment($attachment_id, true);
return new WP_Error(
'fea_subir_avatar_metadata_failed',
'No se pudo guardar la metadata del avatar.',
['status' => 500]
);
}
$previous_attachment_id = (int) get_user_meta($user_id, 'foto_perfil', true);
if (!update_user_meta($user_id, 'foto_perfil', (string) $attachment_id)) {
wp_delete_attachment($attachment_id, true);
return new WP_Error(
'fea_subir_avatar_assignment_failed',
'No se pudo asignar el avatar al usuario.',
['status' => 500]
);
}
$size = wp_getimagesize($normalized['path']);
return new WP_REST_Response([
'user_id' => $user_id,
'attachment_id' => (int) $attachment_id,
'previous_attachment_id' => $previous_attachment_id ?: null,
'avatar_url' => wp_get_attachment_image_url($attachment_id, 'full'),
'width' => (int) ($size[0] ?? 0),
'height' => (int) ($size[1] ?? 0),
], 201);
}
function fea_subir_avatar_validate_file($file) {
if (!is_array($file) || empty($file['tmp_name']) || !isset($file['error'])) {
return new WP_Error(
'fea_subir_avatar_missing_file',
'avatar es obligatorio.',
['status' => 400]
);
}
if ((int) $file['error'] !== UPLOAD_ERR_OK) {
return new WP_Error(
'fea_subir_avatar_file_error',
'La subida de avatar falló.',
['status' => 400]
);
}
if ((int) $file['size'] > FEA_AVATAR_MAX_BYTES) {
return new WP_Error(
'fea_subir_avatar_file_too_large',
'La imagen no puede superar 5 MB.',
['status' => 400]
);
}
$image = wp_getimagesize($file['tmp_name']);
if (!$image) {
return new WP_Error(
'fea_subir_avatar_invalid_image',
'avatar debe ser una imagen válida.',
['status' => 400]
);
}
$width = (int) $image[0];
$height = (int) $image[1];
if ($width < FEA_AVATAR_MIN_SIZE || $height < FEA_AVATAR_MIN_SIZE) {
return new WP_Error(
'fea_subir_avatar_invalid_image',
'avatar debe ser una imagen de al menos 512×512 píxeles.',
['status' => 400]
);
}
if ($width > FEA_AVATAR_MAX_DIMENSION || $height > FEA_AVATAR_MAX_DIMENSION || ($width * $height) > FEA_AVATAR_MAX_PIXELS) {
return new WP_Error(
'fea_subir_avatar_image_too_large',
'avatar no puede superar 4096 píxeles por lado ni 16 megapíxeles.',
['status' => 400]
);
}
$allowed_mimes = ['image/jpeg', 'image/png', 'image/webp'];
if (!in_array($image['mime'], $allowed_mimes, true)) {
return new WP_Error(
'fea_subir_avatar_unsupported_type',
'Solo se admiten imágenes JPEG, PNG o WebP.',
['status' => 400]
);
}
return true;
}
/** Recorta al centro y normaliza la imagen al tamaño estándar del avatar. */
function fea_subir_avatar_normalize(string $path) {
$editor = wp_get_image_editor($path);
if (is_wp_error($editor)) {
return new WP_Error(
'fea_subir_avatar_editor_unavailable',
'No se pudo procesar la imagen subida.',
['status' => 400]
);
}
$resized = $editor->resize(FEA_AVATAR_SIZE, FEA_AVATAR_SIZE, true);
if (is_wp_error($resized)) {
return new WP_Error(
'fea_subir_avatar_resize_failed',
'No se pudo normalizar el avatar.',
['status' => 400]
);
}
$saved = $editor->save($path);
if (is_wp_error($saved) || empty($saved['path'])) {
return new WP_Error(
'fea_subir_avatar_save_failed',
'No se pudo guardar el avatar normalizado.',
['status' => 500]
);
}
return $saved;
}
+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)
+57 -18
View File
@@ -3,9 +3,9 @@
sync_audio_to_prod.py — Sube a PROD los mp3 de TTS ya generados/enlazados en
local (fea_audio_done=1) y fija el meta fea_audio_url en prod.
Prod (134.0.10.170) tiene glibc rota: scp/sftp NO funcionan (connection closed).
Workaround: subir el binario por stdin de ssh ("cat > ruta"), igual que el resto
de scripts que tocan ese servidor (ver feadulta-server-glibc-rota.md).
Prod vive en Hetzner (Coolify/Docker) desde el cutover de agosto 2026. Subida
del binario por stdin de ssh ("cat > ruta" dentro del contenedor vía
`docker exec -i` si FEA_PROD_DOCKER_CONTAINER está definido).
Uso:
python3 sync_audio_to_prod.py --carta 54254 # sincroniza toda la cola de la carta
@@ -19,6 +19,7 @@ Rollback (despublica en prod lo que este script publicó):
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
@@ -31,11 +32,14 @@ DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
# define, el helper y el mp3 se suben/ejecutan dentro del contenedor.
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
PROD_HELPER = "/tmp/fea_post_io.php"
PROD_UPLOADS_TTS = "/web/wp-content/uploads/tts"
PROD_UPLOADS_TTS = os.environ.get("FEA_PROD_UPLOADS_TTS", "/var/www/html/wp-content/uploads/tts")
HELPER_SRC = Path(__file__).resolve().parent / "fea_post_io.php"
LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
@@ -85,19 +89,47 @@ def carta_article_ids(carta_id: int) -> list[int]:
return [int(x) for x in r.stdout.split() if x.isdigit()]
# ── Prod (glibc rota: nada de scp/sftp, todo por ssh + cat) ────────────────────
# ── Prod (Hetzner/Docker: todo por ssh, mp3 por stdin de "cat") ────────────────
def _ssh_text(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
# El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
# solo se antepone si hay contraseña configurada (servidor viejo CDMON).
if PROD_PASS:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
else:
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
if r.returncode != 0:
raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}\n{r.stderr.strip()[:400]}")
return r.stdout
def sh_quote(s: str) -> str:
return "'" + s.replace("'", "'\\''") + "'"
def _remote_wrap(inner_cmd: str) -> str:
"""Envuelve un comando para que corra dentro del contenedor Docker de prod
si FEA_PROD_DOCKER_CONTAINER está definido; si no, corre en el host tal cual.
El wrapping (incluidas redirecciones como `< ruta`) debe quedar DENTRO de la
shell del contenedor (`sh -c '...'`): `docker exec CONTENEDOR cmd < ruta`
resuelve esa redirección en el filesystem del HOST, no en el contenedor.
"""
if PROD_DOCKER_CONTAINER:
return f"docker exec -i {PROD_DOCKER_CONTAINER} sh -c {sh_quote(inner_cmd)}"
return inner_cmd
def _ssh_upload_bytes(data: bytes, remote_path: str, *, timeout: int = 180) -> None:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, f"cat > {remote_path}"]
remote_cmd = _remote_wrap(f"cat > {remote_path}")
if PROD_PASS:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
else:
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
sh(cmd, input_bytes=data, timeout=timeout)
@@ -110,22 +142,28 @@ def prod_helper(subcmd: str, *args: str) -> str:
_ssh_upload_bytes(HELPER_SRC.read_bytes(), PROD_HELPER)
_prod_helper_ready = True
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
return _ssh_text(inner, timeout=60)
return _ssh_text(_remote_wrap(inner), timeout=60)
def prod_upload_mp3(post_id: int) -> None:
def prod_upload_mp3(post_id: int) -> str:
src = LOCAL_TTS_DIR / f"{post_id}.mp3"
data = src.read_bytes()
local_sha256 = hashlib.sha256(data).hexdigest()
remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3"
_ssh_upload_bytes(data, remote_path)
# Verificación de tamaño (glibc rota => sin fiarse ciegamente del rc=0 de ssh)
remote_size = int(_ssh_text(f"wc -c < {remote_path}").strip())
# Verificación de tamaño+contenido: un rc=0 de ssh no acredita que el asset
# sea el mismo. El hash se persiste como meta y permite playback verificado.
remote_size = int(_ssh_text(_remote_wrap(f"wc -c < {remote_path}")).strip())
if remote_size != len(data):
raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}")
remote_sha256 = _ssh_text(_remote_wrap(f"sha256sum {remote_path} | cut -d' ' -f1")).strip()
if remote_sha256 != local_sha256:
raise RuntimeError(f"SHA-256 no coincide tras subir #{post_id}: local={local_sha256} remoto={remote_sha256}")
return local_sha256
def prod_remove_mp3(post_id: int) -> None:
_ssh_text(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3")
_ssh_text(_remote_wrap(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3"))
# ── Estado ───────────────────────────────────────────────────────────────────
@@ -153,8 +191,9 @@ def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
return "PLAN: subiría mp3 + setaudio"
voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
prod_upload_mp3(post_id)
sha256 = prod_upload_mp3(post_id)
prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice)
prod_helper("setflag", str(post_id), "fea_audio_sha256", sha256)
if post_id not in state["synced"]:
state["synced"].append(post_id)
save_state(state)
+34 -7
View File
@@ -44,7 +44,10 @@ WP_CONTAINER = os.environ.get("FEA_WP_CONTAINER", "wordpress-web")
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
# define, el helper se sube y se ejecuta dentro del contenedor.
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
PROD_HELPER = "/tmp/fea_translate_helper.php"
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
@@ -78,18 +81,41 @@ _prod_ready = False
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
# El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
# solo se antepone si hay contraseña configurada (servidor viejo CDMON).
if PROD_PASS:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
else:
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
return sh(cmd, stdin=stdin, timeout=timeout)
def sh_quote(s: str) -> str:
return "'" + s.replace("'", "'\\''") + "'"
def _remote_wrap(inner_cmd: str) -> str:
"""Envuelve un comando para que corra dentro del contenedor Docker de prod
si FEA_PROD_DOCKER_CONTAINER está definido; si no, corre en el host tal cual.
El wrapping (incluidas redirecciones como `< ruta`) debe quedar DENTRO de la
shell del contenedor (`sh -c '...'`): `docker exec CONTENEDOR cmd < ruta`
resuelve esa redirección en el filesystem del HOST, no en el contenedor.
"""
if PROD_DOCKER_CONTAINER:
return f"docker exec -i {PROD_DOCKER_CONTAINER} sh -c {sh_quote(inner_cmd)}"
return inner_cmd
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
global _prod_ready
if not _prod_ready:
_ssh(f"cat > {PROD_HELPER}", stdin=HELPER_SRC.read_text(encoding="utf-8"))
_ssh(_remote_wrap(f"cat > {PROD_HELPER}"), stdin=HELPER_SRC.read_text(encoding="utf-8"))
_prod_ready = True
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
return _ssh(inner, stdin=stdin, timeout=180)
return _ssh(_remote_wrap(inner), stdin=stdin, timeout=180)
def prod_read_full(post_id: int) -> dict:
@@ -234,9 +260,10 @@ def main() -> int:
ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no escribe en local.")
args = ap.parse_args()
if not PROD_HOST or not PROD_PASS:
if not PROD_HOST:
raise SystemExit(
"Faltan FEA_PROD_SSH_HOST / FEA_PROD_SSH_PASS en el entorno.\n"
"Falta FEA_PROD_SSH_HOST en el entorno (FEA_PROD_SSH_PASS es opcional: "
"el Hetzner nuevo usa auth por clave).\n"
"Antes de ejecutar: source ~/.hermes/profiles/feadulta/.env"
)
+33 -7
View File
@@ -29,9 +29,12 @@ DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
# define, el helper se sube y se ejecuta dentro del contenedor.
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
PROD_HELPER = "/tmp/fea_translate_helper.php"
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
@@ -159,18 +162,41 @@ _prod_ready = False
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
# El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
# solo se antepone si hay contraseña configurada (servidor viejo CDMON).
if PROD_PASS:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
else:
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
return sh(cmd, stdin=stdin, timeout=timeout)
def _remote_wrap(inner_cmd: str) -> str:
"""Envuelve un comando para que corra dentro del contenedor Docker de prod
si FEA_PROD_DOCKER_CONTAINER está definido; si no, corre en el host tal cual.
El wrapping (incluidas redirecciones como `< ruta`) debe quedar DENTRO de la
shell del contenedor (`sh -c '...'`): `docker exec CONTENEDOR cmd < ruta`
resuelve esa redirección en el filesystem del HOST, no en el contenedor.
"""
if PROD_DOCKER_CONTAINER:
return f"docker exec -i {PROD_DOCKER_CONTAINER} sh -c {sh_quote(inner_cmd)}"
return inner_cmd
def sh_quote(s: str) -> str:
return "'" + s.replace("'", "'\\''") + "'"
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
global _prod_ready
if not _prod_ready:
_ssh(f"cat > {PROD_HELPER}", stdin=HELPER_SRC.read_text(encoding="utf-8"))
_ssh(_remote_wrap(f"cat > {PROD_HELPER}"), stdin=HELPER_SRC.read_text(encoding="utf-8"))
_prod_ready = True
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
return _ssh(inner, stdin=stdin, timeout=180)
return _ssh(_remote_wrap(inner), stdin=stdin, timeout=180)
def prod_create(origin: int, lang: str, title: str, content: str) -> int:
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` |
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Integración local para POST /wp-json/fea/v1/subir-avatar (#175).
# Requiere:
# FEA_TEST_URL=http://localhost:8081
# FEA_TEST_AUTH='usuario:application-password'
# FEA_TEST_USER_ID=<usuario temporal existente>
set -euo pipefail
: "${FEA_TEST_URL:?Falta FEA_TEST_URL}"
: "${FEA_TEST_AUTH:?Falta FEA_TEST_AUTH}"
: "${FEA_TEST_USER_ID:?Falta FEA_TEST_USER_ID}"
endpoint="${FEA_TEST_URL%/}/wp-json/fea/v1/subir-avatar"
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
# PNG RGB válido de 640×640, generado sin dependencias externas.
python3 - "$tmpdir/avatar.png" <<'PY'
import struct, sys, zlib
width = height = 640
raw = b''.join(b'\x00' + bytes((35, 100, 180)) * width for _ in range(height))
def chunk(kind, data):
return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data) & 0xffffffff)
png = b'\x89PNG\r\n\x1a\n'
png += chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0))
png += chunk(b'IDAT', zlib.compress(raw, 9))
png += chunk(b'IEND', b'')
open(sys.argv[1], 'wb').write(png)
PY
# PNG válido pero con una dimensión que excede el máximo permitido; queda muy
# comprimido a propósito para cubrir la defensa contra image bombs.
python3 - "$tmpdir/too-wide.png" <<'PY'
import struct, sys, zlib
width, height = 4097, 512
raw = b''.join(b'\x00' + bytes((35, 100, 180)) * width for _ in range(height))
def chunk(kind, data):
return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data) & 0xffffffff)
png = b'\x89PNG\r\n\x1a\n'
png += chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0))
png += chunk(b'IDAT', zlib.compress(raw, 9))
png += chunk(b'IEND', b'')
open(sys.argv[1], 'wb').write(png)
PY
assert_status() {
local expected="$1" actual="$2" label="$3" response_file="$4"
if [[ "$actual" != "$expected" ]]; then
echo "FAIL: $label — esperado HTTP $expected, recibido $actual" >&2
cat "$response_file" >&2 || true
exit 1
fi
}
# La ruta existe y no permite una llamada sin autenticar.
status="$(curl -sS -o "$tmpdir/unauth.json" -w '%{http_code}' -X POST "$endpoint")"
assert_status 401 "$status" 'llamada sin autenticar' "$tmpdir/unauth.json"
# Autenticada, pero sin destino: no puede persistir nada.
status="$(curl -sS -o "$tmpdir/no-user.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -X POST "$endpoint")"
assert_status 400 "$status" 'falta user_id' "$tmpdir/no-user.json"
# Un usuario válido sin imagen no puede crear attachments vacíos.
status="$(curl -sS -o "$tmpdir/no-avatar.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "user_id=$FEA_TEST_USER_ID" "$endpoint")"
assert_status 400 "$status" 'falta avatar' "$tmpdir/no-avatar.json"
# Una imagen-bomba comprimida se rechaza por dimensiones antes de abrir el editor.
status="$(curl -sS -o "$tmpdir/too-wide.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "user_id=$FEA_TEST_USER_ID" -F "avatar=@$tmpdir/too-wide.png;type=image/png" "$endpoint")"
assert_status 400 "$status" 'dimensiones excesivas' "$tmpdir/too-wide.json"
# Usuario inexistente: no puede crear attachments huérfanos.
status="$(curl -sS -o "$tmpdir/no-such-user.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F 'user_id=999999999' -F "avatar=@$tmpdir/avatar.png;type=image/png" "$endpoint")"
assert_status 404 "$status" 'usuario inexistente' "$tmpdir/no-such-user.json"
# Camino feliz: el endpoint crea attachment y devuelve el avatar asignado.
status="$(curl -sS -o "$tmpdir/success.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "user_id=$FEA_TEST_USER_ID" -F "avatar=@$tmpdir/avatar.png;type=image/png" "$endpoint")"
assert_status 201 "$status" 'subida válida' "$tmpdir/success.json"
python3 - "$tmpdir/success.json" "$FEA_TEST_USER_ID" <<'PY'
import json, os, sys
payload = json.load(open(sys.argv[1]))
assert int(payload['user_id']) == int(sys.argv[2]), payload
assert int(payload['attachment_id']) > 0, payload
assert payload['avatar_url'].startswith(('http://', 'https://')), payload
assert payload['width'] == 512 and payload['height'] == 512, payload
previous = os.environ.get('FEA_TEST_PREVIOUS_ATTACHMENT_ID')
if previous:
assert int(payload['previous_attachment_id']) == int(previous), payload
print('PASS: avatar asignado', payload['attachment_id'])
PY