Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 654c3d3c35 | |||
| 7af2173014 | |||
| 483c68bf5f | |||
| 2260343ec4 | |||
| bc3af3cbb9 | |||
| bbae0571ae | |||
| a806cf9cae | |||
| 696934016f | |||
| 06d7057e76 | |||
| ef2978fc21 | |||
| 587b10c2b3 | |||
| 6b966a3b5f | |||
| 754915b91f | |||
| 75c0f07eab | |||
| f0946be1fc | |||
| d836297c89 | |||
| d9ff5e891c | |||
| 5cf588ffe3 | |||
| a734ffc933 | |||
| b3a2ae682b | |||
| 825434a0b7 | |||
| 908188ddf2 | |||
| 68b95aae89 | |||
| 82c352aa15 | |||
| 51ae723c5a | |||
| 7a623e8bb9 | |||
| 5055e92143 | |||
| 638f5b70c7 |
@@ -17,6 +17,7 @@ scripts/ — scripts Python/PHP de migración, traducción, TTS y mantenimi
|
|||||||
| `fea-homepage.php` | Tarjetas de portada |
|
| `fea-homepage.php` | Tarjetas de portada |
|
||||||
| `fea-share.php` | Sección "Comparte FeAdulta" (Facebook, Instagram, Imprimir) |
|
| `fea-share.php` | Sección "Comparte FeAdulta" (Facebook, Instagram, Imprimir) |
|
||||||
| `fea-ui.php` | Estilos globales, menú, fondo cálido en artículos |
|
| `fea-ui.php` | Estilos globales, menú, fondo cálido en artículos |
|
||||||
|
| `fea-accesibilidad.php` | Tipografía Atkinson Hyperlegible (self-hosted) + toggle claro/oscuro persistente |
|
||||||
| `fea-beta-feedback.php` | Barra de feedback beta + endpoint `/fea/v1/lang/{id}` |
|
| `fea-beta-feedback.php` | Barra de feedback beta + endpoint `/fea/v1/lang/{id}` |
|
||||||
| `fea-search.php` | Buscador móvil nativo |
|
| `fea-search.php` | Buscador móvil nativo |
|
||||||
| `fea-pensamientos.php` | Widget de pensamientos |
|
| `fea-pensamientos.php` | Widget de pensamientos |
|
||||||
@@ -31,8 +32,40 @@ scripts/ — scripts Python/PHP de migración, traducción, TTS y mantenimi
|
|||||||
| `translate_haiku.py` | Traducción automática vía Claude Haiku |
|
| `translate_haiku.py` | Traducción automática vía Claude Haiku |
|
||||||
| `tts_produce.py` | Generación de audio TTS (MiniMax) |
|
| `tts_produce.py` | Generación de audio TTS (MiniMax) |
|
||||||
| `sync_translations_to_prod.py` | Sincronizar traducciones local → producción |
|
| `sync_translations_to_prod.py` | Sincronizar traducciones local → producción |
|
||||||
|
| `sync_audio_to_prod.py` | Sincronizar UN post/carta de audio TTS local → producción (sube el mp3, verifica SHA-256 remoto y persiste `fea_audio_sha256`) |
|
||||||
|
| `sync_audio_bulk.sh` | Envoltorio de `sync_audio_to_prod.py` para lotes: para en el primer fallo, sin reintentos ciegos, resumen ok/skip |
|
||||||
| `detect_untranslated.php` | Detectar posts sin traducir |
|
| `detect_untranslated.php` | Detectar posts sin traducir |
|
||||||
|
|
||||||
|
## Sincronizar audio TTS a producción (local → prod)
|
||||||
|
|
||||||
|
`sync_audio_to_prod.py` sube el mp3 de UN post/carta y, desde el #213, calcula y verifica el
|
||||||
|
SHA-256 en cada subida (reproducción verificada automática, sin backfill manual — ver #212).
|
||||||
|
|
||||||
|
Para lotes de varios posts, usar `sync_audio_bulk.sh`: es el envoltorio que añade "parar en el
|
||||||
|
primer fallo" (el script Python no lo hace por sí solo, recorre toda la lista y acumula errores).
|
||||||
|
Carga los IDs con `mapfile`/mediante array y redirige el stdin de cada invocación a `/dev/null` a
|
||||||
|
propósito — un `while read id; do ...ssh...; done < fichero` sin esa precaución se rompe en
|
||||||
|
silencio porque `ssh` se come el resto del fichero de IDs (ver
|
||||||
|
`feedback-bash-while-read-ssh-stdin` en la memoria del proyecto).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export FEA_PROD_SSH_HOST="rafa@188.40.120.157"
|
||||||
|
export FEA_PROD_DOCKER_CONTAINER="wordpress-r2ssjifwj0r0ghyoqd528uaa"
|
||||||
|
|
||||||
|
# Dry-run primero, siempre
|
||||||
|
scripts/sync_audio_bulk.sh --ids 55519,55520,822 --dry-run
|
||||||
|
|
||||||
|
# Aplicar de verdad
|
||||||
|
scripts/sync_audio_bulk.sh --ids 55519,55520,822
|
||||||
|
|
||||||
|
# Desde un fichero (un ID por línea) o toda la cola de una carta
|
||||||
|
scripts/sync_audio_bulk.sh --file /tmp/ids_pendientes.txt
|
||||||
|
scripts/sync_audio_bulk.sh --carta 54254
|
||||||
|
```
|
||||||
|
|
||||||
|
Rollback de un post concreto: `python3 scripts/sync_audio_to_prod.py --rollback --ids <id>`
|
||||||
|
(borra el meta en prod, no toca el mp3 local).
|
||||||
|
|
||||||
## Servidor local de desarrollo
|
## Servidor local de desarrollo
|
||||||
|
|
||||||
WordPress corre en Docker local, accesible vía Tailscale:
|
WordPress corre en Docker local, accesible vía Tailscale:
|
||||||
|
|||||||
@@ -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/
|
||||||
|
```
|
||||||
@@ -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 1–5 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.
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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 46937–46955). De ellos **15 son ES** (46937–46951) y 4 ya venían en otros idiomas desde Joomla.
|
||||||
|
- **Cluster multiidioma** 46951–46955 = 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 **46959–47018**), `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.6–0.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 **47079–47082**), 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 (47079–47082), 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 (46937–47082) 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** (46937–47082 + 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 46937–46955 con IDs preservados, metas (`_carta_id`, `_fgj2wp_old_k2_id`), categorías, idioma ES. Revisar.
|
||||||
|
2. [ ] Cluster multiidioma 46951–46955: enlazar Polylang en prod (los 5 ya existirían tras paso 1).
|
||||||
|
3. [ ] Traducciones EN/FR/IT/PT (carta + artículos): 46959–47018 con IDs preservados, Polylang, categorías.
|
||||||
|
4. [ ] Evangelio: crear en prod las 4 traducciones del 2682 (47079–47082) 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.
|
||||||
@@ -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ú.
|
||||||
@@ -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,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
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta — Accesibilidad (tipografía + contraste)
|
||||||
|
* Description: Tipografía Atkinson Hyperlegible global (self-hosted) + toggle
|
||||||
|
* claro/oscuro persistente, pensado para el público mayor de
|
||||||
|
* Fe Adulta (issue rafa/feadulta#196, evidencia en comment #609).
|
||||||
|
* Version: 1.0
|
||||||
|
*
|
||||||
|
* Alcance decidido en el issue (2026-08-08): SOLO dos cosas, sin más superficie
|
||||||
|
* de UI que la necesaria —
|
||||||
|
* 1. Atkinson Hyperlegible sustituye la tipografía del cuerpo, sin selector.
|
||||||
|
* 2. Un único toggle claro/oscuro persistente (claro por defecto).
|
||||||
|
* Nada de panel de accesibilidad, A+/Aa ni cambio de tamaño de letra.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
if (!defined('FEA_A11Y_ACCENT_DARK')) {
|
||||||
|
// Variante clara del carmesí de marca (#8b1a2e) para que enlaces/foco
|
||||||
|
// cumplan contraste AA sobre fondo casi negro (el carmesí original da
|
||||||
|
// ~1.8:1 sobre #0d0d0d, muy por debajo del 4.5:1 que exige texto).
|
||||||
|
define('FEA_A11Y_ACCENT_DARK', '#e8899e');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 1) Anti-flash: fija el tema ANTES de que el navegador pinte nada ─────
|
||||||
|
* Se engancha con prioridad muy baja para salir de los primeros bytes de
|
||||||
|
* <head>, igual que el resto del sitio guarda preferencias en localStorage
|
||||||
|
* (ver fea-beta-feedback.php). Sin esto habría un parpadeo claro→oscuro. */
|
||||||
|
add_action('wp_head', function () {
|
||||||
|
if (is_admin()) return;
|
||||||
|
?>
|
||||||
|
<script>(function(){try{
|
||||||
|
var t = localStorage.getItem('fea_theme');
|
||||||
|
if (t === 'dark') document.documentElement.setAttribute('data-fea-theme', 'dark');
|
||||||
|
}catch(e){}})();</script>
|
||||||
|
<?php
|
||||||
|
}, 1);
|
||||||
|
|
||||||
|
/* ── 2) Fuente self-hosted + estilos de tema oscuro ───────────────────── */
|
||||||
|
add_action('wp_head', function () {
|
||||||
|
if (is_admin()) return;
|
||||||
|
$base = plugins_url('fea-accesibilidad/fonts', __FILE__);
|
||||||
|
$accent_dark = FEA_A11Y_ACCENT_DARK;
|
||||||
|
?>
|
||||||
|
<style id="fea-a11y">
|
||||||
|
/* ── Atkinson Hyperlegible (Braille Institute, OFL), subset latin ──── */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Atkinson Hyperlegible';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('<?php echo esc_url($base); ?>/atkinson-hyperlegible-regular.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Atkinson Hyperlegible';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 501 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('<?php echo esc_url($base); ?>/atkinson-hyperlegible-bold.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sustitución global del cuerpo de texto (no opcional, issue #196).
|
||||||
|
Especificidad baja a propósito: el menú (fea-ui.php, issue #95) usa
|
||||||
|
una sans de UI más específica y debe seguir ganando. */
|
||||||
|
body {
|
||||||
|
font-family: 'Atkinson Hyperlegible', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6,
|
||||||
|
.fea-hero-title,
|
||||||
|
.fea-section-title {
|
||||||
|
font-family: 'Atkinson Hyperlegible', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
input, textarea, select, button {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toggle claro/oscuro: botón inyectado junto al selector de idioma ── */
|
||||||
|
#fea-theme-btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 30px; height: 30px; border-radius: 50%;
|
||||||
|
border: 1px solid rgba(0,0,0,0.25); background: none; color: inherit;
|
||||||
|
cursor: pointer; font-size: 0.95rem; line-height: 1; padding: 0;
|
||||||
|
}
|
||||||
|
#fea-theme-btn:hover { background: rgba(0,0,0,0.06); }
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════════
|
||||||
|
MODO OSCURO — activado por [data-fea-theme="dark"] en <html>
|
||||||
|
══════════════════════════════════════════════════════════════════ */
|
||||||
|
html[data-fea-theme="dark"] {
|
||||||
|
/* Deja que el navegador oscurezca controles nativos gratis:
|
||||||
|
audio/video, scrollbars, checkboxes, selects sin estilo propio. */
|
||||||
|
color-scheme: dark;
|
||||||
|
--wp--preset--color--base: #0d0d0d;
|
||||||
|
--wp--preset--color--contrast: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] body {
|
||||||
|
background-color: #0d0d0d !important;
|
||||||
|
color: #ececec !important;
|
||||||
|
}
|
||||||
|
/* En artículos, fea-ui.php fija --wp--preset--color--base en el body
|
||||||
|
para el modo claro. El contenedor desplegable del menú hereda esa
|
||||||
|
variable, así que hay que restaurarla aquí en oscuro. */
|
||||||
|
html[data-fea-theme="dark"] body.single-post {
|
||||||
|
--wp--preset--color--base: #0d0d0d;
|
||||||
|
--wp--preset--color--contrast: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] a,
|
||||||
|
html[data-fea-theme="dark"] a:visited { color: <?php echo esc_html($accent_dark); ?>; }
|
||||||
|
html[data-fea-theme="dark"] a:focus-visible,
|
||||||
|
html[data-fea-theme="dark"] button:focus-visible {
|
||||||
|
outline-color: <?php echo esc_html($accent_dark); ?> !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cabecera/pie y bloques de grupo sin fondo explícito heredan el body;
|
||||||
|
solo hay que corregir los pocos sitios con color fijo en claro. */
|
||||||
|
html[data-fea-theme="dark"] input,
|
||||||
|
html[data-fea-theme="dark"] textarea,
|
||||||
|
html[data-fea-theme="dark"] select {
|
||||||
|
background-color: #1c1c1c;
|
||||||
|
color: #ececec;
|
||||||
|
border-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search block uses more-specific own color rules. */
|
||||||
|
html[data-fea-theme="dark"] .wp-block-search__inside-wrapper,
|
||||||
|
html[data-fea-theme="dark"] .wp-block-search__input {
|
||||||
|
background-color: #1c1c1c;
|
||||||
|
color: #ececec;
|
||||||
|
border-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .wp-block-search__button {
|
||||||
|
background-color: #ececec;
|
||||||
|
color: #0d0d0d;
|
||||||
|
border-color: #ececec;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-link-wrap { background: #161311; border-bottom-color: #2a2320; }
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-link { color: <?php echo esc_html($accent_dark); ?>; }
|
||||||
|
|
||||||
|
/* Crop the white edge embedded in slide images in dark mode only. */
|
||||||
|
html[data-fea-theme="dark"] .fea-hero-slider .n2-ss-slide-background-image {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-hero-slider .n2-ss-slide-background-image img {
|
||||||
|
transform: scale(1.015);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portada — franja hero (fea-homepage.php) */
|
||||||
|
html[data-fea-theme="dark"] .fea-hero-band {
|
||||||
|
background: linear-gradient(180deg, #1a1512, #0d0d0d);
|
||||||
|
border-bottom-color: #2a2320;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-hero-title,
|
||||||
|
html[data-fea-theme="dark"] .fea-section-title {
|
||||||
|
color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-section-label,
|
||||||
|
html[data-fea-theme="dark"] .fea-section-more,
|
||||||
|
html[data-fea-theme="dark"] .fea-card-author {
|
||||||
|
color: <?php echo esc_html($accent_dark); ?>;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-hero-meta { color: #b8afa8; }
|
||||||
|
|
||||||
|
/* Tarjetas de autor/artículo (fea-homepage.php) */
|
||||||
|
html[data-fea-theme="dark"] .fea-card {
|
||||||
|
background: #161311;
|
||||||
|
border-color: #2a2320;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-card-title a { color: #ececec; }
|
||||||
|
|
||||||
|
/* Archivos (categoría, búsqueda y autor) — fea-homepage.php fija aquí
|
||||||
|
fondos y texto claros que no heredan del tema. */
|
||||||
|
html[data-fea-theme="dark"] .wp-block-query-title {
|
||||||
|
color: #ececec !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-archive-card {
|
||||||
|
background: #161311;
|
||||||
|
border-color: #2a2320;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-archive-title a {
|
||||||
|
color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-archive-excerpt {
|
||||||
|
color: #b8afa8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comentarios al evangelio: el panel usa colores editoriales claros fijos. */
|
||||||
|
html[data-fea-theme="dark"] .fea-com-evangelio {
|
||||||
|
background: #1a1711;
|
||||||
|
border-left-color: #c9973e;
|
||||||
|
color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-com-evangelio-details summary {
|
||||||
|
color: #f0c674;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Elementos editoriales presentes en artículos y resultados. */
|
||||||
|
html[data-fea-theme="dark"] .fea-audio {
|
||||||
|
background: #161311;
|
||||||
|
border-color: #3a302b;
|
||||||
|
border-left-color: <?php echo esc_html($accent_dark); ?>;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-byline-name { color: #ececec; }
|
||||||
|
html[data-fea-theme="dark"] .fea-byline-cat,
|
||||||
|
html[data-fea-theme="dark"] .fea-eed-link .fecha { color: #b8afa8; }
|
||||||
|
html[data-fea-theme="dark"] .fea-share-rule { border-top-color: #3a302b; }
|
||||||
|
html[data-fea-theme="dark"] .fea-share-row { color: #d5cbc3; }
|
||||||
|
html[data-fea-theme="dark"] .fea-share-label,
|
||||||
|
html[data-fea-theme="dark"] .fea-share-sep { color: #b8afa8; }
|
||||||
|
|
||||||
|
/* Formulario y filtros de búsqueda avanzada. */
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-details {
|
||||||
|
background: #161311; border-color: #3a302b;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-summary {
|
||||||
|
background: #24181c; border-bottom-color: #3a302b;
|
||||||
|
color: <?php echo esc_html($accent_dark); ?>;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-label,
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-chips-label,
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-meta,
|
||||||
|
html[data-fea-theme="dark"] .fea-archive-card .fea-adv-byline { color: #b8afa8; }
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-chip {
|
||||||
|
background: #2a1b20; border-color: #56343d; color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-input,
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-select {
|
||||||
|
background: #1c1c1c; border-color: #3a3a3a; color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-count { color: #b8afa8; }
|
||||||
|
|
||||||
|
/* Recopilatorios y galería multimedia. */
|
||||||
|
html[data-fea-theme="dark"] .fea-recop-year { border-bottom-color: #3a302b; }
|
||||||
|
html[data-fea-theme="dark"] .fea-recop-list li { border-bottom-color: #2a2320; }
|
||||||
|
html[data-fea-theme="dark"] .fea-recop-list a,
|
||||||
|
html[data-fea-theme="dark"] .fea-mm-title { color: #ececec; }
|
||||||
|
html[data-fea-theme="dark"] .fea-recop-date,
|
||||||
|
html[data-fea-theme="dark"] .fea-mm-date,
|
||||||
|
html[data-fea-theme="dark"] .fea-mm-excerpt { color: #b8afa8; }
|
||||||
|
html[data-fea-theme="dark"] .fea-mm-card { background: #161311; border-color: #2a2320; }
|
||||||
|
html[data-fea-theme="dark"] .fea-mm-thumb { background: #241f1c; }
|
||||||
|
|
||||||
|
/* Galerías de pensamientos y navegación del tema base. */
|
||||||
|
html[data-fea-theme="dark"] .fea-gallery-item {
|
||||||
|
background: #161311; border-color: #3a302b;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-lightbox-frame img { background: #161311; }
|
||||||
|
html[data-fea-theme="dark"] .fea-lightbox button { background: #2a2320; color: #ececec; }
|
||||||
|
html[data-fea-theme="dark"] .wp-block-navigation .wp-block-navigation-item__content {
|
||||||
|
color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .wp-block-navigation__submenu-container {
|
||||||
|
background: #161311; border-color: #3a302b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Página de apoyo: plantilla propia con colores claros explícitos. */
|
||||||
|
html[data-fea-theme="dark"] .fea-support-page {
|
||||||
|
--fea-support-ink: #ececec;
|
||||||
|
--fea-support-warm: #161311;
|
||||||
|
--fea-support-line: #3a302b;
|
||||||
|
--fea-support-card: #161311;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-support-hero,
|
||||||
|
html[data-fea-theme="dark"] .fea-support-banner {
|
||||||
|
background: #1a1711 !important;
|
||||||
|
border-color: #3a302b !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-support-card,
|
||||||
|
html[data-fea-theme="dark"] .fea-support-button.is-secondary {
|
||||||
|
background: #161311 !important;
|
||||||
|
border-color: #3a302b !important;
|
||||||
|
color: #ececec !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-support-card__title,
|
||||||
|
html[data-fea-theme="dark"] .fea-support-progress__numbers span,
|
||||||
|
html[data-fea-theme="dark"] .fea-support-progress__note,
|
||||||
|
html[data-fea-theme="dark"] .fea-support-sidebar__small { color: #b8afa8; }
|
||||||
|
html[data-fea-theme="dark"] .fea-support-progress__track { background: #3a302b; }
|
||||||
|
|
||||||
|
/* Colaboradores: stylesheet propio del tema con tarjetas claras y !important. */
|
||||||
|
html[data-fea-theme="dark"] .entry-content .wrapper {
|
||||||
|
background: #161311 !important;
|
||||||
|
border-color: #3a302b !important;
|
||||||
|
color: #ececec !important;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,.45) !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .entry-content .wrapper strong {
|
||||||
|
color: #ececec !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .entry-content .wrapper a,
|
||||||
|
html[data-fea-theme="dark"] .entry-content .wrapper a:visited {
|
||||||
|
color: #ececec !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .entry-content .wrapper a:hover {
|
||||||
|
color: <?php echo esc_html($accent_dark); ?> !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selector de idioma (fea-homepage.php) */
|
||||||
|
html[data-fea-theme="dark"] #fea-lang-btn { border-color: rgba(255,255,255,0.3); }
|
||||||
|
html[data-fea-theme="dark"] #fea-lang-btn:hover,
|
||||||
|
html[data-fea-theme="dark"] #fea-theme-btn:hover { background: rgba(255,255,255,0.08); }
|
||||||
|
html[data-fea-theme="dark"] #fea-theme-btn { border-color: rgba(255,255,255,0.3); }
|
||||||
|
html[data-fea-theme="dark"] #fea-lang-dropdown {
|
||||||
|
background: #161311; border-color: #3a3a3a;
|
||||||
|
box-shadow: 0 6px 16px rgba(0,0,0,.5);
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] #fea-lang-dropdown a { color: #ececec; }
|
||||||
|
html[data-fea-theme="dark"] #fea-lang-dropdown a:hover { background: #241f1c; }
|
||||||
|
html[data-fea-theme="dark"] #fea-lang-dropdown a[aria-current] { background: #2a2320; color: #fff; }
|
||||||
|
|
||||||
|
/* Buscador móvil (fea-search.php) */
|
||||||
|
html[data-fea-theme="dark"] .fea-search-bar { background: #0d0d0d; border-bottom-color: #2a2320; }
|
||||||
|
html[data-fea-theme="dark"] .fea-search { background: #1c1c1c; border-color: #3a3a3a; }
|
||||||
|
html[data-fea-theme="dark"] .fea-search input[type=search] { color: #ececec; }
|
||||||
|
|
||||||
|
/* Reproductor de audio (fea-audio-player.php) — el <audio> nativo ya
|
||||||
|
cambia solo vía color-scheme; solo hace falta la etiqueta de texto. */
|
||||||
|
html[data-fea-theme="dark"] .fea-audio-label { color: #b8afa8; }
|
||||||
|
|
||||||
|
/* Barra y tarjeta de Beta Feedback (fea-beta-feedback.php) */
|
||||||
|
html[data-fea-theme="dark"] #fea-beta-bar {
|
||||||
|
background: #161311; border-top-color: #2a2320; color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] #fea-beta-bar .fea-beta-dismiss { color: #b8afa8; }
|
||||||
|
html[data-fea-theme="dark"] #fea-fb .fea-fb-card {
|
||||||
|
background: #161311; border-color: #3a3a3a; color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] #fea-fb button.fea-fb-vote {
|
||||||
|
background: #1c1c1c; border-color: #3a3a3a; color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] #fea-fb button.fea-fb-vote.sel {
|
||||||
|
border-color: <?php echo esc_html($accent_dark); ?>; background: #2a1418;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] #fea-fb textarea { background: #1c1c1c; border-color: #3a3a3a; color: #ececec; }
|
||||||
|
|
||||||
|
/* Barra de consentimiento de cookies (fea-cookie-consent.php) */
|
||||||
|
html[data-fea-theme="dark"] #fea-cc {
|
||||||
|
background: #161311 !important; color: #ececec !important; border-top-color: #2a2320 !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] #fea-cc a { color: #b8afa8 !important; }
|
||||||
|
html[data-fea-theme="dark"] #fea-cc .fea-cc-reject { color: #ececec !important; border-color: #3a3a3a !important; }
|
||||||
|
</style>
|
||||||
|
<?php
|
||||||
|
}, 30);
|
||||||
|
|
||||||
|
/* ── 3) Botón de toggle, inyectado en el header junto al selector de idioma ──
|
||||||
|
* Mismo patrón que el selector de idioma (fea-homepage.php): se crea por JS
|
||||||
|
* y se inserta en el nav del header, para no tocar el template FSE. */
|
||||||
|
add_action('wp_footer', function () {
|
||||||
|
if (is_admin()) return;
|
||||||
|
?>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
// El footer puede renderizarse más de una vez en algunos templates FSE.
|
||||||
|
// Evitamos crear un segundo toggle en ese caso.
|
||||||
|
if (document.getElementById('fea-theme-btn')) return;
|
||||||
|
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.id = 'fea-theme-btn';
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.setAttribute('aria-pressed', document.documentElement.getAttribute('data-fea-theme') === 'dark' ? 'true' : 'false');
|
||||||
|
btn.setAttribute('aria-label', 'Cambiar a modo oscuro');
|
||||||
|
btn.textContent = document.documentElement.getAttribute('data-fea-theme') === 'dark' ? '☀️' : '🌙';
|
||||||
|
|
||||||
|
function setLabel(isDark) {
|
||||||
|
btn.setAttribute('aria-pressed', isDark ? 'true' : 'false');
|
||||||
|
btn.setAttribute('aria-label', isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro');
|
||||||
|
btn.textContent = isDark ? '☀️' : '🌙';
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var isDark = document.documentElement.getAttribute('data-fea-theme') === 'dark';
|
||||||
|
if (isDark) {
|
||||||
|
document.documentElement.removeAttribute('data-fea-theme');
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-fea-theme', 'dark');
|
||||||
|
}
|
||||||
|
setLabel(!isDark);
|
||||||
|
try { localStorage.setItem('fea_theme', !isDark ? 'dark' : 'light'); } catch (e) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
function inject() {
|
||||||
|
var header = document.querySelector('header.wp-block-template-part') || document.querySelector('header');
|
||||||
|
if (!header) return;
|
||||||
|
var navGroup = header.querySelector('.wp-block-navigation__container') || header.querySelector('nav');
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.className = 'wp-block-navigation-item';
|
||||||
|
li.style.cssText = 'display:flex;align-items:center;margin-left:0.5rem;';
|
||||||
|
li.appendChild(btn);
|
||||||
|
if (!navGroup) {
|
||||||
|
header.style.position = 'relative';
|
||||||
|
li.style.cssText = 'position:absolute;top:50%;right:1.5rem;transform:translateY(-50%);';
|
||||||
|
header.appendChild(li);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Insertar después del selector de idioma si ya se inyectó, si no, al final del nav.
|
||||||
|
var langSwitcher = navGroup.querySelector('#fea-lang-switcher');
|
||||||
|
if (langSwitcher) {
|
||||||
|
var anchor = langSwitcher;
|
||||||
|
while (anchor.parentNode && anchor.parentNode !== navGroup) anchor = anchor.parentNode;
|
||||||
|
anchor.parentNode.insertBefore(li, anchor.nextSibling);
|
||||||
|
} else {
|
||||||
|
navGroup.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', inject);
|
||||||
|
} else {
|
||||||
|
inject();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
}, 21);
|
||||||
Binary file not shown.
Binary file not shown.
@@ -13,11 +13,23 @@ function fea_audio_player_html(): string {
|
|||||||
$url = get_post_meta($id, 'fea_audio_url', true);
|
$url = get_post_meta($id, 'fea_audio_url', true);
|
||||||
if (!$url) return '';
|
if (!$url) return '';
|
||||||
$voice = get_post_meta($id, 'fea_audio_voice', true) ?: 'NicoFeadulta2026';
|
$voice = get_post_meta($id, 'fea_audio_voice', true) ?: 'NicoFeadulta2026';
|
||||||
return '<div class="fea-audio">'
|
$sha256 = get_post_meta($id, 'fea_audio_sha256', true);
|
||||||
. '<span class="fea-audio-label">'
|
$label = '<span class="fea-audio-label">'
|
||||||
. '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">'
|
. '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">'
|
||||||
. '<path fill="currentColor" d="M3 10v4h4l5 5V5L7 10H3zm13.5 2a4.5 4.5 0 0 0-2.5-4.03v8.06A4.5 4.5 0 0 0 16.5 12zM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54z"/>'
|
. '<path fill="currentColor" d="M3 10v4h4l5 5V5L7 10H3zm13.5 2a4.5 4.5 0 0 0-2.5-4.03v8.06A4.5 4.5 0 0 0 16.5 12zM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54z"/>'
|
||||||
. '</svg> Escucha</span>'
|
. '</svg> Escucha</span>';
|
||||||
|
// Para un asset con hash registrado, descarga y verifica el fichero completo
|
||||||
|
// antes de delegar playback al navegador. Evita que un stream Range mezclado
|
||||||
|
// por una caché reproduzca fragmentos de otro MP3.
|
||||||
|
if (preg_match('/^[a-f0-9]{64}$/i', $sha256)) {
|
||||||
|
return '<div class="fea-audio">' . $label
|
||||||
|
. '<button type="button" class="fea-audio-verified-play" data-fea-audio-verified-play'
|
||||||
|
. ' data-src="' . esc_url($url) . '" data-sha256="' . esc_attr(strtolower($sha256)) . '"'
|
||||||
|
. ' data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '">Reproducir audio</button>'
|
||||||
|
. '<audio controls preload="none" hidden data-fea-audio-track data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '"></audio>'
|
||||||
|
. '<span class="fea-audio-status" role="status" aria-live="polite"></span></div>';
|
||||||
|
}
|
||||||
|
return '<div class="fea-audio">' . $label
|
||||||
. '<audio controls preload="none" src="' . esc_url($url) . '"'
|
. '<audio controls preload="none" src="' . esc_url($url) . '"'
|
||||||
. ' data-fea-audio-track data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '"></audio>'
|
. ' data-fea-audio-track data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '"></audio>'
|
||||||
. '</div>';
|
. '</div>';
|
||||||
@@ -56,6 +68,9 @@ add_action('wp_head', function () {
|
|||||||
.fea-audio-label{display:inline-flex;align-items:center;gap:.35rem;font-size:.78rem;
|
.fea-audio-label{display:inline-flex;align-items:center;gap:.35rem;font-size:.78rem;
|
||||||
font-weight:600;color:#8b1a2e;white-space:nowrap;line-height:1}
|
font-weight:600;color:#8b1a2e;white-space:nowrap;line-height:1}
|
||||||
.fea-audio audio{height:32px;width:230px;max-width:44vw}
|
.fea-audio audio{height:32px;width:230px;max-width:44vw}
|
||||||
|
.fea-audio-verified-play{border:1px solid #8b1a2e;border-radius:6px;background:#fff;color:#8b1a2e;padding:.35rem .6rem;font:inherit;font-size:.82rem;font-weight:600;cursor:pointer}
|
||||||
|
.fea-audio-verified-play:disabled{opacity:.65;cursor:wait}
|
||||||
|
.fea-audio-status{font-size:.76rem;color:#6b5c60}
|
||||||
@media(max-width:600px){
|
@media(max-width:600px){
|
||||||
.fea-audio{margin-left:0;width:100%;margin-top:.5rem}
|
.fea-audio{margin-left:0;width:100%;margin-top:.5rem}
|
||||||
.fea-audio audio{flex:1 1 auto;width:auto;max-width:none}
|
.fea-audio audio{flex:1 1 auto;width:auto;max-width:none}
|
||||||
@@ -73,6 +88,40 @@ add_action('wp_footer', function () {
|
|||||||
if (!get_post_meta(get_queried_object_id(), 'fea_audio_url', true)) return;
|
if (!get_post_meta(get_queried_object_id(), 'fea_audio_url', true)) return;
|
||||||
?>
|
?>
|
||||||
<script>
|
<script>
|
||||||
|
(function () {
|
||||||
|
function hex(buffer) {
|
||||||
|
return Array.from(new Uint8Array(buffer)).map(function (n) {
|
||||||
|
return n.toString(16).padStart(2, '0');
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
document.querySelectorAll('[data-fea-audio-verified-play]').forEach(function (button) {
|
||||||
|
button.addEventListener('click', async function () {
|
||||||
|
var wrap = button.closest('.fea-audio');
|
||||||
|
var audio = wrap.querySelector('audio[data-fea-audio-track]');
|
||||||
|
var status = wrap.querySelector('.fea-audio-status');
|
||||||
|
button.disabled = true;
|
||||||
|
status.textContent = 'Verificando audio…';
|
||||||
|
try {
|
||||||
|
// Se descarga el objeto entero una vez y se valida su hash. Sólo
|
||||||
|
// entonces se crea un blob local: el <audio> no hará Range contra
|
||||||
|
// CDN y jamás podrá mezclar fragmentos de otro MP3.
|
||||||
|
var response = await fetch(button.dataset.src, {cache: 'no-store'});
|
||||||
|
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||||
|
var bytes = await response.arrayBuffer();
|
||||||
|
var digest = hex(await crypto.subtle.digest('SHA-256', bytes));
|
||||||
|
if (digest !== button.dataset.sha256) throw new Error('La copia recibida no coincide con el audio verificado');
|
||||||
|
audio.src = URL.createObjectURL(new Blob([bytes], {type: 'audio/mpeg'}));
|
||||||
|
audio.hidden = false;
|
||||||
|
button.hidden = true;
|
||||||
|
status.textContent = '';
|
||||||
|
await audio.play();
|
||||||
|
} catch (error) {
|
||||||
|
status.textContent = 'No se pudo verificar el audio. Inténtalo de nuevo.';
|
||||||
|
button.disabled = false;
|
||||||
|
console.error('Fe Adulta audio verification failed:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
document.querySelectorAll('audio[data-fea-audio-track]').forEach(function (audio) {
|
document.querySelectorAll('audio[data-fea-audio-track]').forEach(function (audio) {
|
||||||
var fired = false;
|
var fired = false;
|
||||||
audio.addEventListener('play', function () {
|
audio.addEventListener('play', function () {
|
||||||
@@ -84,6 +133,7 @@ add_action('wp_footer', function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}());
|
||||||
</script>
|
</script>
|
||||||
<?php
|
<?php
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta — Evangelio en comentarios
|
||||||
|
* Description: Deriva la cita de la carta semanal y muestra la lectura en los comentarios al evangelio.
|
||||||
|
* Version: 1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
function fea_evangelio_category($es_id) {
|
||||||
|
return function_exists('fea_cat') ? (int) fea_cat($es_id) : (int) $es_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_evangelio_comment_categories() {
|
||||||
|
return array_map('fea_evangelio_category', [1647, 1646]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_evangelio_is_comment($post_id) {
|
||||||
|
return has_category(fea_evangelio_comment_categories(), $post_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lee el meta del comentario; las traducciones heredan el dato editorial del original ES. */
|
||||||
|
function fea_evangelio_comment_meta($post_id, $key) {
|
||||||
|
$value = get_post_meta($post_id, $key, true);
|
||||||
|
if ($value !== '' && $value !== null) return $value;
|
||||||
|
if (!function_exists('pll_get_post')) return $value;
|
||||||
|
|
||||||
|
$source_id = (int) pll_get_post((int) $post_id, 'es');
|
||||||
|
return $source_id && $source_id !== (int) $post_id
|
||||||
|
? get_post_meta($source_id, $key, true)
|
||||||
|
: $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_evangelio_reading_title($cita) {
|
||||||
|
$cita = trim(wp_strip_all_tags((string) $cita));
|
||||||
|
if (!preg_match('/^(Mt|Mc|Lc|Jn)\s+(.+)$/u', $cita, $m)) return '';
|
||||||
|
$books = ['Mt' => 'MATEO', 'Mc' => 'MARCOS', 'Lc' => 'LUCAS', 'Jn' => 'JUAN'];
|
||||||
|
return $books[$m[1]] . ' ' . preg_replace('/\s+/u', ' ', trim($m[2]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Localiza la lectura española, sin depender del slug (que puede llevar sufijos). */
|
||||||
|
function fea_evangelio_reading_id($cita) {
|
||||||
|
global $wpdb;
|
||||||
|
static $cache = [];
|
||||||
|
$title = fea_evangelio_reading_title($cita);
|
||||||
|
if ($title === '') return 0;
|
||||||
|
if (isset($cache[$title])) return $cache[$title];
|
||||||
|
// La coincidencia se hace siempre contra la lectura original en español; después
|
||||||
|
// Polylang la sustituye por la traducción del comentario cuando exista.
|
||||||
|
$cat = 1645;
|
||||||
|
$sql = $wpdb->prepare(
|
||||||
|
"SELECT p.ID FROM {$wpdb->posts} p
|
||||||
|
INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
|
||||||
|
INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
|
||||||
|
WHERE p.post_type = 'post' AND p.post_status = 'publish' AND p.post_title = %s
|
||||||
|
AND tt.taxonomy = 'category' AND tt.term_id = %d
|
||||||
|
ORDER BY p.post_date DESC LIMIT 1",
|
||||||
|
$title, $cat
|
||||||
|
);
|
||||||
|
return $cache[$title] = (int) $wpdb->get_var($sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Devuelve los evangelios enlazados en la sección pertinente de una carta. */
|
||||||
|
function fea_evangelio_from_carta($carta_id) {
|
||||||
|
if (!function_exists('fea_parse_carta_sections')) return [];
|
||||||
|
if (function_exists('pll_get_post')) $carta_id = (int) (pll_get_post((int) $carta_id, 'es') ?: $carta_id);
|
||||||
|
$links = fea_parse_carta_sections((int) $carta_id)['evangelio'] ?? [];
|
||||||
|
$result = [];
|
||||||
|
foreach ($links as $id) {
|
||||||
|
$post = get_post($id);
|
||||||
|
if (!$post) continue;
|
||||||
|
$cita = get_post_meta($id, '* Cita biblica', true);
|
||||||
|
if (!$cita && preg_match('/^(MATEO|MARCOS|LUCAS|JUAN)\s+(.+)$/u', trim($post->post_title), $m)) {
|
||||||
|
$abbr = ['MATEO' => 'Mt', 'MARCOS' => 'Mc', 'LUCAS' => 'Lc', 'JUAN' => 'Jn'][$m[1]];
|
||||||
|
$cita = $abbr . ' ' . $m[2];
|
||||||
|
}
|
||||||
|
if (fea_evangelio_reading_title($cita)) $result[(string) $id] = trim($cita);
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Busca una carta en las tres categorías históricas, dentro de cuatro días. */
|
||||||
|
function fea_evangelio_nearest_carta_id(WP_Post $post) {
|
||||||
|
$date = mysql2date('Y-m-d', $post->post_date, false);
|
||||||
|
$cartas = get_posts([
|
||||||
|
'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => 10,
|
||||||
|
'category__in' => array_map('fea_evangelio_category', [6, 21, 22]),
|
||||||
|
'date_query' => [[
|
||||||
|
'after' => date('Y-m-d', strtotime($date . ' -4 days')),
|
||||||
|
'before' => date('Y-m-d', strtotime($date . ' +4 days')), 'inclusive' => true,
|
||||||
|
]],
|
||||||
|
'orderby' => 'date', 'order' => 'DESC',
|
||||||
|
]);
|
||||||
|
if (!$cartas) return 0;
|
||||||
|
usort($cartas, function($a, $b) use ($post) {
|
||||||
|
return abs(strtotime($a->post_date) - strtotime($post->post_date)) <=> abs(strtotime($b->post_date) - strtotime($post->post_date));
|
||||||
|
});
|
||||||
|
return (int) $cartas[0]->ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escribe solo metadatos ausentes cuando la carta identifica un único evangelio. */
|
||||||
|
function fea_evangelio_capture_meta($post_id) {
|
||||||
|
$post = get_post($post_id);
|
||||||
|
if (!$post || $post->post_type !== 'post' || $post->post_status !== 'publish' || !fea_evangelio_is_comment($post_id)) return false;
|
||||||
|
if (get_post_meta($post_id, '_cita_evangelio', true)) return false;
|
||||||
|
$carta_id = (int) get_post_meta($post_id, '_carta_id', true);
|
||||||
|
if (!$carta_id) $carta_id = fea_evangelio_nearest_carta_id($post);
|
||||||
|
if (!$carta_id) return false;
|
||||||
|
$evangelios = fea_evangelio_from_carta($carta_id);
|
||||||
|
if (count($evangelios) !== 1) {
|
||||||
|
error_log(sprintf('Fe Adulta: comentario %d sin cita; carta %d tiene %d evangelios.', $post_id, $carta_id, count($evangelios)));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
update_post_meta($post_id, '_cita_evangelio', reset($evangelios));
|
||||||
|
if (!get_post_meta($post_id, '_carta_id', true)) update_post_meta($post_id, '_carta_id', $carta_id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_evangelio_summary_label($lang) {
|
||||||
|
$labels = [
|
||||||
|
'es' => 'Leer el evangelio', 'en' => 'Read the Gospel', 'fr' => 'Lire l’Évangile',
|
||||||
|
'it' => 'Leggi il Vangelo', 'pt' => 'Ler o Evangelho',
|
||||||
|
];
|
||||||
|
return $labels[$lang] ?? $labels['es'];
|
||||||
|
}
|
||||||
|
|
||||||
|
add_action('transition_post_status', function($new_status, $old_status, $post) {
|
||||||
|
if ($new_status === 'publish' && $old_status !== 'publish') fea_evangelio_capture_meta($post->ID);
|
||||||
|
}, 20, 3);
|
||||||
|
|
||||||
|
/** Añade referencia y texto plegado, cerrado por defecto, antes del comentario. */
|
||||||
|
add_filter('the_content', function($content) {
|
||||||
|
if (!is_single() || is_admin()) return $content;
|
||||||
|
global $post;
|
||||||
|
if (!$post || !fea_evangelio_is_comment($post->ID)) return $content;
|
||||||
|
$cita = trim((string) fea_evangelio_comment_meta($post->ID, '_cita_evangelio'));
|
||||||
|
if (!$cita) return $content;
|
||||||
|
$reading_id = fea_evangelio_reading_id($cita);
|
||||||
|
$lang = function_exists('pll_current_language') ? pll_current_language() : 'es';
|
||||||
|
if ($reading_id && $lang && $lang !== 'es' && function_exists('pll_get_post')) $reading_id = (int) (pll_get_post($reading_id, $lang) ?: $reading_id);
|
||||||
|
$reading = $reading_id ? get_post($reading_id) : null;
|
||||||
|
$starts_with_cita = preg_match('/^\s*' . preg_quote($cita, '/') . '(?:[\s\.,:;\-–—]|$)/iu', wp_strip_all_tags($content));
|
||||||
|
$html = '<section class="fea-com-evangelio" aria-label="' . esc_attr(fea_evangelio_summary_label($lang)) . '">';
|
||||||
|
if (!$starts_with_cita) $html .= '<p class="fea-com-evangelio-cita">' . esc_html($cita) . '</p>';
|
||||||
|
if ($reading && trim(wp_strip_all_tags($reading->post_content)) !== '') {
|
||||||
|
$html .= '<details class="fea-com-evangelio-details"><summary>' . esc_html(fea_evangelio_summary_label($lang)) . '</summary>'
|
||||||
|
. wpautop(do_shortcode($reading->post_content)) . '</details>';
|
||||||
|
}
|
||||||
|
return $html . '</section>' . $content;
|
||||||
|
}, 8);
|
||||||
|
|
||||||
|
add_action('wp_head', function() {
|
||||||
|
if (!is_single()) return;
|
||||||
|
global $post;
|
||||||
|
if (!$post || !fea_evangelio_is_comment($post->ID)) return;
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
.fea-com-evangelio { margin: 0 0 1.75rem; padding: 1rem 1.15rem; background: #f7f4ed; border-left: 4px solid #a77b2d; }
|
||||||
|
.fea-com-evangelio-cita { margin: 0 0 .65rem; font-weight: 700; }
|
||||||
|
.fea-com-evangelio-details summary { cursor: pointer; font-weight: 700; color: #51401e; }
|
||||||
|
.fea-com-evangelio-details > :last-child { margin-bottom: 0; }
|
||||||
|
.fea-com-evangelio-details[open] summary { margin-bottom: .85rem; }
|
||||||
|
</style>
|
||||||
|
<?php
|
||||||
|
});
|
||||||
@@ -2044,8 +2044,12 @@ add_filter('the_content', function($content) {
|
|||||||
global $post;
|
global $post;
|
||||||
if (!in_category('comentarios-al-evangelio', $post)) return $content;
|
if (!in_category('comentarios-al-evangelio', $post)) return $content;
|
||||||
|
|
||||||
$carta_id = get_post_meta($post->ID, '_carta_id', true);
|
$carta_id = function_exists('fea_evangelio_comment_meta')
|
||||||
$cita = get_post_meta($post->ID, '_cita_evangelio', true);
|
? fea_evangelio_comment_meta($post->ID, '_carta_id')
|
||||||
|
: get_post_meta($post->ID, '_carta_id', true);
|
||||||
|
$cita = function_exists('fea_evangelio_comment_meta')
|
||||||
|
? fea_evangelio_comment_meta($post->ID, '_cita_evangelio')
|
||||||
|
: get_post_meta($post->ID, '_cita_evangelio', true);
|
||||||
|
|
||||||
if (!$carta_id && !$cita) return $content;
|
if (!$carta_id && !$cita) return $content;
|
||||||
|
|
||||||
@@ -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 ---
|
// --- Otros comentarios de esta misma cita ---
|
||||||
if ($cita) {
|
if ($cita) {
|
||||||
$others = get_posts([
|
$others = get_posts([
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
/** Propuesta/aplicación segura del backfill de _cita_evangelio. */
|
||||||
|
/** Uso: wp eval-file scripts/backfill_evangelio_meta.php [--apply] --path=/var/www/html */
|
||||||
|
|
||||||
|
// WP-CLI no reenvía de forma consistente argumentos desconocidos a eval-file.
|
||||||
|
// El modo escritura exige por tanto una intención explícita y auditable:
|
||||||
|
// FEA_EVANGELIO_APPLY=1 wp eval-file scripts/backfill_evangelio_meta.php
|
||||||
|
$apply = getenv('FEA_EVANGELIO_APPLY') === '1'
|
||||||
|
|| in_array('--apply', $_SERVER['argv'] ?? [], true);
|
||||||
|
$posts = get_posts([
|
||||||
|
'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => -1,
|
||||||
|
'category__in' => fea_evangelio_comment_categories(),
|
||||||
|
'meta_query' => [['key' => '_cita_evangelio', 'compare' => 'NOT EXISTS']],
|
||||||
|
'orderby' => 'date', 'order' => 'ASC',
|
||||||
|
]);
|
||||||
|
$out = fopen('php://output', 'w');
|
||||||
|
fputcsv($out, ['post_id', 'fecha', 'carta_id', 'cita_propuesta', 'resultado']);
|
||||||
|
foreach ($posts as $post) {
|
||||||
|
$before_carta = (int) get_post_meta($post->ID, '_carta_id', true);
|
||||||
|
// Las cartas deducidas solo se proponen: la cola histórica requiere revisión humana.
|
||||||
|
$carta_id = $before_carta ?: fea_evangelio_nearest_carta_id($post);
|
||||||
|
$evangelios = $carta_id ? fea_evangelio_from_carta($carta_id) : [];
|
||||||
|
$cita = $before_carta && count($evangelios) === 1 ? reset($evangelios) : '';
|
||||||
|
$result = $cita ? ($apply ? 'aplicado' : 'propuesta') : (!$before_carta ? 'revision_manual_carta_por_fecha' : (count($evangelios) > 1 ? 'revision_manual_multi_evangelio' : 'sin_evangelio'));
|
||||||
|
if ($apply && $cita) {
|
||||||
|
update_post_meta($post->ID, '_cita_evangelio', $cita);
|
||||||
|
if (!$before_carta) update_post_meta($post->ID, '_carta_id', $carta_id);
|
||||||
|
}
|
||||||
|
fputcsv($out, [$post->ID, $post->post_date, $carta_id ?: '', $cita, $result]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""doc_convert.py — convierte un .doc viejo a .docx usando Microsoft Word (COM).
|
||||||
|
|
||||||
|
Necesario para Marcos (marroro@telefonica.net), que envía .doc (Word antiguo) y
|
||||||
|
python-docx no sabe leer .doc. El contenido ya viene en EML (lo etiqueta Inma al
|
||||||
|
revisar); aquí solo cambiamos el FORMATO de archivo.
|
||||||
|
|
||||||
|
Requiere Microsoft Word instalado + pywin32 (igual que marcos_review).
|
||||||
|
API: doc_to_docx(path, force=False) -> Path | None
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
WD_FORMAT_DOCX = 16 # wdFormatDocumentDefault (.docx)
|
||||||
|
|
||||||
|
|
||||||
|
def doc_to_docx(path, force: bool = False):
|
||||||
|
"""Convierte un .doc a .docx con Word COM. Idempotente: si el .docx ya existe y
|
||||||
|
no force, lo devuelve sin reconvertir. Devuelve la ruta .docx, o None si falla."""
|
||||||
|
path = Path(path)
|
||||||
|
if path.suffix.lower() != ".doc":
|
||||||
|
return None
|
||||||
|
out = path.with_suffix(".docx")
|
||||||
|
if out.exists() and not force:
|
||||||
|
return out
|
||||||
|
try:
|
||||||
|
import win32com.client # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
word = win32com.client.Dispatch("Word.Application")
|
||||||
|
word.Visible = False
|
||||||
|
try:
|
||||||
|
d = word.Documents.Open(str(path))
|
||||||
|
d.SaveAs(str(out), FileFormat=WD_FORMAT_DOCX)
|
||||||
|
d.Close(SaveChanges=False)
|
||||||
|
finally:
|
||||||
|
word.Quit()
|
||||||
|
if out.exists():
|
||||||
|
quitar_smarttags(out)
|
||||||
|
return out if out.exists() else None
|
||||||
|
|
||||||
|
|
||||||
|
def quitar_smarttags(docx_path) -> int:
|
||||||
|
"""Desenvuelve los <w:smartTag> del .docx. Devuelve cuántos ha quitado.
|
||||||
|
|
||||||
|
⚠️ CRÍTICO, no es cosmético. Los .doc de Fray Marcos traen `smartTag` (el viejo
|
||||||
|
reconocimiento de nombres de Word) envolviendo expresiones como "la Iglesia" o
|
||||||
|
"la Cananea". **python-docx solo lee los <w:r> que cuelgan directamente del párrafo,
|
||||||
|
así que el texto de dentro del smartTag DESAPARECE sin dar error**: se publicaron
|
||||||
|
frases mutiladas en las cartas 736, 737 y 739 ("...no va dirigida a los apóstoles,
|
||||||
|
sino a dureza de la respuesta..."). Detectado el 12-ago-2026.
|
||||||
|
"""
|
||||||
|
import re, shutil, zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
docx_path = Path(docx_path)
|
||||||
|
with zipfile.ZipFile(docx_path) as z:
|
||||||
|
piezas = {n: z.read(n) for n in z.namelist()}
|
||||||
|
xml = piezas.get("word/document.xml", b"").decode("utf-8", "replace")
|
||||||
|
nuevo, n = re.subn(r"</?w:smartTag(?:Pr)?\b[^>]*>|<w:attr\b[^>]*/>", "", xml)
|
||||||
|
if not n:
|
||||||
|
return 0
|
||||||
|
piezas["word/document.xml"] = nuevo.encode("utf-8")
|
||||||
|
tmp = docx_path.with_suffix(".docx.tmp")
|
||||||
|
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as z:
|
||||||
|
for nombre, datos in piezas.items():
|
||||||
|
z.writestr(nombre, datos)
|
||||||
|
shutil.move(str(tmp), str(docx_path))
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("uso: python doc_convert.py path/al/archivo.doc", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
res = doc_to_docx(Path(sys.argv[1]), force="--force" in sys.argv)
|
||||||
|
print(f"OK: {res}" if res else "FALLO (¿Word/pywin32?)")
|
||||||
|
sys.exit(0 if res else 1)
|
||||||
Executable
+115
@@ -0,0 +1,115 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# sync_audio_bulk.sh — Sincroniza a producción, uno por uno, un lote de posts con
|
||||||
|
# audio TTS ya generado en local. Envoltorio de sync_audio_to_prod.py que añade
|
||||||
|
# "parar en el primer fallo" (el script Python no lo hace: recorre toda la
|
||||||
|
# lista y acumula errores, ver #212/#213).
|
||||||
|
#
|
||||||
|
# Motivo del wrapper en bash con mapfile: un `while read id; do ...; done <
|
||||||
|
# fichero` normal comparte el file descriptor de stdin con cualquier `ssh`
|
||||||
|
# lanzado dentro del bucle. sync_audio_to_prod.py llama a ssh internamente; si
|
||||||
|
# el bucle no está protegido, ssh se come el resto del fichero de IDs y el
|
||||||
|
# bucle termina "sin error" tras procesar solo el primero. Detectado y
|
||||||
|
# documentado en memoria (feedback-bash-while-read-ssh-stdin) el 2026-08-24
|
||||||
|
# tras un lote real que solo sincronizó 1 de 184 posts sin ningún error visible.
|
||||||
|
#
|
||||||
|
# Uso:
|
||||||
|
# scripts/sync_audio_bulk.sh --ids 55519,55520,822
|
||||||
|
# scripts/sync_audio_bulk.sh --file /tmp/ids.txt # un ID por línea
|
||||||
|
# scripts/sync_audio_bulk.sh --carta 54254 # cola completa de la carta
|
||||||
|
# scripts/sync_audio_bulk.sh --ids 55519,55520 --dry-run # solo plan, no toca prod
|
||||||
|
#
|
||||||
|
# Requiere las mismas variables de entorno que sync_audio_to_prod.py para
|
||||||
|
# apuntar a producción (ver ese script): FEA_PROD_SSH_HOST,
|
||||||
|
# FEA_PROD_DOCKER_CONTAINER. Ejemplo:
|
||||||
|
# export FEA_PROD_SSH_HOST="rafa@188.40.120.157"
|
||||||
|
# export FEA_PROD_DOCKER_CONTAINER="wordpress-r2ssjifwj0r0ghyoqd528uaa"
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SYNC_PY="$SCRIPT_DIR/sync_audio_to_prod.py"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Uso: $0 (--ids id1,id2,... | --file ruta | --carta post_id) [--dry-run]" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
ids_csv=""
|
||||||
|
ids_file=""
|
||||||
|
carta=""
|
||||||
|
dry_run=""
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--ids) ids_csv="$2"; shift 2 ;;
|
||||||
|
--file) ids_file="$2"; shift 2 ;;
|
||||||
|
--carta) carta="$2"; shift 2 ;;
|
||||||
|
--dry-run) dry_run="--dry-run"; shift ;;
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Carga explícita de IDs en un array ANTES de cualquier llamada a ssh — es la
|
||||||
|
# parte que evita el bug de stdin. No usar `while read ... < fichero`.
|
||||||
|
ids=()
|
||||||
|
if [ -n "$ids_csv" ]; then
|
||||||
|
IFS=',' read -r -a ids <<< "$ids_csv"
|
||||||
|
elif [ -n "$ids_file" ]; then
|
||||||
|
mapfile -t ids < "$ids_file"
|
||||||
|
elif [ -n "$carta" ]; then
|
||||||
|
# No usar `mapfile < <(cmd)`: la sustitución de procesos no deja comprobar
|
||||||
|
# de forma fiable el exit code de `cmd` (mapfile ve su propio $?, no el de
|
||||||
|
# la tubería). Si la consulta falla, un array vacío se confundiría con
|
||||||
|
# "la carta no tiene artículos" y el lote saldría "éxito" sin sincronizar
|
||||||
|
# nada. Se resuelve a fichero temporal y se comprueba el rc de python
|
||||||
|
# explícitamente antes de poblar el array.
|
||||||
|
carta_tmpfile="$(mktemp)"
|
||||||
|
python3 - "$carta" "$SCRIPT_DIR" > "$carta_tmpfile" <<'PYEOF'
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, sys.argv[2])
|
||||||
|
from sync_audio_to_prod import carta_article_ids
|
||||||
|
for pid in carta_article_ids(int(sys.argv[1])):
|
||||||
|
print(pid)
|
||||||
|
PYEOF
|
||||||
|
py_rc=$?
|
||||||
|
if [ $py_rc -ne 0 ]; then
|
||||||
|
echo "ERROR: fallo consultando la cola de la carta $carta (exit $py_rc), no se sincroniza nada." >&2
|
||||||
|
rm -f "$carta_tmpfile"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mapfile -t ids < "$carta_tmpfile"
|
||||||
|
rm -f "$carta_tmpfile"
|
||||||
|
else
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
|
||||||
|
total=${#ids[@]}
|
||||||
|
if [ "$total" -eq 0 ]; then
|
||||||
|
echo "Nada que sincronizar (lista de IDs vacía)." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ok=0
|
||||||
|
skip=0
|
||||||
|
n=0
|
||||||
|
for id in "${ids[@]}"; do
|
||||||
|
id="$(echo "$id" | tr -d '[:space:]')"
|
||||||
|
[ -z "$id" ] && continue
|
||||||
|
n=$((n + 1))
|
||||||
|
# stdin del hijo explícitamente en /dev/null: cinturón y tirantes además del
|
||||||
|
# array en memoria, por si algún día se cambia el bucle.
|
||||||
|
out="$(python3 "$SYNC_PY" --ids "$id" $dry_run < /dev/null)"
|
||||||
|
rc=$?
|
||||||
|
echo "$out"
|
||||||
|
if [ $rc -ne 0 ]; then
|
||||||
|
echo "=== ABORT: fallo en #$id ($n/$total procesados, $ok ok). Deteniendo lote, sin reintentar. ==="
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if echo "$out" | grep -q "PLAN: subiría\|#$id: ok"; then
|
||||||
|
ok=$((ok + 1))
|
||||||
|
else
|
||||||
|
skip=$((skip + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "=== FIN LOTE. ok=$ok skip=$skip de $total candidatos. ==="
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
sync_audio_to_prod.py — Sube a PROD los mp3 de TTS ya generados/enlazados en
|
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.
|
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).
|
Prod vive en Hetzner (Coolify/Docker) desde el cutover de agosto 2026. Subida
|
||||||
Workaround: subir el binario por stdin de ssh ("cat > ruta"), igual que el resto
|
del binario por stdin de ssh ("cat > ruta" dentro del contenedor vía
|
||||||
de scripts que tocan ese servidor (ver feadulta-server-glibc-rota.md).
|
`docker exec -i` si FEA_PROD_DOCKER_CONTAINER está definido).
|
||||||
|
|
||||||
Uso:
|
Uso:
|
||||||
python3 sync_audio_to_prod.py --carta 54254 # sincroniza toda la cola de la carta
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
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_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
|
||||||
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
||||||
|
|
||||||
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
|
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
|
||||||
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
|
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 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_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"
|
HELPER_SRC = Path(__file__).resolve().parent / "fea_post_io.php"
|
||||||
LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
|
LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
|
||||||
@@ -71,6 +75,8 @@ def local_meta(post_id: int, key: str) -> str:
|
|||||||
["docker", "exec", WP_CONTAINER, "php", "/tmp/fea_post_io.php", "getmeta", str(post_id), key],
|
["docker", "exec", WP_CONTAINER, "php", "/tmp/fea_post_io.php", "getmeta", str(post_id), key],
|
||||||
capture_output=True, text=True, timeout=60,
|
capture_output=True, text=True, timeout=60,
|
||||||
)
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(f"docker exec {WP_CONTAINER} falló leyendo meta '{key}' de #{post_id} (rc={r.returncode}): {r.stderr.strip()[:300]}")
|
||||||
return r.stdout.strip()
|
return r.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
@@ -82,22 +88,52 @@ def carta_article_ids(carta_id: int) -> list[int]:
|
|||||||
DB_NAME, "-N", "-e", q],
|
DB_NAME, "-N", "-e", q],
|
||||||
capture_output=True, text=True, timeout=60,
|
capture_output=True, text=True, timeout=60,
|
||||||
)
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(f"consulta de _carta_id={carta_id} falló (rc={r.returncode}): {r.stderr.strip()[:300]}")
|
||||||
return [int(x) for x in r.stdout.split() if x.isdigit()]
|
return [int(x) for x in r.stdout.split() if x.isdigit()]
|
||||||
|
|
||||||
|
|
||||||
# ── 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:
|
def _ssh_text(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
||||||
|
# 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",
|
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
"-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)
|
r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}…\n{r.stderr.strip()[:400]}")
|
raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}…\n{r.stderr.strip()[:400]}")
|
||||||
return r.stdout
|
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:
|
def _ssh_upload_bytes(data: bytes, remote_path: str, *, timeout: int = 180) -> None:
|
||||||
|
remote_cmd = _remote_wrap(f"cat > {remote_path}")
|
||||||
|
if PROD_PASS:
|
||||||
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
"-o", "ConnectTimeout=20", PROD_HOST, f"cat > {remote_path}"]
|
"-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)
|
sh(cmd, input_bytes=data, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
@@ -110,22 +146,28 @@ def prod_helper(subcmd: str, *args: str) -> str:
|
|||||||
_ssh_upload_bytes(HELPER_SRC.read_bytes(), PROD_HELPER)
|
_ssh_upload_bytes(HELPER_SRC.read_bytes(), PROD_HELPER)
|
||||||
_prod_helper_ready = True
|
_prod_helper_ready = True
|
||||||
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
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"
|
src = LOCAL_TTS_DIR / f"{post_id}.mp3"
|
||||||
data = src.read_bytes()
|
data = src.read_bytes()
|
||||||
|
local_sha256 = hashlib.sha256(data).hexdigest()
|
||||||
remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3"
|
remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3"
|
||||||
_ssh_upload_bytes(data, remote_path)
|
_ssh_upload_bytes(data, remote_path)
|
||||||
# Verificación de tamaño (glibc rota => sin fiarse ciegamente del rc=0 de ssh)
|
# Verificación de tamaño+contenido: un rc=0 de ssh no acredita que el asset
|
||||||
remote_size = int(_ssh_text(f"wc -c < {remote_path}").strip())
|
# 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):
|
if remote_size != len(data):
|
||||||
raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}")
|
raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}")
|
||||||
|
remote_sha256 = _ssh_text(_remote_wrap(f"sha256sum {remote_path} | cut -d' ' -f1")).strip()
|
||||||
|
if remote_sha256 != local_sha256:
|
||||||
|
raise RuntimeError(f"SHA-256 no coincide tras subir #{post_id}: local={local_sha256} remoto={remote_sha256}")
|
||||||
|
return local_sha256
|
||||||
|
|
||||||
|
|
||||||
def prod_remove_mp3(post_id: int) -> None:
|
def prod_remove_mp3(post_id: int) -> None:
|
||||||
_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 ───────────────────────────────────────────────────────────────────
|
# ── Estado ───────────────────────────────────────────────────────────────────
|
||||||
@@ -153,8 +195,9 @@ def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
|
|||||||
return "PLAN: subiría mp3 + setaudio"
|
return "PLAN: subiría mp3 + setaudio"
|
||||||
|
|
||||||
voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
|
voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
|
||||||
prod_upload_mp3(post_id)
|
sha256 = prod_upload_mp3(post_id)
|
||||||
prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice)
|
prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice)
|
||||||
|
prod_helper("setflag", str(post_id), "fea_audio_sha256", sha256)
|
||||||
if post_id not in state["synced"]:
|
if post_id not in state["synced"]:
|
||||||
state["synced"].append(post_id)
|
state["synced"].append(post_id)
|
||||||
save_state(state)
|
save_state(state)
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ WP_CONTAINER = os.environ.get("FEA_WP_CONTAINER", "wordpress-web")
|
|||||||
|
|
||||||
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
|
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
|
||||||
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
|
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"
|
PROD_HELPER = "/tmp/fea_translate_helper.php"
|
||||||
|
|
||||||
HELPER_SRC = Path(__file__).resolve().parent / "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:
|
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
||||||
|
# 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",
|
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
"-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)
|
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:
|
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
|
||||||
global _prod_ready
|
global _prod_ready
|
||||||
if not _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
|
_prod_ready = True
|
||||||
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
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:
|
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.")
|
ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no escribe en local.")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
if not PROD_HOST or not PROD_PASS:
|
if not PROD_HOST:
|
||||||
raise SystemExit(
|
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"
|
"Antes de ejecutar: source ~/.hermes/profiles/feadulta/.env"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
|
||||||
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
||||||
|
|
||||||
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
|
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
|
||||||
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
|
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"
|
PROD_HELPER = "/tmp/fea_translate_helper.php"
|
||||||
|
|
||||||
HELPER_SRC = Path(__file__).resolve().parent / "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:
|
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
||||||
|
# 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",
|
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
"-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)
|
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:
|
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
|
||||||
global _prod_ready
|
global _prod_ready
|
||||||
if not _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
|
_prod_ready = True
|
||||||
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
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:
|
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+69
@@ -0,0 +1,69 @@
|
|||||||
|
# `.doc` originales de Fray Marcos
|
||||||
|
|
||||||
|
Los 30 ficheros `.doc` (Word binario antiguo) tal y como los envía Fray Marcos por correo a
|
||||||
|
`ediciones@feadulta.com`, sin tocar. Subidos a petición de Rafa en el **issue #209**, para poder
|
||||||
|
reproducir y diffear desde este lado el problema de los `<w:smartTag>`.
|
||||||
|
|
||||||
|
## Por qué están aquí
|
||||||
|
|
||||||
|
`python-docx` **no lee el texto que va dentro de un `<w:smartTag>`**: lo salta sin dar error. Los `.doc`
|
||||||
|
de Fray Marcos arrastran esas etiquetas heredadas de versiones viejas de Word envolviendo expresiones
|
||||||
|
como «la Iglesia» o «la Cananea», así que al convertirlos se publicaban frases con palabras borradas en
|
||||||
|
silencio. Está corregido en `scripts/doc_convert.py` con `quitar_smarttags()`.
|
||||||
|
|
||||||
|
Ojo: la cadena `smartTag` **no aparece en el binario `.doc`**; surge al convertirlo a `.docx`, que es
|
||||||
|
formato OOXML. Para verlas hay que convertir primero.
|
||||||
|
|
||||||
|
## Cómo reproducir
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/doc_convert.py "tests/fixtures/fray-marcos/739_8r_Marcos_d 20 la Cananea.doc"
|
||||||
|
```
|
||||||
|
|
||||||
|
Requiere Word instalado (la conversión va por COM). `doc_convert.py` desenvuelve los smartTag y hace el
|
||||||
|
reemplazo con reintentos, porque Dropbox bloquea el fichero unos segundos justo después de que Word lo
|
||||||
|
cree (WinError 1224).
|
||||||
|
|
||||||
|
## Alcance del problema (barrido cerrado en el #209)
|
||||||
|
|
||||||
|
De los 30 `.doc` revisados, 20 traían smartTag y sumaban 46 fragmentos envueltos, pero solo **3
|
||||||
|
artículos publicados quedaron dañados** (posts `53981`, `55078` y `55659`), ya reparados en producción.
|
||||||
|
Los comentarios de febrero a junio están intactos porque se publicaron por la vía de la migración de
|
||||||
|
Joomla, no desde el `.docx`; solo quedó expuesto lo publicado por Mixbot desde julio.
|
||||||
|
|
||||||
|
## Inventario
|
||||||
|
|
||||||
|
El prefijo numérico es el número de carta; el resto es el nombre original del fichero.
|
||||||
|
|
||||||
|
| carta | semana | fichero original |
|
||||||
|
|---:|---|---|
|
||||||
|
| 713 | semana_VI_TO | `713_8r_Marcos_d 06 plenitud de ley.doc` |
|
||||||
|
| 714 | semana_I_Cuaresma | `714_8r_Marcos_Cuar 1 tentaciones.doc` |
|
||||||
|
| 715 | semana_II_Cuaresma | `715_8r_Marcos_Cuar 2 Transfigurac.doc` |
|
||||||
|
| 716 | semana_III_Cuaresma | `716_8r_Marcos_Cuar 3 Samaritana.doc` |
|
||||||
|
| 717 | semana_IV_Cuaresma | `717_8r_Marcos_Cuar 4 ciego de naci.doc` |
|
||||||
|
| 718 | semana_V_Cuaresma | `718_8r_Marcos_Cuar 5 Lázaro.doc` |
|
||||||
|
| 719 | semana_Domingo_Ramos | `719_8r_Marcos_Cuar 6 Ramos.doc` |
|
||||||
|
| 720 | semana_Santa | `720_8r_Marcos_JUEVES SANTO.doc` |
|
||||||
|
| 720 | semana_Santa | `720_8r_Marcos_Pasc 1 resucitó.doc` |
|
||||||
|
| 720 | semana_Santa | `720_8r_Marcos_VIERNES SANTO.doc` |
|
||||||
|
| 721 | semana_II_Pascua | `721_8r_Marcos_Pasc 2 Tomás.doc` |
|
||||||
|
| 722 | semana_III_Pascua | `722_8r_Marcos_Pasc 3 Emaús.doc` |
|
||||||
|
| 723 | semana_IV_Pascua | `723_8r_Marcos_Pasc 4 pastor modelo.doc` |
|
||||||
|
| 724 | semana_V_Pascua | `724_8r_Marcos_Pasc 5 camino,verdad,vida.doc` |
|
||||||
|
| 725 | semana_VI_Pascua | `725_8r_Marcos_Pasc 6 El Paráclito.doc` |
|
||||||
|
| 726 | semana_VII_Pascua | `726_8r_Marcos_Pasc 7 ascensión.doc` |
|
||||||
|
| 727 | semana_Pentecostes | `727_8r_Marcos_PENTECOSTÉS.doc` |
|
||||||
|
| 728 | semana_Trinidad | `728_8r_Marcos_TRINIDAD.doc` |
|
||||||
|
| 729 | semana_Corpus | `729_8r_Marcos_CORPUS.doc` |
|
||||||
|
| 730 | semana_11_TO | `730_8r_Marcos_d 11 misión de los 12.doc` |
|
||||||
|
| 731 | semana_12_TO | `731_8r_Marcos_d 12 no tengas miedo.doc` |
|
||||||
|
| 732 | semana_13_TO | `732_8r_Marcos_d 13 amor a Dios o família.doc` |
|
||||||
|
| 733 | semana_14_TO | `733_8r_Marcos_d 14 sencillez.doc` |
|
||||||
|
| 734 | semana_15_TO | `734_8r_Marcos_d 15 sembrador.doc` |
|
||||||
|
| 735 | semana_16_TO | `735_8r_Marcos_d 16 cizaña.doc` |
|
||||||
|
| 736 | semana_17_TO | `736_8r_Marcos_d 17 tesoro y perla.doc` |
|
||||||
|
| 737 | semana_18_TO | `737_8r_Marcos_d 18 panes y peces.doc` |
|
||||||
|
| 738 | semana_19_TO | `738_8r_Marcos_d 19 Qué poca fe.doc` |
|
||||||
|
| 739 | semana_20_TO | `739_8r_Marcos_d 20 la Cananea.doc` |
|
||||||
|
| 740 | semana_21_TO | `740_8r_Marcos_d 21 quién es Jesús.doc` |
|
||||||
Reference in New Issue
Block a user