diff --git a/.gitignore b/.gitignore
index d42a698..ca8a984 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,6 +40,13 @@ __pycache__/
# Logs
*.log
+logs/
+
+# Muestras de voz para clonación TTS (GBs, no es código) — ver tts-voices/
+tts-voices/
+
+# Backups sueltos con fecha en el nombre
+*.bak-*
# Claude Code local settings
.claude/
diff --git a/README.md b/README.md
index 1648faf..31e4698 100644
--- a/README.md
+++ b/README.md
@@ -2,18 +2,18 @@
Working tree del proyecto feadulta.org. WordPress nuevo, scripts de migración K2 → WP, mu-plugins custom.
-**La documentación operativa está en la [wiki del repo](http://localhost:3000/rafa/feadulta/wiki).**
+**La documentación operativa está en la [wiki del repo](https://gitea.feadulta.com/rafa/feadulta/wiki).**
## Enlaces rápidos
-- [Wiki — Home](http://localhost:3000/rafa/feadulta/wiki)
-- [Credenciales y accesos](http://localhost:3000/rafa/feadulta/wiki/Credenciales-y-accesos)
-- [Infraestructura](http://localhost:3000/rafa/feadulta/wiki/Infraestructura)
-- [Arquitectura WordPress](http://localhost:3000/rafa/feadulta/wiki/Arquitectura-WordPress)
-- [Sincronización local → producción](http://localhost:3000/rafa/feadulta/wiki/Sincronizacion-local-prod)
-- [Limitaciones del servidor de producción](http://localhost:3000/rafa/feadulta/wiki/Limitaciones-servidor-prod)
-- [Roadmap](http://localhost:3000/rafa/feadulta/wiki/Roadmap)
-- [Issues](http://localhost:3000/rafa/feadulta/issues)
+- [Wiki — Home](https://gitea.feadulta.com/rafa/feadulta/wiki)
+- [Credenciales y accesos](https://gitea.feadulta.com/rafa/feadulta/wiki/Credenciales-y-accesos)
+- [Infraestructura](https://gitea.feadulta.com/rafa/feadulta/wiki/Infraestructura)
+- [Arquitectura WordPress](https://gitea.feadulta.com/rafa/feadulta/wiki/Arquitectura-WordPress)
+- [Sincronización local → producción](https://gitea.feadulta.com/rafa/feadulta/wiki/Sincronizacion-local-prod)
+- [Limitaciones del servidor de producción](https://gitea.feadulta.com/rafa/feadulta/wiki/Limitaciones-servidor-prod)
+- [Roadmap](https://gitea.feadulta.com/rafa/feadulta/wiki/Roadmap)
+- [Issues](https://gitea.feadulta.com/rafa/feadulta/issues)
## Runbooks locales
@@ -31,7 +31,7 @@ Working tree del proyecto feadulta.org. WordPress nuevo, scripts de migración K
├── tools/
│ ├── e2e/ Suite Playwright + Gemma vision
│ └── akeeba-kickstart/ Kickstart.php para restaurar .jpa
-├── backups/ Backups históricos (gitignored, ~22 GB) — ver backups/README.md
+├── (backups/ ya no está aquí — movida a N:\Backup\Joomla_db el 2026-07-06, ~25 GB, fuera de WSL)
├── archive/ Scripts setup-inicial y logs migración (gitignored)
├── analisis-cartas/ Histórico de cartas semanales
├── evangelios_html/ HTML de los evangelios
diff --git a/backups/README.md b/backups/README.md
index 73c04a4..013ce54 100644
--- a/backups/README.md
+++ b/backups/README.md
@@ -1,108 +1,58 @@
-# Backups de feadulta — inventario
+# backups/ — inventario
-Organizado por fecha y propósito. Esta carpeta está **gitignored** (volúmenes en GBs). Mantén este README al día cuando añadas o elimines archivos.
+Directorio para backups pesados (GBs), excluido de git (`.gitignore: backups/*`). Solo este
+README se trackea.
-Reorganizado: 2026-05-26 (consolidación de `backup/` + `backups/` + Akeeba sueltos en `capturas/`).
+## prod-mirror/
+Dumps de la base de datos de prod (`wp-nuevo.feadulta.com`, servidor `134.0.10.170`), para que
+el WP local Docker (`docker-compose.yml`) sea un espejo funcional de prod por si hay una caída
+o se borra algo por accidente.
-## Estructura
+- **Cómo generarlo:** `mysqldump` **directo por SSH** (no vía `wp-cli`, que tiene `proc_open()`
+ deshabilitado en prod): `ssh feadulta@134.0.10.170 "mysqldump -h 127.0.0.1 -u myfeadulta
+ -p'' --skip-ssl --single-transaction --quick --default-character-set=utf8mb4
+ "`. `DB_NAME` cambia — leerlo de `/web/wp-nuevo/wp-config.php`. Credenciales en
+ memoria `master-feadulta.md`.
+- **⚠️ El backup diario de UpdraftPlus (`wp-content/updraft/*-db.gz` en prod) NO sirve como
+ fuente para este espejo si necesitas el estado de HOY** — es un cron nocturno (~03:09 GMT),
+ puede tener hasta 24h de retraso frente al contenido publicado ese mismo día.
+- **⚠️ Colación MariaDB incompatible con MySQL 8** (el Docker local usa `mysql:8.0`, prod es
+ MariaDB 11.8): el dump trae `utf8mb3_uca1400_ai_ci` / `utf8mb4_uca1400_ai_ci`, que MySQL 8 no
+ reconoce (falla el import). Sustituir antes de importar:
+ ```bash
+ sed -e 's/utf8mb4_uca1400_ai_ci/utf8mb4_0900_ai_ci/g' \
+ -e 's/utf8mb3_uca1400_ai_ci/utf8mb3_general_ci/g' \
+ dump.sql > dump.mysql8.sql
+ ```
+- **Importar:** `docker exec -i wordpress-mysql mysql -uroot -pwordpress_root_pass
+ --default-character-set=utf8mb4 wordpress_db < dump.mysql8.sql`
+- **Tras importar**, `siteurl`/`home` en `wp_options` quedan con el valor de prod
+ (`wp-nuevo.feadulta.com`) — para que el WP local sea navegable en su propia URL, actualizar a
+ `https://farmer.taild3aaf6.ts.net/fea` (Tailscale HTTPS; Application Passwords/login solo
+ funcionan sobre SSL o localhost).
-```
-backups/
-├── feadulta-20260111-pre-incidente/ 2.8 G Akeeba Enero (último limpio antes del malware)
-├── feadulta-20260306-akeeba/ 2.8 G Akeeba Marzo (referencia migración)
-├── feadulta-20260525-INFECTED/ 11 G Estado pre-limpieza + dump DB + hotfixes PHP83
-├── feadulta-clean-20260525/ 5.4 G Estado post-limpieza (limpio) + Akeeba post-limpieza
-├── wp-local-dumps-20260525/ 47 M Dumps WP local (Docker) del día de las mejoras
-└── README.md Este fichero
-```
-
-Total: ~22 GB.
-
-## feadulta-20260111-pre-incidente/
-
-Akeeba Backup de Enero 2026, **antes** de que el escáner antimalware del hosting reportara las detecciones (issue #49). Probablemente la cuenta `ssmith` ya estaba creada (12 enero) pero el malware más reciente (`com_aimysitemap` con fecha Oct 25 2025) aún no había sido depositado o lo había sido muy poco antes. **No es garantía de limpieza** — usar solo como referencia histórica.
-
-| Fichero | Tamaño | MD5 |
-|---------|--------|-----|
-| `site-www.feadulta.com-20260111-201614-DP_213gSrB86EMc_.j01` | 2.0 G | `43ff49190faecd5c49f6668d7ec3b818` |
-| `site-www.feadulta.com-20260111-201614-DP_213gSrB86EMc_.jpa` | 803 M | `0b41e9559e064c211ba52f7d5bb5fc80` |
-
-## feadulta-20260306-akeeba/
-
-Akeeba Backup de Marzo 2026, ya con el malware presente en el filesystem. Útil como referencia del estado durante la migración K2→WP.
-
-| Fichero | Tamaño | MD5 |
-|---------|--------|-----|
-| `site-www.feadulta.com-20260306-011457-1os52_Qdg5-vCycH.j01` | 2.0 G | `4859ea1e8f707a96ab553830f5123146` |
-| `site-www.feadulta.com-20260306-011457-1os52_Qdg5-vCycH.jpa` | 807 M | `c442753105c62c79e27f6e031e956a9f` |
-
-## feadulta-20260525-INFECTED/
-
-Estado **infectado** capturado el 25-mayo, antes de las fases B+C+D de limpieza (issue #49). Contiene también los snapshots intermedios del deploy PHP 8.3 (issues #46, #47).
-
-| Fichero | Tamaño | MD5 | Propósito |
-|---------|--------|-----|-----------|
-| `feadulta-web-20260525.INFECTED.tar.gz` | 11 G | `f74edeaff2e11feb7593f95c47331f84` | Filesystem completo `/web/` antes de limpiar |
-| `fejoomla3-20260525.sql.gz` | 64 M | `0671407797c5bcd62129dc8dd8e9129e` | Dump DB pre-limpieza |
-| `pre-step1-20260525-184255*` | <1 M | — | Snapshot antes del deploy PHP83 step 1 |
-| `pre-hotfix-itemlistfilter-20260525-190134/` | <1 M | — | Snapshot pre-hotfix #47 |
-| `pre-hotfix-search-20260525-191650/` | <1 M | — | Snapshot pre-hotfix buscador |
-| `pre-malware-cleanup-20260525-194000.INFECTED*` | <1 M | — | Snapshot justo antes de la limpieza |
-
-**Restauración:** este directorio contiene el último estado conocido íntegro antes del cambio. **Solo restaurar en una instalación aislada** — no es un estado limpio.
-
-## feadulta-clean-20260525/
-
-Estado **limpio** tras ejecutar las fases B+C+D de limpieza (24 horas antes del cutover previsto). Contiene tanto un tar.gz mío del filesystem como el Akeeba oficial post-limpieza.
-
-| Fichero | Tamaño | MD5 | Propósito |
-|---------|--------|-----|-----------|
-| `feadulta-com-web-clean-20260525.tar.gz` | 2.7 G | `2aef9448238da8bccbbdcede15f2654a` | Filesystem `/web/` excluyendo `administrator/components/com_akeeba/backup/`, `cache/`, `tmp/`, `logs/` |
-| `akeeba/site-www.feadulta.com-20260526-061340-rPlHC3AhISdZpLnb.j01` | 2.0 G | `8ea195c112541b298bf2abe54c6dceac` | Akeeba parte 1 (verificado md5 contra el servidor el 2026-05-26) |
-| `akeeba/site-www.feadulta.com-20260526-061340-rPlHC3AhISdZpLnb.jpa` | 772 M | `0aa321822d537b2b097c48e848441e8e` | Akeeba parte 2 + manifest |
-| `evidencia/malware-backup-20260525.tar.gz` | 5.5 M | — | Fase B v1 (3 extensiones iniciales) |
-| `evidencia/malware-backup-20260525-v2.tar.gz` | 5.5 M | — | Fase B v2 (9 extensiones tras descubrir sub-paquetes) |
-| `evidencia/malware-fase-c-20260525.tar.gz` | 111 K | — | Fase C (ficheros sueltos) |
-| `evidencia/malware-fase-d-residuales-20260525.tar.gz` | 2.8 M | — | Fase D (residuales frontend + .ini) |
-| `evidencia/local-malware-quarantine-20260525-200939.tar.gz` | 71 K | — | Cuarentena del WP local Docker |
-| `evidencia/ssess_d45d182b50f80a5a9b73503603ffb552.bak` | 78 B | — | Sesión Joomla expirada (falso positivo) |
-
-**Restauración del Akeeba post-limpieza:** copiar las dos partes (`.j01` + `.jpa`) al mismo directorio en el destino + `tools/akeeba-kickstart/kickstart.php`, abrir `kickstart.php` en el navegador y seguir las instrucciones.
-
-## wp-local-dumps-20260525/
-
-Dos snapshots de la BD WordPress local Docker del día de las mejoras (issue #38 corrección de enlaces Joomla, issue #43 nextend slider, etc.). Conservados como referencia.
-
-| Fichero | Propósito |
-|---------|-----------|
-| `wp_db_pre_image_fix_20260525-062643.sql.gz` | Dump completo antes de la primera ronda de fix de imágenes |
-| `wp_term_pre_clasif_20260525-154817.sql.gz` | Tablas de términos antes de reclasificación final |
-
-Los 6 dumps intermedios entre estas dos marcas se descartaron el 2026-05-26 (eran snapshots de hotfixes ya consolidados).
-
-## Restauración rápida
-
-| Quiero... | Usa... |
-|-----------|--------|
-| Recuperar el estado de producción **antes** del incidente | `feadulta-20260111-pre-incidente/` (Akeeba) |
-| Recuperar el estado **infectado** (forense) | `feadulta-20260525-INFECTED/feadulta-web-20260525.INFECTED.tar.gz` |
-| Recuperar el estado **limpio** post-limpieza (filesystem) | `feadulta-clean-20260525/feadulta-com-web-clean-20260525.tar.gz` |
-| Recuperar el estado **limpio** + DB (Akeeba) | `feadulta-clean-20260525/akeeba/*.j01 + *.jpa` |
-| Volver al WP local de antes de las mejoras del 25-mayo | `wp-local-dumps-20260525/wp_db_pre_image_fix_*.sql.gz` |
-
-## Verificación de integridad
+## local-pre-mirror/
+Backup de seguridad de la BD local **antes** de sobrescribirla con un import de prod (por si el
+import sale mal o hacía falta algo que solo estaba en local). `mysqldump` dentro del contenedor:
+`docker exec wordpress-mysql sh -c 'exec mysqldump -uroot -pwordpress_root_pass wordpress_db'`.
+## uploads/ (no es un dump, es el propio bind mount)
+`wp-content/uploads/` del WP local **no** se respalda aquí — se sincroniza directamente sobre
+`wordpress/wp-content/uploads/` vía `rsync` desde prod. Los ficheros del volumen quedan
+propiedad de `www-data` (creados por el contenedor) — si `rsync` falla con "Permission denied"
+al escribir desde el host (usuario `rafa`), ejecutar el rsync **desde dentro del contenedor**
+como root en vez de desde el host:
```bash
-cd /home/rafa/joomla-migration/backups
-md5sum -c <<'EOF'
-43ff49190faecd5c49f6668d7ec3b818 feadulta-20260111-pre-incidente/site-www.feadulta.com-20260111-201614-DP_213gSrB86EMc_.j01
-0b41e9559e064c211ba52f7d5bb5fc80 feadulta-20260111-pre-incidente/site-www.feadulta.com-20260111-201614-DP_213gSrB86EMc_.jpa
-4859ea1e8f707a96ab553830f5123146 feadulta-20260306-akeeba/site-www.feadulta.com-20260306-011457-1os52_Qdg5-vCycH.j01
-c442753105c62c79e27f6e031e956a9f feadulta-20260306-akeeba/site-www.feadulta.com-20260306-011457-1os52_Qdg5-vCycH.jpa
-f74edeaff2e11feb7593f95c47331f84 feadulta-20260525-INFECTED/feadulta-web-20260525.INFECTED.tar.gz
-0671407797c5bcd62129dc8dd8e9129e feadulta-20260525-INFECTED/fejoomla3-20260525.sql.gz
-2aef9448238da8bccbbdcede15f2654a feadulta-clean-20260525/feadulta-com-web-clean-20260525.tar.gz
-8ea195c112541b298bf2abe54c6dceac feadulta-clean-20260525/akeeba/site-www.feadulta.com-20260526-061340-rPlHC3AhISdZpLnb.j01
-0aa321822d537b2b097c48e848441e8e feadulta-clean-20260525/akeeba/site-www.feadulta.com-20260526-061340-rPlHC3AhISdZpLnb.jpa
-EOF
+docker exec -u 0 wordpress-web bash -c "apt-get update -qq && apt-get install -y -qq rsync openssh-client sshpass"
+docker exec -u 0 -e SSHPASS='' wordpress-web bash -c "
+ sshpass -e rsync -e 'ssh -o StrictHostKeyChecking=accept-new' -avz \
+ feadulta@134.0.10.170:/web/wp-nuevo/wp-content/uploads/ \
+ /var/www/html/wp-content/uploads/"
```
+
+## Histórico
+- **2026-07-07:** primer espejo completo prod→local de esta ronda. mu-plugins (24 ficheros,
+ diff por checksum — 1 sumado que faltaba en local: `fea-cloudflare-realip.php`, desplegado a
+ prod directamente en su día sin bajar copia; 1 sobrante local-only sin desplegar:
+ `fea-support-campaign.php`, WIP, se deja). DB: dump fresco directo (no el de UpdraftPlus,
+ desactualizado). uploads/: rsync completo, 5.6GB, 45.879 ficheros.
diff --git a/docker-compose.yml b/docker-compose.yml
index 263a8ef..5047e12 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -74,6 +74,7 @@ services:
- "8081:80"
volumes:
- ./wordpress:/var/www/html
+ - ./joomla/images:/var/www/joomla-images:ro
depends_on:
- wordpress-db
networks:
diff --git a/docs/guia-publicacion-carta-inma.md b/docs/guia-publicacion-carta-inma.md
index 3c072e7..3ebad8c 100644
--- a/docs/guia-publicacion-carta-inma.md
+++ b/docs/guia-publicacion-carta-inma.md
@@ -19,11 +19,13 @@
| Entorno | URL | Estado |
|---------|-----|--------|
-| **Beta (actual)** | `https://wp-nuevo.feadulta.com` | Es donde se trabaja **ahora** |
-| Producción final | `https://feadulta.com` | Tras el "cutover" de DNS (cambiará la URL base) |
+| **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í |
-Mientras no se avise, **todo va a `wp-nuevo.feadulta.com`**. Cuando se haga el cambio de
-dominio, solo hay que sustituir la URL base en los ejemplos de abajo.
+**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
@@ -33,7 +35,7 @@ No hace falta SSH ni tocar la base de datos. WordPress trae una API REST y un si
**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://wp-nuevo.feadulta.com/wp-admin`.
+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
@@ -45,10 +47,10 @@ usuario):
```bash
curl -s -u "USUARIO:xxxx xxxx xxxx xxxx xxxx xxxx" \
- https://wp-nuevo.feadulta.com/wp-json/wp/v2/users/me
+ https://www.feadulta.com/wp-json/wp/v2/users/me
```
-Base de la API para todo lo demás: `https://wp-nuevo.feadulta.com/wp-json/wp/v2/`
+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
@@ -96,8 +98,8 @@ Las categorías se asignan por su **ID numérico** vía API (campo `categories:
| 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. |
-| **21** | Cartas de otras semanas | `cartas-de-otras-semanas` | Histórico (todas las anteriores). |
+| **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)
@@ -128,7 +130,7 @@ estén listos, publicarlos.
```bash
curl -s -u "USUARIO:APP_PASSWORD" \
- -X POST https://wp-nuevo.feadulta.com/wp-json/wp/v2/posts \
+ -X POST https://www.feadulta.com/wp-json/wp/v2/posts \
-H "Content-Type: application/json" \
-d '{
"title": "Título del artículo",
@@ -145,6 +147,56 @@ De la respuesta JSON interesan dos campos:
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/` (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:
@@ -156,7 +208,9 @@ 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`).
+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
@@ -164,35 +218,53 @@ La carta va en la categoría **6** (`cartasemana`).
```bash
curl -s -u "USUARIO:APP_PASSWORD" \
- -X POST https://wp-nuevo.feadulta.com/wp-json/wp/v2/posts \
+ -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": "…cuerpo de la carta con sus secciones y enlaces…
",
"status": "future",
"date": "2026-06-29T08:00:00",
- "categories": [6]
+ "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:
+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").
-2. A la que estaba en **22**: pasarla a la **21** ("Cartas de otras semanas").
+ ("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):
+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://wp-nuevo.feadulta.com/wp-json/wp/v2/posts/ID_DE_LA_CARTA_VIEJA \
+ -X POST https://www.feadulta.com/wp-json/wp/v2/posts/ID_DE_LA_CARTA_VIEJA \
-H "Content-Type: application/json" \
- -d '{"categories": [22]}'
+ -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).
@@ -220,6 +292,12 @@ de portada indicado:
- 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:**
@@ -228,23 +306,23 @@ de portada indicado:
Evangelio y comentarios al Evangelio
Artículos seleccionados para la semana
Para unas eucaristías más participativas y actuales
Material multimedia
```
@@ -276,31 +354,42 @@ de portada indicado:
---
-## 7. Traducción y audio automáticos (lado servidor — informativo)
+## 7. Traducción y audio (lado servidor — ⚠️ hoy es MANUAL, no automático)
-**Inma no tiene que traducir ni generar audio.** En el servidor hay (o habrá) un proceso
-programado (cron) que:
+**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.
-1. Detecta cartas y artículos nuevos publicados **en español** sin traducción.
-2. Los **traduce** a EN/FR/IT/PT y los enlaza como traducciones (Polylang).
-3. Genera el **audio TTS** (voz, MiniMax) de la carta y sus artículos.
+**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 es importante: **subir siempre el contenido en español** y dejar que el proceso haga
-el resto. Si una carta urgente necesita traducción inmediata, avisar a Rafa.
+**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í solo para que el
-> asistente de Inma sepa que no debe duplicar ese trabajo.*
+> *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 (borrador → publicado). Guardar su URL.
+- [ ] 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 la categoría **6**.
-- [ ] Rotar la carta anterior: 6 → 22, y la de 22 → 21.
+- [ ] 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: lo hace el servidor.
+- [ ] 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.
---
@@ -314,6 +403,9 @@ el resto. Si una carta urgente necesita traducción inmediata, avisar a Rafa.
| 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}` |
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).
diff --git a/docs/guia-tts-traduccion-inma.md b/docs/guia-tts-traduccion-inma.md
new file mode 100644
index 0000000..dfdaa18
--- /dev/null
+++ b/docs/guia-tts-traduccion-inma.md
@@ -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 --langs en,fr,it,pt --status draft
+# o para un solo artículo:
+python3 scripts/translate_post.py --post-id --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="" 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
+# deshacer si algo suena mal:
+python3 scripts/sync_audio_to_prod.py --rollback --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.
diff --git a/docs/handoff-tecnico-rafa-issues-174-175-2026-07-15.md b/docs/handoff-tecnico-rafa-issues-174-175-2026-07-15.md
new file mode 100644
index 0000000..a26b9a8
--- /dev/null
+++ b/docs/handoff-tecnico-rafa-issues-174-175-2026-07-15.md
@@ -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=`
+- 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.
diff --git a/docs/revision-issues-2026-07-15.md b/docs/revision-issues-2026-07-15.md
new file mode 100644
index 0000000..7ce1d15
--- /dev/null
+++ b/docs/revision-issues-2026-07-15.md
@@ -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.
diff --git a/docs/revision-issues-live-gitea-2026-07-15.md b/docs/revision-issues-live-gitea-2026-07-15.md
new file mode 100644
index 0000000..0e2278c
--- /dev/null
+++ b/docs/revision-issues-live-gitea-2026-07-15.md
@@ -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
diff --git a/scripts/carta-semana-plugin.php b/scripts/carta-semana-plugin.php
old mode 100644
new mode 100755
index 11659d7..98a4dba
--- a/scripts/carta-semana-plugin.php
+++ b/scripts/carta-semana-plugin.php
@@ -2,17 +2,74 @@
/**
* Plugin Name: Fe Adulta — Carta de la Semana
* Description: Redirige las URLs de carta al archivo de categoría correspondiente.
- * Version: 1.4
+ * Version: 1.8
*/
// Redirigir las páginas custom a las categorías
-add_action('template_redirect', function() {
- if (is_page('carta-de-la-semana')) {
- wp_redirect(home_url('/category/cartasemana/'), 302);
+add_action("template_redirect", function() {
+ if (is_page("carta-de-la-semana")) {
+ wp_redirect(home_url("/category/cartasemana/"), 302);
exit;
}
- if (is_page('la-semana-pasada')) {
- wp_redirect(home_url('/category/carta-semana-pasada/'), 302);
+ if (is_page("la-semana-pasada")) {
+ wp_redirect(home_url("/category/carta-semana-pasada/"), 302);
exit;
}
});
+
+// Las categorías de carta actual/anterior siempre llevan al post traducido que
+// corresponde a la categoría española canónica. No dependemos del count ni de
+// las relaciones traducidas, que pueden quedar desfasadas durante una importación.
+add_action("template_redirect", function() {
+ if (!is_category()) return;
+ $cat = get_queried_object();
+ if (!$cat || empty($cat->term_id)) return;
+
+ $source_cat_id = (int) $cat->term_id;
+ if (function_exists('pll_get_term')) {
+ $spanish_cat_id = (int) pll_get_term($source_cat_id, 'es');
+ if ($spanish_cat_id) $source_cat_id = $spanish_cat_id;
+ }
+ if (!in_array($source_cat_id, [6, 22], true)) return;
+
+ global $wpdb;
+ $source_post_id = (int) $wpdb->get_var($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 tt.taxonomy = 'category' AND tt.term_id = %d
+ AND p.post_type = 'post' AND p.post_status = 'publish'
+ ORDER BY p.post_date DESC, p.ID DESC
+ LIMIT 1",
+ $source_cat_id
+ ));
+ if (!$source_post_id) return;
+
+ $post_id = $source_post_id;
+ if (function_exists('pll_current_language') && function_exists('pll_get_post')) {
+ $lang = pll_current_language();
+ $translated = $lang ? (int) pll_get_post($source_post_id, $lang) : 0;
+ if ($translated) $post_id = $translated;
+ }
+
+ $url = get_permalink($post_id);
+ if (!$url) return;
+ wp_safe_redirect($url, 302);
+ exit;
+}, 9);
+
+// Mostrar 50 artículos por página en los archivos de cartas
+add_action("pre_get_posts", function($query) {
+ if (!$query->is_main_query() || is_admin()) return;
+ if ($query->is_category([
+ "cartasemana", "carta-semana-pasada", "cartas-de-otras-semanas",
+ "letter-of-the-week", "lettre-de-la-semaine", "lettera-della-settimana", "carta-da-semana",
+ "carta-semana-pasada-en", "carta-semana-pasada-fr",
+ "carta-semana-pasada-it", "carta-semana-pasada-pt",
+ "letters-from-other-weeks", "lettres-des-autres-semaines",
+ "lettere-delle-altre-settimane", "cartas-de-outras-semanas",
+ ])) {
+ $query->set("posts_per_page", 50);
+ }
+});
diff --git a/scripts/create_lecturas.php b/scripts/create_lecturas.php
index 6f0a424..96b8d64 100644
--- a/scripts/create_lecturas.php
+++ b/scripts/create_lecturas.php
@@ -1,24 +1,37 @@
'MATTHEW','fr'=>'MATTHIEU','it'=>'MATTEO','pt'=>'MATEUS'];
$VER = ['en'=>'Douay-Rheims Bible','fr'=>'Bible du Semeur 2015','it'=>'Nuova Riveduta 2006','pt'=>'Bíblia CNBB 2002'];
-$REST = 'MATEO 10, 26-33'; // título es
+$REST = getenv('LECTURA_TITULO_ES') ?: 'MATEO 10, 26-33'; // título es
$tail = preg_replace('~^MATEO~','',$REST); // " 10, 26-33"
$es_cats = wp_get_post_categories($ES);
+$target_ids = array_values(array_filter(array_map('intval', explode(',', getenv('TARGET_IDS') ?: ''))));
+$target_map = [];
+foreach (['en','fr','it','pt'] as $idx => $lang0) {
+ if (!empty($target_ids[$idx])) $target_map[$lang0] = (int)$target_ids[$idx];
+}
$grp = pll_get_post_translations($ES); if(!$grp) $grp=['es'=>$ES];
foreach (['en','fr','it','pt'] as $lang) {
$exist = (int)pll_get_post($ES,$lang);
if ($exist && get_post($exist)) { echo "$lang ya existe #$exist — saltado\n"; $grp[$lang]=$exist; continue; }
$title = $BOOK[$lang].$tail;
- $id = wp_insert_post([
+ $postarr = [
'post_title'=>$title,'post_content'=>$tr_html[$lang],'post_status'=>'publish',
'post_type'=>'post','post_author'=>(int)$src->post_author,'post_date'=>$src->post_date,
- ], true);
+ ];
+ $target = (int)($target_map[$lang] ?? 0);
+ if ($target && get_post($target)) {
+ $postarr['ID'] = $target;
+ } elseif ($target) {
+ $postarr['import_id'] = $target;
+ }
+ $id = wp_insert_post($postarr, true);
if (is_wp_error($id)) { echo "$lang ERROR ".$id->get_error_message()."\n"; continue; }
+ if ($target && (int)$id !== $target) { echo "$lang ERROR id esperado $target creado $id\n"; continue; }
pll_set_post_language($id,$lang);
$mapped=[]; foreach($es_cats as $c){ $tc=(int)pll_get_term($c,$lang); $mapped[]=$tc?:$c; }
wp_set_post_categories($id, array_values(array_unique($mapped)));
diff --git a/scripts/demote_old_cartasemana.php b/scripts/demote_old_cartasemana.php
old mode 100644
new mode 100755
index 5e02c19..506656b
--- a/scripts/demote_old_cartasemana.php
+++ b/scripts/demote_old_cartasemana.php
@@ -1,13 +1,10 @@
ejecutar esto tras publicar las traducciones.
+ * Ciclo carta nueva — sincroniza "esta semana", "semana pasada" y "otras semanas"
+ * en TODOS los idiomas (ES + EN/FR/IT/PT).
*
* Deriva los términos por Polylang desde los términos ES base:
- * cartasemana = term 6 | cartas-de-otras-semanas = term 21
+ * cartasemana = term 6 | otras semanas = term 21 | semana pasada = term 22
*
* Uso: CARTA= php demote_old_cartasemana.php (dry-run)
* APPLY=1 CARTA= php demote_old_cartasemana.php
@@ -19,11 +16,26 @@ if (!$CARTA) { fwrite(STDERR,"Falta CARTA=\n"); exit(1); }
$cs_terms = pll_get_term_translations(6); // cartasemana por idioma
$otras_terms = pll_get_term_translations(21); // cartas de otras semanas por idioma
+$last_terms = pll_get_term_translations(22); // carta semana pasada por idioma
$carta_tr = pll_get_post_translations($CARTA);
+$last_es_posts = get_posts([
+ 'post_type' => 'post',
+ 'numberposts' => 1,
+ 'post_status' => 'publish',
+ 'fields' => 'ids',
+ 'cat' => 22,
+ 'orderby' => 'date',
+ 'order' => 'DESC',
+ 'suppress_filters' => true,
+]);
+$last_es = (int) ($last_es_posts[0] ?? 0);
+$last_tr = $last_es ? pll_get_post_translations($last_es) : [];
foreach ($cs_terms as $lang=>$cs) {
$keep = $carta_tr[$lang] ?? 0;
$otras = $otras_terms[$lang] ?? 0;
+ $last = $last_terms[$lang] ?? 0;
+ $keep_last = $last_tr[$lang] ?? 0;
$posts = get_posts(['post_type'=>'post','numberposts'=>-1,'post_status'=>'any','fields'=>'ids',
'tax_query'=>[['taxonomy'=>'category','field'=>'term_id','terms'=>$cs]]]);
$moved=0;
@@ -35,9 +47,23 @@ foreach ($cs_terms as $lang=>$cs) {
}
$moved++;
}
- if ($APPLY) clean_term_cache([$cs,$otras],'category');
+ $last_posts = $last ? get_posts([
+ 'post_type'=>'post','numberposts'=>-1,'post_status'=>'any','fields'=>'ids',
+ 'tax_query'=>[['taxonomy'=>'category','field'=>'term_id','terms'=>$last]],
+ ]) : [];
+ $last_removed = 0;
+ foreach ($last_posts as $pid) {
+ if ($pid == $keep_last) continue;
+ if ($APPLY) wp_remove_object_terms($pid, [(int)$last], 'category');
+ $last_removed++;
+ }
+ if ($APPLY && $last && $keep_last) {
+ wp_set_object_terms($keep_last, [(int)$last], 'category', true);
+ }
+ if ($APPLY) clean_term_cache(array_filter([$cs,$otras,$last]),'category');
$t=get_term($cs);
- echo sprintf("%s: %s %d | '%s' count=%d keep=#%d\n", strtoupper($lang),
- $APPLY?"movidas":"se moverían", $moved, $t->slug, $t->count, $keep);
+ echo sprintf("%s: %s %d de actual | anterior=#%d (limpia %d) | '%s' keep=#%d\n",
+ strtoupper($lang), $APPLY?"movidas":"se moverían", $moved,
+ $keep_last, $last_removed, $t->slug, $keep);
}
echo $APPLY ? "APLICADO\n" : "DRY-RUN (APPLY=1 para aplicar)\n";
diff --git a/scripts/detect_untranslated.php b/scripts/detect_untranslated.php
new file mode 100644
index 0000000..8f6cd8c
--- /dev/null
+++ b/scripts/detect_untranslated.php
@@ -0,0 +1,77 @@
+| |~', "\n", $html);
+ $t = preg_replace('~<[^>]+>~', ' ', $t);
+ $t = preg_replace('~\[[^\]]+\]~', ' ', $t);
+ $t = html_entity_decode($t, ENT_QUOTES);
+ return $t;
+}
+/** Frases normalizadas de longitud >= 40 (las cortas dan falsos positivos). */
+function sentences($html) {
+ $t = norm_text($html);
+ $parts = preg_split('~(?<=[.!?…])\s+|\n+~u', $t);
+ $out = [];
+ foreach ($parts as $s) {
+ $s = trim(preg_replace('~\s+~u', ' ', $s));
+ $s = mb_strtolower($s);
+ if (mb_strlen($s) >= 40) $out[$s] = mb_strlen($s);
+ }
+ return $out;
+}
+
+$statuses = $STATUS === 'any' ? ['draft','publish'] : [$STATUS];
+$in = "'" . implode("','", $statuses) . "'";
+$ids = $wpdb->get_col(
+ "SELECT p.ID FROM wp_posts p
+ JOIN wp_term_relationships tr ON tr.object_id=p.ID
+ JOIN wp_term_taxonomy tt ON tt.term_taxonomy_id=tr.term_taxonomy_id AND tt.taxonomy='language'
+ JOIN wp_terms t ON t.term_id=tt.term_id AND t.slug IN ('en','fr','it','pt')
+ WHERE p.post_type='post' AND p.post_status IN ($in)
+ GROUP BY p.ID"
+);
+
+$by_lang = []; $offenders = [];
+foreach ($ids as $id) {
+ $lang = pll_get_post_language($id);
+ $es = pll_get_post((int)$id, 'es');
+ if (!$es) continue;
+ $tr_s = sentences(get_post($id)->post_content);
+ if (!$tr_s) continue;
+ $es_s = sentences(get_post($es)->post_content);
+ if (!$es_s) continue;
+ $total = array_sum($tr_s); $match = 0;
+ foreach ($tr_s as $s => $len) if (isset($es_s[$s])) $match += $len;
+ $ratio = $total ? $match / $total : 0;
+ $by_lang[$lang]['n'] = ($by_lang[$lang]['n'] ?? 0) + 1;
+ if ($ratio >= $THRESH) {
+ $by_lang[$lang]['bad'] = ($by_lang[$lang]['bad'] ?? 0) + 1;
+ $offenders[] = [$id, $lang, $es, round($ratio, 2), get_post($id)->post_title];
+ }
+}
+
+usort($offenders, fn($a, $b) => $b[3] <=> $a[3]);
+echo "=== Traducciones con fragmentos ES (ratio >= $THRESH, status=$STATUS) ===\n";
+foreach ($offenders as $o)
+ echo sprintf("#%d [%s] ratio=%.2f es=%d %s\n", $o[0], $o[1], $o[3], $o[2], mb_substr($o[4], 0, 45));
+echo "\n--- resumen por idioma ---\n";
+foreach ($by_lang as $l => $d)
+ echo sprintf("%s: %d/%d con fragmentos ES\n", $l, $d['bad'] ?? 0, $d['n']);
+echo "TOTAL ofensores: " . count($offenders) . "\n";
+// Volcar IDs para el reprocesado
+file_put_contents('/tmp/untranslated_ids.txt', implode("\n", array_map(fn($o) => $o[0], $offenders)));
diff --git a/scripts/fix_carta_content_links.php b/scripts/fix_carta_content_links.php
new file mode 100644
index 0000000..895f148
--- /dev/null
+++ b/scripts/fix_carta_content_links.php
@@ -0,0 +1,65 @@
+/-.html
+ * que fix_carta_joomla_links.php NO mapea (solo trata /item/-...). Resuelve
+ * el número (id de contenido Joomla) por meta `_fgj2wp_old_content_id` y, en su
+ * defecto, `_fgj2wp_old_id` (contenido migrado en el bulk original) → permalink
+ * WP en el idioma de cada carta (degrada a ES si no hay traducción).
+ *
+ * Deja intactos los enlaces absolutos a feadulta.com (navegación externa) y los
+ * índices de sección (tablon-de-anuncios.html, noticias-de-alcance.html, etc.).
+ *
+ * Uso: CARTA= php fix_carta_content_links.php (dry-run)
+ * APPLY=1 CARTA= php fix_carta_content_links.php
+ */
+require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
+global $wpdb;
+$APPLY = getenv('APPLY') === '1';
+$CARTA = (int)(getenv('CARTA') ?: 0);
+if (!$CARTA) { fwrite(STDERR, "Falta CARTA=\n"); exit(1); }
+$BAK = "/tmp/fix_carta_content_bak"; if ($APPLY) @mkdir($BAK, 0777, true);
+
+function content_es_post($jid) {
+ global $wpdb;
+ foreach (['_fgj2wp_old_content_id', '_fgj2wp_old_id'] as $mk) {
+ $pid = $wpdb->get_var($wpdb->prepare(
+ "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key=%s AND meta_value=%s LIMIT 1", $mk, (string)$jid));
+ if ($pid) return (int)$pid;
+ }
+ return 0;
+}
+
+$tot = 0;
+foreach (pll_get_post_translations($CARTA) as $lang => $pid) {
+ $post = get_post($pid); if (!$post) continue;
+ $chg = 0; $miss = [];
+ $new = preg_replace_callback('~href="([^"]+)"~i', function($m) use ($lang, &$chg, &$miss) {
+ $href = html_entity_decode(trim($m[1]));
+ if (stripos($href, '.html') === false) return $m[0]; // solo legacy .html
+ if (stripos($href, 'feadulta.com') !== false) return $m[0]; // absoluto externo → dejar
+ if (stripos($href, '/item/') !== false) return $m[0]; // K2 lo trata otro script
+ if (!preg_match('~/(\d+)-[^/"]+\.html$~i', $href, $mm)) return $m[0]; // necesita -slug.html
+ $es = content_es_post((int)$mm[1]);
+ if (!$es) { $miss[] = $href; return $m[0]; }
+ $t = function_exists('pll_get_post') ? (pll_get_post($es, $lang) ?: $es) : $es;
+ $url = get_permalink($t);
+ if (!$url || strpos($url, '?p=') !== false) return $m[0];
+ $chg++;
+ return 'href="' . esc_url($url) . '"';
+ }, $post->post_content);
+ printf("#%d [%s] «%s» — %d enlaces de contenido%s\n", $pid, $lang, mb_substr($post->post_title,0,26), $chg,
+ $miss ? (" | sin mapear: " . implode(", ", array_slice($miss,0,3))) : "");
+ $tot += $chg;
+ if ($APPLY && $chg) {
+ file_put_contents("$BAK/$pid.html", $post->post_content);
+ wp_update_post(['ID'=>$pid, 'post_content'=>$new]);
+ clean_post_cache($pid);
+ }
+}
+if ($APPLY) {
+ $wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_fea_carta_sections_%'");
+ $wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_fea_carta_sections_%'");
+}
+echo ($APPLY ? "APLICADO" : "DRY-RUN") . ": $tot enlaces.\n";
diff --git a/scripts/fix_carta_joomla_links.php b/scripts/fix_carta_joomla_links.php
index fd14f28..37e1098 100644
--- a/scripts/fix_carta_joomla_links.php
+++ b/scripts/fix_carta_joomla_links.php
@@ -1,5 +1,5 @@
/ (rompen en prod).
+ * - Traducciones con enlaces relativos // (rompen en local, falta /fea).
+ * - Enlaces que apuntan a un artículo en otro idioma (los re-apunta a la
+ * traducción del MISMO idioma de la carta si existe).
+ *
+ * Para CADA : si el slug resuelve a un post del sitio, se reescribe al
+ * permalink absoluto del post en el idioma de la página (fallback: el que haya).
+ * Si el slug NO resuelve a ningún post (legacy .html, externos), se deja intacto.
+ *
+ * Uso (dentro del contenedor, con WP cargado):
+ * php fix_carta_links.php -> DRY-RUN (no escribe nada)
+ * APPLY=1 php fix_carta_links.php -> aplica y guarda backup en /tmp/fix_links_bak/
+ */
+if (!defined('ABSPATH')) {
+ require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
+}
+global $wpdb;
+
+$APPLY = getenv("APPLY") === "1";
+$BAKDIR = "/tmp/fix_links_bak";
+if ($APPLY) @mkdir($BAKDIR, 0777, true);
+
+// Conjunto de trabajo: posts ES con localhost:8081 + todas sus traducciones.
+$es_ids = $wpdb->get_col(
+ "SELECT ID FROM wp_posts WHERE post_type='post'
+ AND post_status IN ('publish','draft')
+ AND post_content LIKE '%localhost:8081%'"
+);
+$targets = [];
+foreach ($es_ids as $id) {
+ $targets[$id] = true;
+ foreach (pll_get_post_translations($id) as $tid) $targets[$tid] = true;
+}
+$targets = array_keys($targets);
+
+/** Extrae el slug candidato de un href, o null si no parece interno. */
+function slug_from_href($href) {
+ $href = html_entity_decode(trim($href));
+ if ($href === '' || $href[0] === '#') return null;
+ if (preg_match('~^(mailto:|tel:|javascript:)~i', $href)) return null;
+ if (stripos($href, '.html') !== false) return null; // legacy Joomla
+ if (stripos($href, 'feadulta.com') !== false) return null; // dominio viejo
+ if (strpos($href, '%') !== false) return null; // placeholders [unsubscribe]
+ // Quitar querystring / fragment
+ $href = preg_replace('~[?#].*$~', '', $href);
+ // Quitar esquema+host si los hay
+ $path = preg_replace('~^https?://[^/]+~i', '', $href);
+ if ($path === '') return null;
+ if ($path[0] !== '/') return null; // relativo raro -> no tocar
+ if (stripos($path, '/category/') !== false) return null; // categorías, no posts
+ if (stripos($path, '/wp-') === 0) return null;
+ // Quitar /fea y prefijo de idioma
+ $path = preg_replace('~^/fea~', '', $path);
+ $path = preg_replace('~^/(en|fr|it|pt|es)(/|$)~', '/', $path);
+ $segs = array_values(array_filter(explode('/', $path), 'strlen'));
+ if (count($segs) !== 1) return null; // solo // de un nivel
+ return $segs[0];
+}
+
+$total_posts = 0; $total_links = 0; $samples = 0;
+foreach ($targets as $pid) {
+ $post = get_post($pid);
+ if (!$post) continue;
+ $lang = pll_get_post_language($pid) ?: 'es';
+ $content = $post->post_content;
+ $changes = 0;
+
+ $new = preg_replace_callback('~href="([^"]*)"~i', function($m) use ($lang, &$changes, $wpdb) {
+ $href = $m[1];
+ $slug = slug_from_href($href);
+ if ($slug === null) return $m[0];
+ $found = $wpdb->get_var($wpdb->prepare(
+ "SELECT ID FROM wp_posts WHERE post_name=%s AND post_type='post'
+ AND post_status='publish' LIMIT 1", $slug));
+ if (!$found) return $m[0]; // no es un post -> intacto
+ // Resolver a la traducción del idioma de la página
+ $target = pll_get_post((int)$found, $lang);
+ if (!$target) $target = (int)$found;
+ $url = get_permalink($target);
+ if (!$url || $url === $href) return $m[0];
+ $changes++;
+ return 'href="' . esc_url($url) . '"';
+ }, $content);
+
+ if ($changes > 0) {
+ $total_posts++; $total_links += $changes;
+ echo sprintf("#%d [%s] «%s» — %d enlace(s) reescrito(s)\n",
+ $pid, $lang, mb_substr($post->post_title, 0, 40), $changes);
+ if ($APPLY) {
+ file_put_contents("$BAKDIR/$pid.html", $content);
+ $wpdb->update($wpdb->posts, ['post_content' => $new], ['ID' => $pid]);
+ clean_post_cache($pid);
+ }
+ }
+}
+
+echo "\n";
+echo ($APPLY ? "APLICADO" : "DRY-RUN") . ": $total_links enlaces en $total_posts posts.\n";
+if (!$APPLY) echo "Para aplicar: APPLY=1 php fix_carta_links.php (backup en $BAKDIR)\n";
diff --git a/scripts/fix_k2_authors.php b/scripts/fix_k2_authors.php
index ade6f55..f703429 100644
--- a/scripts/fix_k2_authors.php
+++ b/scripts/fix_k2_authors.php
@@ -17,7 +17,7 @@
* FROM ew4r_k2_items i LEFT JOIN ew4r_users u ON u.id=i.created_by \
* WHERE i.id IN ($IDS);" > /tmp/autores143.tsv
*
- * Uso (en el servidor, dentro de /web/wp-nuevo):
+ * Uso (en el servidor, dentro de /web):
* FEA_TSV=/tmp/autores143.tsv wp eval-file scripts/fix_k2_authors.php # dry-run
* APPLY=1 FEA_TSV=/tmp/autores143.tsv wp eval-file scripts/fix_k2_authors.php # aplica
*
diff --git a/scripts/gen_avatars_81b.py b/scripts/gen_avatars_81b.py
new file mode 100644
index 0000000..08e3bbe
--- /dev/null
+++ b/scripts/gen_avatars_81b.py
@@ -0,0 +1,33 @@
+#!/usr/bin/env python3
+"""Issue #90 — círculo 200px RGBA (esquinas transparentes) para 2 autores nuevos.
+Recorte cuadrado centrado en la cara + máscara circular supersampleada.
+"""
+from PIL import Image, ImageDraw
+
+SRC = "/home/rafa/Feadulta"
+OUT = "/home/rafa/joomla-migration/wordpress/wp-content/uploads/avatares/autores"
+SIZE = 200
+SS = 4 # supersampling para borde suave
+
+# foto, uid, (left, top, side) recorte cuadrado en coords del original
+JOBS = [
+ ("MP_Lopez.jpeg", 474, (120, 0, 880)),
+ ("A_delaCruz.jpeg", 993, (128, 0, 785)),
+]
+
+mask = Image.new("L", (SIZE * SS, SIZE * SS), 0)
+ImageDraw.Draw(mask).ellipse((0, 0, SIZE * SS - 1, SIZE * SS - 1), fill=255)
+mask = mask.resize((SIZE, SIZE), Image.LANCZOS)
+
+for fname, uid, (l, t, side) in JOBS:
+ im = Image.open(f"{SRC}/{fname}").convert("RGB")
+ W, H = im.size
+ # clamp dentro de la imagen
+ l = max(0, min(l, W - side))
+ t = max(0, min(t, H - side))
+ crop = im.crop((l, t, l + side, t + side)).resize((SIZE, SIZE), Image.LANCZOS)
+ out = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
+ out.paste(crop, (0, 0))
+ out.putalpha(mask)
+ out.save(f"{OUT}/autor-{uid}.png")
+ print(f"OK autor-{uid}.png <- {fname} crop=({l},{t},{side})")
diff --git a/scripts/import_avatars_143.php b/scripts/import_avatars_143.php
index cedda96..b53467e 100644
--- a/scripts/import_avatars_143.php
+++ b/scripts/import_avatars_143.php
@@ -8,7 +8,7 @@
* user_meta _foto_perfil_pre143. Idempotente (si ya apunta, solo regenera metadata).
*
* Entrada: TSV «uiddisplay_name» (env FEA_TSV, por defecto /tmp/users29.tsv).
- * Uso (en el servidor, dentro de /web/wp-nuevo):
+ * Uso (en el servidor, dentro de /web):
* FEA_TSV=/tmp/users29.tsv wp eval-file scripts/import_avatars_143.php # dry-run
* APPLY=1 FEA_TSV=/tmp/users29.tsv wp eval-file scripts/import_avatars_143.php # aplica
*/
diff --git a/scripts/import_avatars_75.php b/scripts/import_avatars_75.php
new file mode 100644
index 0000000..33b5182
--- /dev/null
+++ b/scripts/import_avatars_75.php
@@ -0,0 +1,59 @@
+.png.
+ * Si foto_perfil ya apuntaba a ese fichero (caso #62), solo regenera metadata.
+ * Backup del foto_perfil anterior en user_meta _foto_perfil_pre75 (revertible).
+ *
+ * Uso (dentro del contenedor):
+ * php import_avatars_75.php -> DRY-RUN
+ * APPLY=1 php import_avatars_75.php -> aplica
+ */
+require "/var/www/html/wp-load.php";
+require_once ABSPATH . 'wp-admin/includes/image.php';
+
+$apply = getenv('APPLY') === '1';
+$updir = wp_get_upload_dir();
+
+$authors = [
+ [384, "Enrique Martínez Lozano"],
+ [583, "Fidel Aizpurúa"],
+ [1138, "Guadalupe Labrador"],
+ [383, "José Antonio Pagola"],
+ [774, "José Luis Sicre"],
+ [775, "Miguel A. Munárriz"],
+];
+
+$done = $regen = $err = 0;
+foreach ($authors as [$uid, $name]) {
+ $rel = "avatares/autores/autor-{$uid}.png";
+ $abs = $updir['basedir'] . '/' . $rel;
+ if (!file_exists($abs)) { echo "MISSING uid=$uid ($name)\n"; $err++; continue; }
+
+ $cur = (int) get_user_meta($uid, 'foto_perfil', true);
+
+ // foto_perfil ya apunta a este fichero -> solo se sobrescribió el PNG
+ if ($cur && get_post_meta($cur, '_wp_attached_file', true) === $rel) {
+ echo "REGEN #$uid $name (attachment $cur ya apunta al PNG)\n";
+ if ($apply) wp_update_attachment_metadata($cur, wp_generate_attachment_metadata($cur, $abs));
+ $regen++; continue;
+ }
+
+ echo "NUEVO #$uid $name (foto_perfil actual: " . ($cur ?: 'ninguna') . ")\n";
+ if (!$apply) { $done++; continue; }
+
+ if (get_user_meta($uid, '_foto_perfil_pre75', true) === '') {
+ update_user_meta($uid, '_foto_perfil_pre75', $cur);
+ }
+ $aid = wp_insert_attachment([
+ 'post_mime_type' => 'image/png',
+ 'post_title' => "Avatar {$name}",
+ 'post_status' => 'inherit',
+ 'guid' => $updir['baseurl'] . '/' . $rel,
+ ], $abs, 0, true);
+ if (is_wp_error($aid)) { echo " ERR: " . $aid->get_error_message() . "\n"; $err++; continue; }
+ wp_update_attachment_metadata($aid, wp_generate_attachment_metadata($aid, $abs));
+ update_user_meta($uid, 'foto_perfil', $aid);
+ $done++;
+}
+echo "\n" . ($apply ? "APLICADO" : "DRY-RUN") . ": nuevos=$done regen=$regen errores=$err\n";
diff --git a/scripts/import_avatars_90.php b/scripts/import_avatars_90.php
new file mode 100644
index 0000000..ac9c1ce
--- /dev/null
+++ b/scripts/import_avatars_90.php
@@ -0,0 +1,55 @@
+.png.
+ * Si foto_perfil ya apuntaba a ese fichero (caso #62), solo regenera metadata.
+ * Backup del foto_perfil anterior en user_meta _foto_perfil_pre81 (revertible).
+ *
+ * Uso (dentro del contenedor):
+ * php import_avatars_75.php -> DRY-RUN
+ * APPLY=1 php import_avatars_75.php -> aplica
+ */
+require "/var/www/html/wp-load.php";
+require_once ABSPATH . 'wp-admin/includes/image.php';
+
+$apply = getenv('APPLY') === '1';
+$updir = wp_get_upload_dir();
+
+$authors = [
+ [474, "Mari Paz López Santos"],
+ [993, "África de la Cruz Tomé"],
+];
+
+$done = $regen = $err = 0;
+foreach ($authors as [$uid, $name]) {
+ $rel = "avatares/autores/autor-{$uid}.png";
+ $abs = $updir['basedir'] . '/' . $rel;
+ if (!file_exists($abs)) { echo "MISSING uid=$uid ($name)\n"; $err++; continue; }
+
+ $cur = (int) get_user_meta($uid, 'foto_perfil', true);
+
+ // foto_perfil ya apunta a este fichero -> solo se sobrescribió el PNG
+ if ($cur && get_post_meta($cur, '_wp_attached_file', true) === $rel) {
+ echo "REGEN #$uid $name (attachment $cur ya apunta al PNG)\n";
+ if ($apply) wp_update_attachment_metadata($cur, wp_generate_attachment_metadata($cur, $abs));
+ $regen++; continue;
+ }
+
+ echo "NUEVO #$uid $name (foto_perfil actual: " . ($cur ?: 'ninguna') . ")\n";
+ if (!$apply) { $done++; continue; }
+
+ if (get_user_meta($uid, '_foto_perfil_pre81', true) === '') {
+ update_user_meta($uid, '_foto_perfil_pre81', $cur);
+ }
+ $aid = wp_insert_attachment([
+ 'post_mime_type' => 'image/png',
+ 'post_title' => "Avatar {$name}",
+ 'post_status' => 'inherit',
+ 'guid' => $updir['baseurl'] . '/' . $rel,
+ ], $abs, 0, true);
+ if (is_wp_error($aid)) { echo " ERR: " . $aid->get_error_message() . "\n"; $err++; continue; }
+ wp_update_attachment_metadata($aid, wp_generate_attachment_metadata($aid, $abs));
+ update_user_meta($uid, 'foto_perfil', $aid);
+ $done++;
+}
+echo "\n" . ($apply ? "APLICADO" : "DRY-RUN") . ": nuevos=$done regen=$regen errores=$err\n";
diff --git a/scripts/import_new_cartas.py b/scripts/import_new_cartas.py
index 96167bb..78bbd7e 100644
--- a/scripts/import_new_cartas.py
+++ b/scripts/import_new_cartas.py
@@ -20,7 +20,7 @@ from datetime import datetime
JOOMLA_SSH_HOST = "134.0.10.170"
JOOMLA_SSH_USER = "feadulta"
-JOOMLA_SSH_PASS = "6Rm2qOF@eundwpda"
+JOOMLA_SSH_PASS = "C6c2A!mAl3Wj.BQF"
JOOMLA_DB_HOST = "127.0.0.1"
JOOMLA_DB_USER = "fejoomla3"
JOOMLA_DB_PASS = "5FF-}5^[>7^pK4W9"
@@ -31,7 +31,7 @@ WP_DB_USER = "wordpress_user"
WP_DB_PASS = "wordpress_pass"
WP_DB_NAME = "wordpress_db"
-LAST_CONTENT_ID = 9043 # último ew4r_content.id ya en WP
+LAST_CONTENT_ID = None # se calcula dinámicamente en main(): MAX(_fgj2wp_old_content_id) en WP
# WP term_ids y sus term_taxonomy_ids (se cargan dinámicamente)
CAT_FEADULTA = 71
@@ -50,7 +50,7 @@ DRY_RUN = '--dry-run' in sys.argv
# ── Helpers ────────────────────────────────────────────────────────────────────
def joomla_query(query: str) -> list[dict]:
- mysql_cmd = (f"mysql -h {JOOMLA_DB_HOST} -u {JOOMLA_DB_USER} "
+ mysql_cmd = (f"mysql --skip-ssl -h {JOOMLA_DB_HOST} -u {JOOMLA_DB_USER} "
f"-p'{JOOMLA_DB_PASS}' {JOOMLA_DB_NAME} "
f"--default-character-set=utf8mb4 -B")
cmd = ['sshpass', '-p', JOOMLA_SSH_PASS,
@@ -111,6 +111,11 @@ def unhex(val: str) -> str:
# ── Main ───────────────────────────────────────────────────────────────────────
def main():
+ global LAST_CONTENT_ID
+ # Detección dinámica del último ew4r_content (carta) ya importado
+ r = wp_mysql("SELECT MAX(CAST(meta_value AS UNSIGNED)) m FROM wp_postmeta "
+ "WHERE meta_key='_fgj2wp_old_content_id'")
+ LAST_CONTENT_ID = int(r[0]['m']) if r and r[0].get('m') and r[0]['m'] != 'NULL' else 9043
print(f"=== Import nuevas cartas (ew4r_content id > {LAST_CONTENT_ID}) "
f"{'[DRY RUN]' if DRY_RUN else '[LIVE]'} ===\n")
@@ -172,7 +177,10 @@ def main():
created_by = int(item.get('created_by', 0) or 0)
content = intro + ('\n\n' + full if full.strip() else '')
- wp_author = user_map.get(created_by, 1)
+ # La carta semanal SIEMPRE la firma Inma Calvo (WP user 1048 icalvotorre),
+ # aunque en Joomla la cree el webmaster (José Chicharro / josek 1049).
+ CARTA_AUTHOR = 1048
+ wp_author = CARTA_AUTHOR
wp_cats = CATID_TO_WP.get(catid, [CAT_CARTAS_OTRAS, CAT_FEADULTA])
fecha_carta = created[:10] # YYYY-MM-DD
@@ -237,18 +245,28 @@ def main():
print("\n=== Asignando _carta_id a artículos K2 ===")
- # Obtener los artículos K2 nuevos con su fecha (id 15)
+ # Obtener los artículos K2 con su fecha (id 15), acotando por la fecha más
+ # antigua de las cartas importadas en esta ejecución (evita recorrer todo).
+ min_fecha = min(fecha_a_wp_carta.keys())
k2_query = (
f"SELECT id, HEX(extra_fields) ef "
- f"FROM ew4r_k2_items WHERE published=1 AND id > 17873 ORDER BY id;"
+ f"FROM ew4r_k2_items WHERE published=1 AND created >= '{min_fecha} 00:00:00' "
+ f"ORDER BY id;"
)
k2_items = joomla_query(k2_query)
- print(f"Artículos K2 a procesar: {len(k2_items)}")
+ print(f"Artículos K2 a procesar (desde {min_fecha}): {len(k2_items)}")
assigned = 0
for k2item in k2_items:
k2_id = int(k2item['id'])
- wp_id = k2_id + 26040 # offset conocido
+ # wp_id REAL por meta (NO offset fijo, que pisaba metas en deltas sucesivos)
+ wp_rows = wp_mysql(
+ f"SELECT post_id FROM wp_postmeta WHERE meta_key='_fgj2wp_old_k2_id' "
+ f"AND meta_value='{k2_id}' LIMIT 1"
+ )
+ if not wp_rows:
+ continue
+ wp_id = int(wp_rows[0]['post_id'])
ef_raw = unhex(k2item.get('ef',''))
# Parsear fecha (id 15)
diff --git a/scripts/import_new_content.py b/scripts/import_new_content.py
index d0f309b..564001e 100644
--- a/scripts/import_new_content.py
+++ b/scripts/import_new_content.py
@@ -51,7 +51,7 @@ DRY_RUN = '--dry-run' in sys.argv
# ── Helpers ────────────────────────────────────────────────────────────────────
def joomla_query(query: str) -> list[dict]:
- mysql_cmd = (f"mysql -h {JOOMLA_DB_HOST} -u {JOOMLA_DB_USER} "
+ mysql_cmd = (f"mysql --skip-ssl -h {JOOMLA_DB_HOST} -u {JOOMLA_DB_USER} "
f"-p'{JOOMLA_DB_PASS}' {JOOMLA_DB_NAME} "
f"--default-character-set=utf8mb4 -B")
cmd = ['sshpass', '-p', JOOMLA_SSH_PASS,
@@ -184,7 +184,12 @@ def main():
continue
content = intro + ('\n\n' + full if full.strip() else '')
- wp_author = user_map.get(created_by, 1)
+ # Multimedia/pensamientos/vídeos/cantoral (catid 54/77/65) son contenido
+ # propio de FeAdulta → autor "Fe Adulta" (WP user 890), no el webmaster
+ # que los sube en Joomla. El resto conserva su autor real (noticias, etc.).
+ FEADULTA_AUTHOR = 890
+ FEADULTA_CATIDS = {54, 77, 65}
+ wp_author = FEADULTA_AUTHOR if catid in FEADULTA_CATIDS else user_map.get(created_by, 1)
wp_cats = CATID_TO_WP.get(catid, [1])
print(f" [{joomla_id}] catid={catid} | {title[:50]}")
diff --git a/scripts/import_new_k2_items.py b/scripts/import_new_k2_items.py
index 5e14871..79db559 100644
--- a/scripts/import_new_k2_items.py
+++ b/scripts/import_new_k2_items.py
@@ -25,7 +25,7 @@ from datetime import datetime
JOOMLA_SSH_HOST = "134.0.10.170"
JOOMLA_SSH_USER = "feadulta"
-JOOMLA_SSH_PASS = "6Rm2qOF@eundwpda"
+JOOMLA_SSH_PASS = "C6c2A!mAl3Wj.BQF"
JOOMLA_DB_HOST = "127.0.0.1"
JOOMLA_DB_USER = "fejoomla3"
JOOMLA_DB_PASS = "5FF-}5^[>7^pK4W9"
@@ -37,7 +37,7 @@ WP_DB_PASS = "wordpress_pass"
WP_DB_NAME = "wordpress_db"
WP_DB_HOST = "wordpress-mysql" # dentro del container
-LAST_K2_ID = 17873 # último ID importado en WP
+LAST_K2_ID = None # se calcula dinámicamente en main(): MAX(_fgj2wp_old_k2_id) en WP
# WP term_taxonomy_ids (obtenidos con SELECT tt.term_taxonomy_id FROM wp_term_taxonomy tt WHERE tt.term_id=N)
# Precalculados:
@@ -58,7 +58,7 @@ def ssh_mysql(query: str) -> list[dict]:
cmd = [
'sshpass', '-p', JOOMLA_SSH_PASS,
'ssh', f'{JOOMLA_SSH_USER}@{JOOMLA_SSH_HOST}',
- f'mysql -h {JOOMLA_DB_HOST} -u {JOOMLA_DB_USER} '
+ f'mysql --skip-ssl -h {JOOMLA_DB_HOST} -u {JOOMLA_DB_USER} '
f'-p{repr(JOOMLA_DB_PASS)} {JOOMLA_DB_NAME} '
f'--default-character-set=utf8mb4 -B -e "{query}"'
]
@@ -207,6 +207,11 @@ def determine_categories(ef: dict, title: str) -> list[int]:
# ── Import principal ───────────────────────────────────────────────────────────
def main():
+ global LAST_K2_ID
+ # Detección dinámica del último K2 importado (evita hardcodear y re-importar deltas previos)
+ r = wp_mysql("SELECT MAX(CAST(meta_value AS UNSIGNED)) m FROM wp_postmeta "
+ "WHERE meta_key='_fgj2wp_old_k2_id'")
+ LAST_K2_ID = int(r[0]['m']) if r and r[0].get('m') and r[0]['m'] != 'NULL' else 17873
print(f"=== Import K2 items > {LAST_K2_ID} → WP local {'[DRY RUN]' if DRY_RUN else '[LIVE]'} ===\n")
user_map = load_user_map()
@@ -238,7 +243,7 @@ def main():
f"WHERE published=1 AND id > {LAST_K2_ID} ORDER BY id;"
)
mysql_cmd = (
- f"mysql -h {JOOMLA_DB_HOST} -u {JOOMLA_DB_USER} "
+ f"mysql --skip-ssl -h {JOOMLA_DB_HOST} -u {JOOMLA_DB_USER} "
f"-p'{JOOMLA_DB_PASS}' {JOOMLA_DB_NAME} "
f"--default-character-set=utf8mb4 -B"
)
@@ -340,10 +345,12 @@ def main():
stats['ok'] += 1
continue
- # Obtener el ID del post recién insertado
- new_id_rows = wp_mysql("SELECT LAST_INSERT_ID() as new_id")
+ # Obtener el ID del post recién insertado. NO usar LAST_INSERT_ID(): cada
+ # docker exec abre una conexión nueva y devolvería 0. MAX(ID) es fiable
+ # en uso secuencial (sin inserciones concurrentes).
+ new_id_rows = wp_mysql("SELECT MAX(ID) as new_id FROM wp_posts")
if not new_id_rows:
- print(f" [ERROR] No se pudo obtener LAST_INSERT_ID para k2_id={k2_id}")
+ print(f" [ERROR] No se pudo obtener el ID del post para k2_id={k2_id}")
stats['err'] += 1
continue
new_wp_id = int(new_id_rows[0]['new_id'])
diff --git a/scripts/pretranslate_en_haiku.py b/scripts/pretranslate_en_haiku.py
new file mode 100644
index 0000000..8c1c0a7
--- /dev/null
+++ b/scripts/pretranslate_en_haiku.py
@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+"""Pre-traduce a EN con Haiku los posts del gap que Gemma AÚN no ha alcanzado.
+
+Crea el post EN + enlace Polylang (reutiliza fea_translate_helper.php, igual que
+Gemma) ANTES de que Gemma llegue. Cuando Gemma llega, ve la traducción EN ya
+enlazada en Polylang y la salta (translate_post.py:233), haciendo solo FR/IT/PT.
+Así el EN se hace UNA vez y bien, sin el reprocesado posterior.
+
+Coordinación: recorre los posts en el MISMO orden que Gemma, localiza por dónde
+va (último :en en el state) y arranca `--margin` posts por delante para no
+colisionar con el que Gemma está procesando ahora. Haiku (API) es mucho más
+rápido que Gemma local, así que se aleja y nunca la alcanza.
+
+Uso:
+ pretranslate_en_haiku.py # PLAN: muestra arranque y pendientes
+ pretranslate_en_haiku.py --apply # crea los EN
+ opciones: --margin N (def 2), --limit N
+"""
+import argparse
+import json
+import os
+import sys
+import time
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, HERE)
+
+import translate_post as tp # read_post, translation_exists, create_translation, carta_article_ids
+from translate_haiku import translate # Haiku
+
+# Mismo orden que translate_gap.sh
+CARTAS = "45018 44997 44975 44230 44229 44228 44090 44089 44088 44087 44086 44085 44084 44083 42590".split()
+
+
+def read_state():
+ """Lee el state de Gemma con reintentos (lo reescribe en vivo)."""
+ for _ in range(6):
+ try:
+ d = json.loads(open(tp.STATE_FILE).read())
+ if d.get("done"):
+ return d
+ except (json.JSONDecodeError, FileNotFoundError):
+ pass
+ time.sleep(0.5)
+ sys.exit("No pude leer el state de Gemma con contenido; aborto por seguridad.")
+
+
+def build_order():
+ """Lista global de post_ids en el orden exacto en que Gemma los procesa."""
+ g = []
+ for c in CARTAS:
+ g.append(int(c))
+ g.extend(tp.carta_article_ids(int(c)))
+ return g
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--apply", action="store_true")
+ ap.add_argument("--margin", type=int, default=2)
+ ap.add_argument("--limit", type=int, default=0)
+ args = ap.parse_args()
+
+ state = read_state()
+ done = state["done"]
+ order = build_order()
+
+ # Frente de Gemma = último índice con su :en ya hecho.
+ front = -1
+ for i, pid in enumerate(order):
+ if f"{pid}:en" in done:
+ front = i
+ if front < 0:
+ sys.exit("No encuentro el frente de Gemma en la lista; aborto.")
+
+ start = front + 1 + args.margin
+ work = order[start:]
+ if args.limit:
+ work = work[:args.limit]
+
+ cur = order[front]
+ print(f"Gemma va por #{cur} (índice {front}/{len(order)-1}).")
+ print(f"Margen {args.margin} → arranco en índice {start} (#{order[start] if start < len(order) else '—'}).")
+ print(f"Posts pendientes a pre-traducir: {len(work)}")
+ if work:
+ print(f" primeros: {work[:5]}")
+ print(f" últimos: {work[-5:]}")
+ if not args.apply:
+ print("\nMODO PLAN (no se crea nada). Añade --apply para ejecutar.")
+ return
+
+ tot_in = tot_out = 0.0
+ created = skipped = 0
+ for pid in work:
+ if tp.translation_exists(pid, "en"):
+ print(f"#{pid}: EN ya existe (Gemma se adelantó) — salto")
+ skipped += 1
+ continue
+ try:
+ src = tp.read_post(pid)
+ except Exception as e: # noqa: BLE001
+ print(f"#{pid}: no pude leer ({e}) — salto")
+ continue
+ if src.get("lang") and src["lang"] != "es":
+ continue
+ body, u1 = translate(src["content"], "en")
+ title, u2 = translate(src["title"], "en", is_title=True)
+ tot_in += u1.input_tokens + u2.input_tokens
+ tot_out += u1.output_tokens + u2.output_tokens
+ # Re-chequeo justo antes de crear (ventana de carrera con Gemma).
+ if tp.translation_exists(pid, "en"):
+ print(f"#{pid}: EN apareció mientras traducía — salto")
+ skipped += 1
+ continue
+ new_id = tp.create_translation(pid, "en", title, body, "draft")
+ created += 1
+ print(f"#{pid} → EN #{new_id} «{title[:45]}»")
+
+ cost = tot_in / 1e6 * 1.0 + tot_out / 1e6 * 5.0
+ print(f"\nCreados: {created} Saltados: {skipped}")
+ print(f"Tokens in={int(tot_in)} out={int(tot_out)} coste=${cost:.4f}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/prettify_carta_links.php b/scripts/prettify_carta_links.php
new file mode 100644
index 0000000..f35a2e2
--- /dev/null
+++ b/scripts/prettify_carta_links.php
@@ -0,0 +1,44 @@
+` de las cartas (grupo Polylang) a
+ * su permalink "bonito" por slug. Necesario cuando se arreglaron los enlaces de
+ * la carta con los artículos en DRAFT: get_permalink() devolvía `?p=`, que
+ * el parser de portada (fea_url_to_post_id, resuelve por slug) NO reconoce → los
+ * artículos no salían en sus secciones. Con los posts ya en publish, get_permalink
+ * da el slug. Solo datos, sin tocar mu-plugins. Dry-run por defecto.
+ *
+ * Uso: CARTA= php prettify_carta_links.php (dry-run)
+ * APPLY=1 CARTA= php prettify_carta_links.php
+ */
+require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
+$APPLY = getenv('APPLY') === '1';
+$CARTA = (int)(getenv('CARTA') ?: 0);
+if (!$CARTA) { fwrite(STDERR, "Falta CARTA=\n"); exit(1); }
+$BAK = "/tmp/prettify_carta_bak"; if ($APPLY) @mkdir($BAK, 0777, true);
+
+$tot = 0;
+foreach (pll_get_post_translations($CARTA) as $lang => $pid) {
+ $post = get_post($pid); if (!$post) continue;
+ $chg = 0;
+ $new = preg_replace_callback('~href="([^"]*[?&]p=(\d+)[^"]*)"~i', function($m) use (&$chg) {
+ $id = (int) $m[2];
+ $url = get_permalink($id);
+ if (!$url || strpos($url, '?p=') !== false) return $m[0]; // sigue feo → dejar
+ $chg++;
+ return 'href="' . esc_url($url) . '"';
+ }, $post->post_content);
+ echo sprintf("#%d [%s] «%s» — %d enlaces ?p= → slug\n", $pid, $lang, mb_substr($post->post_title,0,28), $chg);
+ $tot += $chg;
+ if ($APPLY && $chg) {
+ file_put_contents("$BAK/$pid.html", $post->post_content);
+ wp_update_post(['ID'=>$pid, 'post_content'=>$new]);
+ clean_post_cache($pid);
+ }
+}
+// Invalida los transients de secciones de la portada para que recoja los cambios.
+if ($APPLY) {
+ global $wpdb;
+ $wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_fea_carta_sections_%'");
+ $wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_fea_carta_sections_%'");
+}
+echo ($APPLY ? "APLICADO" : "DRY-RUN") . ": $tot enlaces.\n";
diff --git a/scripts/publish_carta.php b/scripts/publish_carta.php
index 0797747..9931fc7 100644
--- a/scripts/publish_carta.php
+++ b/scripts/publish_carta.php
@@ -1,6 +1,6 @@
'post','numberposts'=>-1,'post_status'=>'any',
diff --git a/scripts/remap_carta_tr_links.php b/scripts/remap_carta_tr_links.php
new file mode 100644
index 0000000..abdafa2
--- /dev/null
+++ b/scripts/remap_carta_tr_links.php
@@ -0,0 +1,40 @@
+$pid){
+ if($lang === 'es') continue; // la ES ya está bien
+ $post = get_post($pid); if(!$post) continue;
+ $chg=0; $miss=[];
+ $new = preg_replace_callback('~href="([^"]+)"~i', function($m) use($lang,&$chg,&$miss){
+ $href = html_entity_decode(trim($m[1]));
+ if(stripos($href,'farmer.taild3aaf6.ts.net')===false && stripos($href,'/fea/')===false) return $m[0];
+ $es = 0;
+ if(preg_match('~[?&]p=(\d+)~',$href,$mm)) $es=(int)$mm[1]; // forma ?p=ID
+ if(!$es) $es = (int)url_to_postid($href); // forma /slug/
+ if(!$es){ return $m[0]; }
+ if(pll_get_post_language($es) !== 'es'){ return $m[0]; } // solo si apunta a ES
+ $t = pll_get_post($es,$lang);
+ if(!$t || $t==$es){ $miss[]=$href; return $m[0]; } // sin traducción → dejar
+ $url = get_permalink($t);
+ if(!$url) return $m[0];
+ $chg++;
+ return 'href="'.esc_url($url).'"';
+ }, $post->post_content);
+ echo sprintf("#%d [%s] «%s» — %d remapeados%s\n",$pid,$lang,mb_substr($post->post_title,0,30),$chg,
+ $miss?(" | sin traducción: ".count($miss)):"");
+ $tot+=$chg;
+ if($APPLY && $chg){ file_put_contents("$BAK/$pid.html",$post->post_content);
+ $wpdb->update($wpdb->posts,['post_content'=>$new],['ID'=>$pid]); clean_post_cache($pid); }
+}
+echo ($APPLY?"APLICADO":"DRY-RUN").": $tot enlaces.\n";
diff --git a/scripts/remap_translation_cats.php b/scripts/remap_translation_cats.php
new file mode 100644
index 0000000..cc6f544
--- /dev/null
+++ b/scripts/remap_translation_cats.php
@@ -0,0 +1,42 @@
+get_col("SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE meta_key='traduccion_origen'");
+$fixed = 0;
+
+foreach ($ids as $pid) {
+ $pid = (int) $pid;
+ $lang = pll_get_post_language($pid);
+ if (!$lang || $lang === 'es') continue;
+
+ $cats = wp_get_post_categories($pid);
+ $mapped = [];
+ $changed = false;
+ foreach ($cats as $c) {
+ $tc = (int) pll_get_term($c, $lang);
+ if ($tc && $tc !== $c) { $mapped[] = $tc; $changed = true; }
+ else { $mapped[] = $c; }
+ }
+ if ($changed) {
+ wp_set_post_categories($pid, array_values(array_unique($mapped)));
+ $fixed++;
+ }
+}
+
+echo "Remapeadas categorías en $fixed traducciones (de " . count($ids) . " revisadas)\n";
diff --git a/scripts/repoint_carta_links.php b/scripts/repoint_carta_links.php
index af620bc..0b542ab 100644
--- a/scripts/repoint_carta_links.php
+++ b/scripts/repoint_carta_links.php
@@ -1,5 +1,5 @@
]*>", "", html))
+
+
+def find_broken(state, langs, limit):
+ """Devuelve [(src, lang), ...] de traducciones rotas."""
+ out = []
+ for key, tid in state["done"].items():
+ src, lang = key.split(":")
+ if lang not in langs:
+ continue
+ try:
+ es = get_post(int(src))
+ tr = get_post(int(tid))
+ except RuntimeError:
+ continue
+ olen = strip_len(es["content"])
+ if olen < 40:
+ continue
+ if strip_len(tr["content"]) / olen < RATIO_BROKEN:
+ out.append((int(src), lang))
+ if len(out) >= limit:
+ break
+ return out
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--ids", nargs="*", type=int, default=[])
+ ap.add_argument("--langs", default="en")
+ ap.add_argument("--auto", action="store_true")
+ ap.add_argument("--limit", type=int, default=100)
+ ap.add_argument("--apply", action="store_true")
+ args = ap.parse_args()
+
+ langs = [l.strip() for l in args.langs.split(",") if l.strip()]
+ state = json.load(open(STATE))
+
+ if args.auto:
+ print(f"Autodetectando rotos (ratio<{RATIO_BROKEN}) en {langs}…")
+ pairs = find_broken(state, langs, args.limit)
+ print(f"Encontrados: {pairs}")
+ else:
+ pairs = [(src, lang) for src in args.ids for lang in langs]
+
+ tot_in = tot_out = 0.0
+ for src, lang in pairs:
+ tid = state["done"].get(f"{src}:{lang}")
+ if not tid:
+ print(f"[{src}:{lang}] sin traducción en state; salto")
+ continue
+ es = get_post(src)
+ body, u1 = translate(es["content"], lang)
+ title, u2 = translate(es["title"], lang, is_title=True)
+ tot_in += u1.input_tokens + u2.input_tokens
+ tot_out += u1.output_tokens + u2.output_tokens
+
+ print(f"\n===== src #{src} [{lang}] -> #{tid} =====")
+ print(f"TÍTULO {lang}: {title}")
+ print(f"cuerpo ES={strip_len(es['content'])} -> {lang}={strip_len(body)}")
+
+ if args.apply:
+ open("/tmp/fea_title.txt", "w").write(title)
+ open("/tmp/fea_body.txt", "w").write(body)
+ dcp_to("/tmp/fea_title.txt", "/tmp/fea_title.txt")
+ dcp_to("/tmp/fea_body.txt", "/tmp/fea_body.txt")
+ r = dexec(["php", "/tmp/fea_post_io.php", "update", str(tid),
+ "/tmp/fea_title.txt", "/tmp/fea_body.txt"])
+ print(("APLICADO: " + r.stdout.strip()) if r.returncode == 0
+ else ("FALLO: " + r.stderr.strip()))
+ else:
+ print("(dry-run)")
+
+ cost = tot_in / 1e6 * 1.0 + tot_out / 1e6 * 5.0
+ print(f"\nTOTAL tokens: in={int(tot_in)} out={int(tot_out)} coste=${cost:.4f}")
+ print("MODO: " + ("APLICADO a BD" if args.apply else "DRY-RUN"))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/rotate_cartas.php b/scripts/rotate_cartas.php
new file mode 100644
index 0000000..7cbfe2a
--- /dev/null
+++ b/scripts/rotate_cartas.php
@@ -0,0 +1,83 @@
+ queda solo en "otras semanas"
+ * 2) la que estaba en "semana actual" -> pasa a "semana pasada"
+ * 3) la carta NUEVA -> pasa a "semana actual"
+ *
+ * Robusto/autocorrige: la NUEVA es el parámetro CARTA; la "semana pasada" se
+ * deriva como la carta publicada más reciente que NO es la nueva (por fecha),
+ * no por quién estuviera en el término (que puede estar roto). Garantiza
+ * count=1 en "actual" y count=1 en "pasada" por idioma. "Otras semanas" (21)
+ * es el cajón base que conservan TODAS las cartas.
+ *
+ * Términos ES base (se derivan por Polylang a cada idioma):
+ * actual = 6 (cartasemana) | pasada = 22 (carta-semana-pasada) | otras = 21
+ *
+ * Uso: CARTA= php rotate_cartas.php (dry-run)
+ * APPLY=1 CARTA= php rotate_cartas.php
+ */
+require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
+$APPLY = getenv('APPLY') === '1';
+$CARTA = (int)(getenv('CARTA') ?: 0);
+if (!$CARTA) { fwrite(STDERR, "Falta CARTA=\n"); exit(1); }
+
+$actual_terms = pll_get_term_translations(6);
+$pasada_terms = pll_get_term_translations(22);
+$otras_terms = pll_get_term_translations(21);
+$carta_tr = pll_get_post_translations($CARTA);
+
+function cartas_en($terms) { // posts publish en cualquiera de esos términos (mismo idioma), por fecha desc
+ return get_posts(['post_type'=>'post','post_status'=>'publish','numberposts'=>-1,'fields'=>'ids',
+ 'orderby'=>'date','order'=>'DESC','suppress_filters'=>true,
+ 'tax_query'=>[['taxonomy'=>'category','field'=>'term_id','terms'=>array_values(array_filter($terms))]]]);
+}
+
+foreach ($actual_terms as $lang => $t_actual) {
+ $t_pasada = (int)($pasada_terms[$lang] ?? 0);
+ $t_otras = (int)($otras_terms[$lang] ?? 0);
+ $t_actual = (int)$t_actual;
+ $new = (int)($carta_tr[$lang] ?? 0);
+
+ // Conjunto de cartas de ESTE idioma (los términos ya son por idioma) por fecha desc.
+ $all = cartas_en([$t_actual, $t_pasada, $t_otras]);
+ // "semana pasada" = la más reciente que no es la nueva.
+ $prev = 0; foreach ($all as $pid) { if ($pid != $new) { $prev = $pid; break; } }
+
+ // Posts actualmente marcados como actual/pasada (conjunto pequeño a limpiar).
+ $flagged = get_posts(['post_type'=>'post','post_status'=>'any','numberposts'=>-1,'fields'=>'ids',
+ 'suppress_filters'=>true,
+ 'tax_query'=>[['taxonomy'=>'category','field'=>'term_id','terms'=>array_values(array_filter([$t_actual,$t_pasada]))]]]);
+
+ if ($APPLY) {
+ // 1) limpiar: quitar actual+pasada de cualquiera salvo los dos destinos.
+ foreach ($flagged as $pid) {
+ if ($pid == $new || $pid == $prev) continue;
+ wp_remove_object_terms($pid, array_values(array_filter([$t_actual,$t_pasada])), 'category');
+ }
+ // 2) NUEVA -> semana actual (y fuera de pasada). Mantener otras.
+ if ($new) {
+ wp_set_object_terms($new, [$t_actual], 'category', true);
+ if ($t_pasada) wp_remove_object_terms($new, [$t_pasada], 'category');
+ if ($t_otras) wp_set_object_terms($new, [$t_otras], 'category', true);
+ }
+ // 3) ANTERIOR -> semana pasada (y fuera de actual). Mantener otras.
+ if ($prev && $t_pasada) {
+ wp_set_object_terms($prev, [$t_pasada], 'category', true);
+ wp_remove_object_terms($prev, [$t_actual], 'category');
+ if ($t_otras) wp_set_object_terms($prev, [$t_otras], 'category', true);
+ }
+ clean_term_cache(array_filter([$t_actual,$t_pasada,$t_otras]), 'category');
+ }
+
+ $cleaned = count(array_diff($flagged, [$new, $prev]));
+ $tn = $new ? get_post($new) : null;
+ $tp = $prev ? get_post($prev) : null;
+ printf("%s: actual=#%d «%s» | pasada=#%d «%s» | degradadas a 'otras' %d post(s)%s\n",
+ strtoupper($lang), $new, $tn?mb_substr($tn->post_title,0,26):'-',
+ $prev, $tp?mb_substr($tp->post_title,0,26):'-',
+ $cleaned, $APPLY?'':' [DRY-RUN]');
+}
+echo $APPLY ? "APLICADO\n" : "DRY-RUN (APPLY=1 para aplicar)\n";
diff --git a/scripts/sync_translations_to_prod.py b/scripts/sync_translations_to_prod.py
new file mode 100644
index 0000000..82b948b
--- /dev/null
+++ b/scripts/sync_translations_to_prod.py
@@ -0,0 +1,325 @@
+#!/usr/bin/env python3
+"""
+sync_translations_to_prod.py — Sincroniza contenido local a PROD reutilizando el
+texto ya verificado en local.
+
+Tiene dos modos:
+1. Legado: sincroniza traducciones automáticas (`traduccion_origen`) suponiendo que
+ el post ES origen ya existe en prod con el mismo ID.
+2. IDs preservados: clona posts locales a prod con ID explícito, copiando contenido,
+ slug, metas y categorías, y después reconstruye los grupos Polylang exactos.
+
+El modo 2 es el que usa el handoff de la carta 46956 para evitar romper la
+coincidencia local↔prod cuando prod va por detrás.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import subprocess
+import time
+from pathlib import Path
+
+# ── Config ───────────────────────────────────────────────────────────────────
+WP_CONTAINER = os.environ.get("FEA_WP_CONTAINER", "wordpress-web")
+DB_CONTAINER = os.environ.get("FEA_DB_CONTAINER", "wordpress-mysql")
+DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
+DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
+DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
+
+PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
+PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
+PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
+PROD_HELPER = "/tmp/fea_translate_helper.php"
+
+HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
+LOCAL_HELPER_DST = "/tmp/fea_translate_helper.php"
+STATE_FILE = Path(os.environ.get("FEA_SYNC_STATE", "/tmp/feadulta-sync-state.json"))
+LOG_FILE = Path(os.environ.get("FEA_SYNC_LOG", "/tmp/feadulta-sync.log"))
+STATUS = os.environ.get("FEA_SYNC_STATUS", "draft")
+
+# URLs absolutas del entorno local que NO deben llegar a prod (issue #91): el
+# post_content local arrastra el host de Tailscale con prefijo /fea; en prod la
+# instalación cuelga de la raíz. Se reescriben al desplegar para no dejar enlaces
+# rotos (Tailscale es inaccesible para los visitantes).
+LOCAL_BASE = os.environ.get("FEA_LOCAL_BASE", "https://farmer.taild3aaf6.ts.net/fea")
+PROD_BASE = os.environ.get("FEA_PROD_BASE", "https://www.feadulta.com")
+
+
+def log(msg: str) -> None:
+ line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
+ print(line, flush=True)
+ try:
+ LOG_FILE.open("a", encoding="utf-8").write(line + "\n")
+ except OSError:
+ pass
+
+
+def sh(cmd: list[str], *, stdin: str | None = None, timeout: int = 120) -> str:
+ r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
+ if r.returncode != 0:
+ raise RuntimeError(f"cmd falló ({r.returncode}): {' '.join(cmd[:3])}…\n{r.stderr.strip()[:400]}")
+ return r.stdout
+
+
+def parse_csv_ints(raw: str) -> list[int]:
+ out: list[int] = []
+ for part in raw.split(","):
+ part = part.strip()
+ if part.isdigit():
+ out.append(int(part))
+ return out
+
+
+def localize_urls(text: str | None) -> tuple[str, int]:
+ """Reescribe URLs absolutas local→prod en el contenido antes de subirlo.
+
+ Equivale al search-replace `farmer.taild3aaf6.ts.net/fea` → `www.feadulta.com`
+ pero aplicado en origen, así el contenido llega ya correcto a prod (issue #91).
+ Devuelve (texto, nº de reemplazos).
+ """
+ if not text or not LOCAL_BASE:
+ return text or "", 0
+ n = text.count(LOCAL_BASE)
+ return (text.replace(LOCAL_BASE, PROD_BASE), n) if n else (text, 0)
+
+
+# ── Local ────────────────────────────────────────────────────────────────────
+_local_ready = False
+
+
+def local_helper(subcmd: str, *args: str) -> str:
+ global _local_ready
+ if not _local_ready:
+ sh(["docker", "cp", str(HELPER_SRC), f"{WP_CONTAINER}:{LOCAL_HELPER_DST}"])
+ _local_ready = True
+ return sh(["docker", "exec", "-i", WP_CONTAINER, "php", LOCAL_HELPER_DST, subcmd, *args], timeout=180)
+
+
+def local_read(post_id: int) -> dict:
+ return json.loads(local_helper("read", str(post_id)))
+
+
+def local_read_full(post_id: int) -> dict:
+ return json.loads(local_helper("read_full", str(post_id)))
+
+
+def local_translation_pairs() -> list[tuple[int, int]]:
+ q = ("SELECT post_id, meta_value FROM wp_postmeta "
+ "WHERE meta_key='traduccion_origen' ORDER BY CAST(meta_value AS UNSIGNED), post_id;")
+ out = sh(["docker", "exec", DB_CONTAINER, "mysql", f"-u{DB_USER}", f"-p{DB_PASS}",
+ DB_NAME, "-N", "-e", q])
+ pairs = []
+ for line in out.splitlines():
+ parts = line.split("\t")
+ if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
+ pairs.append((int(parts[0]), int(parts[1])))
+ return pairs
+
+
+def carta_article_ids(carta_id: int) -> list[int]:
+ q = ("SELECT post_id FROM wp_postmeta "
+ f"WHERE meta_key='_carta_id' AND meta_value='{carta_id}' ORDER BY post_id;")
+ out = sh(["docker", "exec", DB_CONTAINER, "mysql", f"-u{DB_USER}", f"-p{DB_PASS}",
+ DB_NAME, "-N", "-e", q])
+ return [int(x) for x in out.split() if x.isdigit()]
+
+
+def collect_related_posts(seed_ids: list[int]) -> tuple[dict[int, dict], list[dict[str, int]]]:
+ posts: dict[int, dict] = {}
+ groups: dict[tuple[tuple[str, int], ...], dict[str, int]] = {}
+
+ for seed in seed_ids:
+ info = local_read_full(seed)
+ posts[seed] = info
+ raw_group = info.get("translations") or {}
+ group = {
+ lang: int(pid)
+ for lang, pid in raw_group.items()
+ if str(pid).isdigit()
+ }
+ if not group:
+ lang = info.get("lang") or "es"
+ group = {lang: seed}
+ sig = tuple(sorted(group.items()))
+ groups[sig] = group
+
+ all_ids = sorted({pid for group in groups.values() for pid in group.values()})
+ for pid in all_ids:
+ if pid not in posts:
+ posts[pid] = local_read_full(pid)
+
+ return posts, list(groups.values())
+
+
+# ── Prod ─────────────────────────────────────────────────────────────────────
+_prod_ready = False
+
+
+def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
+ cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
+ "-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
+ return sh(cmd, stdin=stdin, timeout=timeout)
+
+
+def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
+ global _prod_ready
+ if not _prod_ready:
+ _ssh(f"cat > {PROD_HELPER}", stdin=HELPER_SRC.read_text(encoding="utf-8"))
+ _prod_ready = True
+ inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
+ return _ssh(inner, stdin=stdin, timeout=180)
+
+
+def prod_create(origin: int, lang: str, title: str, content: str) -> int:
+ content, n = localize_urls(content)
+ if n:
+ log(f" localize origin={origin} [{lang}]: {n} URL(s) Tailscale→prod")
+ payload = json.dumps({"title": title, "content": content, "model": "google/gemma-4-e4b (sync)"})
+ out = prod_helper("create", str(origin), lang, STATUS, stdin=payload).strip()
+ return int(out)
+
+
+def prod_clone(post: dict) -> int:
+ content, n1 = localize_urls(post.get("content", ""))
+ excerpt, n2 = localize_urls(post.get("excerpt", ""))
+ if n1 or n2:
+ log(f" localize #{post['id']} [{post.get('lang','?')}]: {n1 + n2} URL(s) Tailscale→prod")
+ payload = {
+ "title": post["title"],
+ "content": content,
+ "excerpt": excerpt,
+ "slug": post.get("slug", ""),
+ "type": post.get("type", "post"),
+ "author": post.get("author", 1),
+ "date": post.get("date"),
+ "date_gmt": post.get("date_gmt"),
+ "status": post.get("status"),
+ "cats": post.get("cats", []),
+ "cat_slugs": post.get("cat_slugs", []),
+ "meta": post.get("meta", {}),
+ }
+ out = prod_helper("clone", str(post["id"]), post["lang"], STATUS, stdin=json.dumps(payload)).strip()
+ return int(out)
+
+
+def prod_save_group(group: dict[str, int]) -> dict[str, int]:
+ out = prod_helper("save_translations", stdin=json.dumps({"translations": group})).strip()
+ return json.loads(out)
+
+
+# ── Estado ───────────────────────────────────────────────────────────────────
+def load_state() -> dict:
+ if STATE_FILE.exists():
+ try:
+ return json.loads(STATE_FILE.read_text())
+ except json.JSONDecodeError:
+ pass
+ return {"done": {}, "errors": {}}
+
+
+def save_state(state: dict) -> None:
+ STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))
+
+
+# ── Modo IDs preservados ─────────────────────────────────────────────────────
+def deploy_fixed_ids(seed_ids: list[int], *, keep_existing: set[int], dry_run: bool) -> int:
+ posts, groups = collect_related_posts(seed_ids)
+ clone_ids = [pid for pid in posts if pid not in keep_existing]
+ clone_ids.sort(key=lambda pid: (0 if posts[pid].get("lang") == "es" else 1, pid))
+
+ log(f"Plan IDs preservados: seeds={seed_ids} clone={len(clone_ids)} grupos={len(groups)} status={STATUS}")
+ if keep_existing:
+ log(f"IDs marcados como ya existentes en prod: {sorted(keep_existing)}")
+
+ if dry_run:
+ for pid in clone_ids:
+ p = posts[pid]
+ log(f" CLONE #{pid} [{p.get('lang','?')}] slug={p.get('slug','')} cats={len(p.get('cat_slugs', []))}")
+ for group in groups:
+ log(f" GROUP {group}")
+ return 0
+
+ for pid in clone_ids:
+ p = posts[pid]
+ new_id = prod_clone(p)
+ log(f" clone #{pid} [{p.get('lang','?')}] → prod #{new_id} «{p['title'][:45]}»")
+
+ for group in groups:
+ saved = prod_save_group(group)
+ log(f" group enlazado {saved}")
+
+ log("FIN sync IDs preservados.")
+ return 0
+
+
+# ── Main legado ──────────────────────────────────────────────────────────────
+def legacy_sync(limit: int, origin: int) -> int:
+ state = load_state()
+ pairs = local_translation_pairs()
+ if origin:
+ pairs = [p for p in pairs if p[1] == origin]
+ log(f"Traducciones locales a sincronizar: {len(pairs)} (status={STATUS})")
+
+ n_ok = n_skip = n_err = 0
+ for tid, src_origin in pairs:
+ if limit and (n_ok + n_err) >= limit:
+ break
+ try:
+ t = local_read(tid)
+ except Exception as exc: # noqa: BLE001
+ log(f" local read #{tid} ERROR: {exc}")
+ n_err += 1
+ continue
+ lang = t.get("lang", "")
+ if lang in ("", "es"):
+ continue
+ key = f"{src_origin}:{lang}"
+ if key in state["done"]:
+ n_skip += 1
+ continue
+ try:
+ new_id = prod_create(src_origin, lang, t["title"], t["content"])
+ state["done"][key] = new_id
+ save_state(state)
+ n_ok += 1
+ log(f" {key} → prod #{new_id} «{t['title'][:45]}»")
+ except Exception as exc: # noqa: BLE001
+ state["errors"][key] = str(exc)[:300]
+ save_state(state)
+ n_err += 1
+ log(f" {key} ERROR: {exc}")
+
+ save_state(state)
+ log(f"FIN sync legado. nuevos={n_ok} saltados={n_skip} errores={n_err}. Estado: {STATE_FILE}")
+ log("Recuerda en prod: ejecutar remap_translation_cats.php si alguna quedó sin categoría traducida.")
+ return 0
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description="Sincroniza contenido local→prod reutilizando el texto local.")
+ ap.add_argument("--limit", type=int, default=0, help="Modo legado: máximo de traducciones a sincronizar.")
+ ap.add_argument("--origin", type=int, default=0, help="Modo legado: solo traducciones de este ES.")
+ ap.add_argument("--carta", type=int, default=0, help="Modo IDs preservados: carta ES y todo su cluster.")
+ ap.add_argument("--ids", default="", help="Modo IDs preservados: lista CSV de posts semilla a clonar/enlazar.")
+ ap.add_argument("--keep-existing", default="", help="IDs que ya existen en prod y no deben clonarse.")
+ ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no toca prod.")
+ args = ap.parse_args()
+
+ seed_ids: list[int] = []
+ if args.carta:
+ seed_ids = [args.carta, *carta_article_ids(args.carta)]
+ elif args.ids:
+ seed_ids = parse_csv_ints(args.ids)
+
+ if seed_ids:
+ keep_existing = set(parse_csv_ints(args.keep_existing))
+ return deploy_fixed_ids(seed_ids, keep_existing=keep_existing, dry_run=args.dry_run)
+
+ return legacy_sync(args.limit, args.origin)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/translate_gap.sh b/scripts/translate_gap.sh
new file mode 100755
index 0000000..bbd12c0
--- /dev/null
+++ b/scripts/translate_gap.sh
@@ -0,0 +1,76 @@
+#!/usr/bin/env bash
+# ============================================================================
+# translate_gap.sh — Traduce el "gap" marzo→ahora de feadulta a EN/FR/IT/PT.
+#
+# Hace TODO de una vez: comprobaciones previas, traduce las 15 cartas semanales
+# (marzo a junio) + sus artículos con Gemma local, remapea categorías y enseña
+# el recuento final. Idempotente y REANUDABLE: re-ejecutar es seguro (salta lo
+# ya traducido). NO publica nada (todo queda en borrador / draft).
+#
+# USO (un solo comando, en segundo plano):
+# nohup bash scripts/translate_gap.sh > /tmp/feadulta-gap.out 2>&1 &
+# Y para ver el progreso:
+# tail -f /tmp/feadulta-gap.log
+#
+# Ver issue rafa/feadulta#75.
+# ============================================================================
+set -u
+
+cd "$(dirname "$0")/.." || { echo "No puedo entrar en el repo"; exit 1; }
+
+LOG=/tmp/feadulta-gap.log
+LANGS="en,fr,it,pt"
+# Cartas del gap (de más reciente a más antigua). Override opcional: CARTAS="45018" bash ...
+CARTAS="${CARTAS:-45018 44997 44975 44230 44229 44228 44090 44089 44088 44087 44086 44085 44084 44083 42590}"
+
+ts() { date '+%Y-%m-%d %H:%M:%S'; }
+say() { echo "[$(ts)] $*" | tee -a "$LOG"; }
+
+say "================ INICIO batch del gap (draft) ================"
+
+# 1) LM Studio + Gemma cargado
+say "Preflight 1/2: LM Studio / Gemma..."
+if ! curl -s --max-time 10 http://172.19.128.1:1234/v1/models 2>/dev/null | grep -q 'gemma-4-e4b'; then
+ say "ERROR: LM Studio no responde o 'google/gemma-4-e4b' no está cargado."
+ say " -> En Windows: abre LM Studio, carga 'google/gemma-4-e4b', server en 0.0.0.0:1234."
+ exit 1
+fi
+say " OK: Gemma disponible."
+
+# 2) Contenedores docker arriba
+say "Preflight 2/2: contenedores docker..."
+for cnt in wordpress-web wordpress-mysql; do
+ if ! docker ps --format '{{.Names}}' | grep -qx "$cnt"; then
+ say "ERROR: el contenedor '$cnt' no está arriba. Arranca el stack (docker compose up -d) y reintenta."
+ exit 1
+ fi
+done
+say " OK: wordpress-web y wordpress-mysql arriba."
+
+# 3) Traducir cada carta + sus artículos
+N=$(echo $CARTAS | wc -w)
+i=0
+for c in $CARTAS; do
+ i=$((i+1))
+ say "=== Carta $c ($i/$N) -> $LANGS (draft) ==="
+ python3 scripts/translate_post.py --carta "$c" --langs "$LANGS" --status draft 2>&1 | tee -a "$LOG"
+done
+
+# 4) Remap de categorías (idempotente, sin Gemma): mete cada traducción en la
+# categoría de su idioma (arregla el archivo de carta por idioma).
+say "Remapeando categorías de todas las traducciones..."
+docker cp scripts/remap_translation_cats.php wordpress-web:/tmp/remap_translation_cats.php >/dev/null 2>&1
+docker exec wordpress-web php /tmp/remap_translation_cats.php 2>&1 | tee -a "$LOG"
+
+# 5) Recuento final por idioma
+say "Recuento final de traducciones por idioma (meta traduccion_origen):"
+docker exec wordpress-mysql mysql -uwordpress_user -pwordpress_pass wordpress_db -N -e "
+SELECT t.slug, COUNT(*) FROM wp_postmeta m
+JOIN wp_term_relationships tr ON m.post_id=tr.object_id
+JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id=tt.term_taxonomy_id AND tt.taxonomy='language'
+JOIN wp_terms t ON tt.term_id=t.term_id
+WHERE m.meta_key='traduccion_origen' GROUP BY t.slug;" 2>/dev/null | tee -a "$LOG"
+
+say "================ FIN batch del gap ================"
+say "Todo en DRAFT. No se ha publicado nada. Avisa a Rafa para revisar antes de publicar."
+say "Log completo: $LOG"
diff --git a/scripts/translate_haiku.py b/scripts/translate_haiku.py
new file mode 100644
index 0000000..ead8055
--- /dev/null
+++ b/scripts/translate_haiku.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+"""Traduce ES->EN con Claude Haiku 4.5 vía API directa. Prueba de coste/calidad.
+
+Lee la ANTHROPIC_API_KEY de portfolio-tracker/.env (la misma que usa el
+portfolio tracker para trade setups). Reporta tokens reales de la API.
+"""
+import os
+import re
+import sys
+
+# Cargar API key del .env de portfolio-tracker sin pisar el entorno existente.
+ENV_PATH = "/home/rafa/portfolio-tracker/.env"
+if "ANTHROPIC_API_KEY" not in os.environ:
+ for line in open(ENV_PATH):
+ line = line.strip()
+ if line.startswith("ANTHROPIC_API_KEY="):
+ os.environ["ANTHROPIC_API_KEY"] = line.split("=", 1)[1].strip().strip('"').strip("'")
+ break
+
+import anthropic
+
+MODEL = "claude-haiku-4-5"
+
+LANG_NAMES = {"en": "English", "fr": "French (français)",
+ "it": "Italian (italiano)", "pt": "Portuguese (português)"}
+
+
+def system_prompt(lang: str) -> str:
+ target = LANG_NAMES[lang]
+ return (
+ f"Eres un traductor profesional de textos religiosos cristianos "
+ f"(espiritualidad y teología católica). Traduce del español al {target}. "
+ f"REGLAS ESTRICTAS:\n"
+ f"1. Conserva EXACTAMENTE el marcado HTML (etiquetas y atributos) y los "
+ f"shortcodes entre [ ] y {{ }}. No los traduzcas ni los reordenes.\n"
+ f"2. NO traduzcas las referencias bíblicas ni sus abreviaturas "
+ f"(p.ej. 'Jn 3, 16', 'Mt 5'). Déjalas idénticas.\n"
+ f"3. Conserva los nombres propios de persona y lugar (salvo exónimos establecidos).\n"
+ f"4. Términos litúrgicos correctos (p.ej. 'Cuaresma' = Lent/Carême/Quaresima/Quaresma; "
+ f"NO inventes palabras).\n"
+ f"5. Traducción FIEL: no resumas, no añadas, no comentes.\n"
+ f"6. Devuelve SOLO la traducción entre las marcas <<>> y <<>>, sin nada más."
+ )
+
+
+def extract(text: str) -> str:
+ # Coge el bloque <<>>...<<>> de contenido MÁS LARGO (robusto al
+ # bug del runner local, donde el modelo a veces re-menciona las marcas).
+ blocks = re.findall(r"<<>>(.*?)<<>>", text, re.S)
+ out = max(blocks, key=len).strip() if blocks else text.strip()
+ out = re.sub(r"^```[a-z]*\n?", "", out)
+ out = re.sub(r"\n?```$", "", out)
+ return out.strip()
+
+
+def translate(text: str, lang: str, *, is_title: bool = False) -> tuple[str, object]:
+ client = anthropic.Anthropic()
+ kind = "el TÍTULO" if is_title else "el texto"
+ user = (
+ f"Traduce {kind} que va entre las marcas. "
+ f"Debe quedar en {LANG_NAMES[lang]} de forma natural.\n"
+ f"<<>>{text}<<>>"
+ )
+ max_tokens = max(1024, int(len(text) * 0.9))
+ resp = client.messages.create(
+ model=MODEL,
+ max_tokens=min(max_tokens, 16000),
+ system=system_prompt(lang),
+ messages=[{"role": "user", "content": user}],
+ )
+ body = "".join(b.text for b in resp.content if b.type == "text")
+ return extract(body), resp.usage
+
+
+if __name__ == "__main__":
+ path = sys.argv[1] if len(sys.argv) > 1 else "/tmp/orig_es.html"
+ lang = sys.argv[2] if len(sys.argv) > 2 else "en"
+ src = open(path).read()
+ out, usage = translate(src, lang)
+ open("/tmp/trad_haiku.html", "w").write(out)
+ cost = usage.input_tokens / 1e6 * 1.0 + usage.output_tokens / 1e6 * 5.0
+ print(f"MODEL={MODEL} lang={lang}")
+ print(f"input_tokens={usage.input_tokens} output_tokens={usage.output_tokens}")
+ print(f"coste_articulo=${cost:.5f}")
+ print(f"chars_in={len(src)} chars_out={len(out)}")
+ print("--- primeras 500 car ---")
+ print(out[:500])
diff --git a/scripts/translate_lectura_titles.php b/scripts/translate_lectura_titles.php
index 1316f24..b48a88a 100644
--- a/scripts/translate_lectura_titles.php
+++ b/scripts/translate_lectura_titles.php
@@ -16,7 +16,7 @@
* Uso (local): docker exec wordpress-web php /var/www/html/scripts/... (o vía cwd)
* php scripts/translate_lectura_titles.php # dry-run + reporte
* APPLY=1 php scripts/translate_lectura_titles.php # aplica
- * Prod: FEA_WP_LOAD=/web/wp-nuevo/wp-load.php php translate_lectura_titles.php
+ * Prod: FEA_WP_LOAD=/web/wp-load.php php translate_lectura_titles.php
*/
error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE);
diff --git a/scripts/translate_post.py b/scripts/translate_post.py
index a756a78..4fe17b6 100644
--- a/scripts/translate_post.py
+++ b/scripts/translate_post.py
@@ -22,6 +22,7 @@ Pensado para que Codex lo lance en lote sobre la cola priorizada (cartas/destaca
from __future__ import annotations
import argparse
+import fcntl
import json
import os
import re
@@ -47,6 +48,12 @@ if ENGINE == "haiku":
MODEL = "claude-haiku-4-5"
sys.path.insert(0, str(Path(__file__).resolve().parent))
import translate_haiku # carga la API key de portfolio-tracker/.env
+elif ENGINE == "minimax":
+ MODEL = os.environ.get("LOCAL_MODEL", "MiniMax-Text-01")
+ MINIMAX_URL = os.environ.get("MINIMAX_URL", "https://api.minimax.io/v1/text/chatcompletion_v2")
+ _kf = Path(os.environ.get("MINIMAX_KEY_FILE", "/home/rafa/Feadulta/minimax.txt"))
+ _keys = [l.strip() for l in _kf.read_text().splitlines() if l.strip().startswith("sk-")]
+ MINIMAX_KEY = _keys[-1] if _keys else ""
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
HELPER_DST = "/tmp/fea_translate_helper.php"
@@ -114,6 +121,24 @@ def gemma(messages: list[dict], *, max_tokens: int) -> str:
return data["choices"][0]["message"]["content"]
+def minimax(messages: list[dict], *, max_tokens: int) -> str:
+ import urllib.request
+
+ body = json.dumps({
+ "model": MODEL,
+ "messages": messages,
+ "temperature": 0.2,
+ "max_tokens": max_tokens,
+ }).encode("utf-8")
+ req = urllib.request.Request(
+ MINIMAX_URL, data=body,
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {MINIMAX_KEY}"},
+ )
+ with urllib.request.urlopen(req, timeout=300) as resp:
+ data = json.loads(resp.read().decode("utf-8"))
+ return data["choices"][0]["message"]["content"]
+
+
def _extract(text: str) -> str:
"""Extrae la traducción del ÚLTIMO bloque <<>>…<<>>.
@@ -175,7 +200,8 @@ def translate_text(text: str, lang: str, *, is_title: bool = False) -> str:
{"role": "user", "content": task},
]
max_tokens = max(800, int(len(text) * 1.6))
- raw = gemma(messages, max_tokens=max_tokens)
+ engine_fn = minimax if ENGINE == "minimax" else gemma
+ raw = engine_fn(messages, max_tokens=max_tokens)
return _extract(raw)
@@ -208,9 +234,19 @@ def translation_exists(es_id: int, lang: str) -> int:
return int(php_helper("exists", str(es_id), lang).strip() or "0")
+WP_LOCK_FILE = Path(os.environ.get("FEA_TR_LOCK", "/tmp/feadulta-translate.lock"))
+
+
def create_translation(es_id: int, lang: str, title: str, content: str, status: str) -> int:
payload = json.dumps({"title": title, "content": content, "model": MODEL})
- return int(php_helper("create", str(es_id), lang, status, stdin=payload).strip())
+ # Lock entre procesos: serializa SOLO la escritura/enlace Polylang (rápido), no la
+ # traducción LLM (lenta), para que 4 streams por idioma no pisen el grupo de traducciones.
+ with WP_LOCK_FILE.open("w") as lk:
+ fcntl.flock(lk, fcntl.LOCK_EX)
+ try:
+ return int(php_helper("create", str(es_id), lang, status, stdin=payload).strip())
+ finally:
+ fcntl.flock(lk, fcntl.LOCK_UN)
def carta_article_ids(carta_id: int) -> list[int]:
@@ -272,6 +308,7 @@ def main() -> int:
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--post-id", type=int, help="ID de un post ES a traducir.")
g.add_argument("--carta", type=int, help="ID de carta: traduce la carta y todos sus artículos (_carta_id).")
+ g.add_argument("--ids-file", help="Fichero con un ID de post ES por línea.")
ap.add_argument("--langs", default="en,fr,it,pt", help="Idiomas destino separados por coma.")
ap.add_argument("--status", default="draft", choices=["draft", "publish"], help="Estado de la traducción.")
ap.add_argument("--force", action="store_true", help="Regenera aunque ya exista la traducción.")
@@ -283,6 +320,9 @@ def main() -> int:
if args.post_id:
ids = [args.post_id]
+ elif args.ids_file:
+ ids = [int(x) for x in Path(args.ids_file).read_text().split() if x.strip().isdigit()]
+ log(f"ids-file {args.ids_file}: {len(ids)} posts")
else:
ids = [args.carta] + carta_article_ids(args.carta)
log(f"Carta {args.carta}: {len(ids)} posts (carta + {len(ids)-1} artículos)")
diff --git a/scripts/tts_carta.py b/scripts/tts_carta.py
new file mode 100644
index 0000000..bcbbf43
--- /dev/null
+++ b/scripts/tts_carta.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+"""Locuta una carta/artículo entero de feadulta con la voz clonada (XTTS-v2 + GPU).
+
+Saca el texto del post ES, lo trocea por párrafos, lo locuta con la voz de
+referencia (calculando los latents del hablante UNA sola vez), concatena con
+pausas y añade comfort noise. Issue #76.
+
+Uso:
+ tts_carta.py [nombre_salida]
+"""
+import html
+import json
+import os
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+os.environ.setdefault("COQUI_TOS_AGREED", "1")
+
+import numpy as np # noqa: E402
+import soundfile as sf # noqa: E402
+import torch # noqa: E402
+from TTS.api import TTS # noqa: E402
+
+DEVICE = "cuda" if torch.cuda.is_available() and not os.environ.get("FEA_CPU") else "cpu"
+OUT = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts-samples"
+SR = 24000
+CONTAINER = "wordpress-web"
+
+
+def get_post_text(pid):
+ subprocess.run(["docker", "exec", CONTAINER, "php", "/tmp/fea_post_io.php", "get", str(pid)],
+ check=True, capture_output=True)
+ subprocess.run(["docker", "cp", f"{CONTAINER}:/tmp/fea_es.json", "/tmp/fea_es.json"], check=True)
+ d = json.load(open("/tmp/fea_es.json"))
+ raw = d["content"]
+ # Conserva límites de párrafo antes de quitar tags.
+ raw = re.sub(r"(?i)
| |", "\n", raw)
+ raw = re.sub(r"<[^>]+>", "", raw) # quita tags
+ raw = re.sub(r"\[[^\]]+\]", "", raw) # quita shortcodes
+ raw = html.unescape(raw)
+ paras = [re.sub(r"\s+", " ", p).strip() for p in raw.split("\n")]
+ paras = [p for p in paras if len(p) > 1]
+ return d["title"], paras
+
+
+def main():
+ if len(sys.argv) < 3:
+ sys.exit("uso: tts_carta.py [nombre_salida]")
+ pid = int(sys.argv[1])
+ spk = sys.argv[2]
+ name = sys.argv[3] if len(sys.argv) > 3 else f"carta-{pid}"
+
+ title, paras = get_post_text(pid)
+ print(f"Post #{pid}: «{title}» ({len(paras)} párrafos, {sum(len(p) for p in paras)} car)")
+
+ print(f"Cargando XTTS-v2 en {DEVICE}…", flush=True)
+ tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(DEVICE)
+ model = tts.synthesizer.tts_model
+ print("Calculando timbre del hablante (1 vez)…", flush=True)
+ gpt_cond, spk_emb = model.get_conditioning_latents(audio_path=[spk])
+
+ pause = np.zeros(int(SR * 0.35), dtype=np.float32)
+ pieces = []
+ import time
+ t0 = time.time()
+ for i, para in enumerate(paras, 1):
+ out = model.inference(
+ para, "es", gpt_cond, spk_emb,
+ temperature=0.65, repetition_penalty=5.0, top_k=50, top_p=0.85,
+ enable_text_splitting=True,
+ )
+ pieces.append(np.asarray(out["wav"], dtype=np.float32))
+ pieces.append(pause)
+ print(f" párrafo {i}/{len(paras)} ({len(para)} car) ok", flush=True)
+ audio = np.concatenate(pieces)
+ dt = time.time() - t0
+ dur = len(audio) / SR
+ print(f"Síntesis: {dt:.1f}s para {dur:.1f}s de audio (x{dur/dt:.1f} tiempo real) en {DEVICE}")
+
+ raw = OUT / f"{name}.raw.wav"
+ sf.write(raw, audio, SR)
+ wav = OUT / f"{name}.wav"
+ subprocess.run([
+ "ffmpeg", "-y", "-i", str(raw), "-filter_complex",
+ "anoisesrc=color=brown:amplitude=0.004:sample_rate=24000[n];"
+ "[n]highpass=f=120,lowpass=f=3800[nf];"
+ "[0:a][nf]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[a]",
+ "-map", "[a]", "-ar", "24000", str(wav),
+ ], capture_output=True)
+ raw.unlink(missing_ok=True)
+ mp3 = OUT / f"{name}.mp3"
+ subprocess.run(["ffmpeg", "-y", "-i", str(wav), "-b:a", "96k", str(mp3)], capture_output=True)
+ print(f"OK -> {mp3} ({dur:.0f}s de audio)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/tts_carta_edge.py b/scripts/tts_carta_edge.py
new file mode 100644
index 0000000..9a2d1ed
--- /dev/null
+++ b/scripts/tts_carta_edge.py
@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+"""Locuta una carta entera con edge-tts (online, gratis). Para comparar con XTTS.
+
+Uso: tts_carta_edge.py [voz] [nombre_salida]
+voz por defecto: es-ES-XimenaNeural
+"""
+import html
+import json
+import os
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+EDGE = os.path.expanduser("~/.hermes/hermes-agent/venv/bin/edge-tts")
+OUT = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts-samples"
+CONTAINER = "wordpress-web"
+
+
+def get_post_text(pid):
+ subprocess.run(["docker", "exec", CONTAINER, "php", "/tmp/fea_post_io.php", "get", str(pid)],
+ check=True, capture_output=True)
+ subprocess.run(["docker", "cp", f"{CONTAINER}:/tmp/fea_es.json", "/tmp/fea_es.json"], check=True)
+ d = json.load(open("/tmp/fea_es.json"))
+ raw = d["content"]
+ raw = re.sub(r"(?i)| |", "\n", raw)
+ raw = re.sub(r"<[^>]+>", "", raw)
+ raw = re.sub(r"\[[^\]]+\]", "", raw)
+ raw = html.unescape(raw)
+ paras = [re.sub(r"\s+", " ", p).strip() for p in raw.split("\n")]
+ return d["title"], [p for p in paras if len(p) > 1]
+
+
+def main():
+ pid = int(sys.argv[1])
+ voice = sys.argv[2] if len(sys.argv) > 2 else "es-ES-XimenaNeural"
+ name = sys.argv[3] if len(sys.argv) > 3 else f"carta-edge-{pid}"
+ title, paras = get_post_text(pid)
+ text = "\n\n".join(paras)
+ txt_path = "/tmp/carta_text.txt"
+ open(txt_path, "w").write(text)
+ print(f"Post #{pid}: «{title}» ({len(text)} car) → {voice}")
+ OUT.mkdir(parents=True, exist_ok=True)
+ mp3 = OUT / f"{name}.mp3"
+ subprocess.run([EDGE, "--voice", voice, "--file", txt_path,
+ "--write-media", str(mp3)], check=True)
+ print(f"OK -> {mp3}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/tts_eval.py b/scripts/tts_eval.py
new file mode 100644
index 0000000..8feea01
--- /dev/null
+++ b/scripts/tts_eval.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+"""
+tts_eval.py — Genera la MISMA frase de feadulta con varias voces/modelos TTS para
+compararlas (evaluación de voz, issue #76). Incluye:
+ - edge-tts Ximena (referencia, gratis, ya la usamos) — siempre.
+ - Modelos premium vía Hugging Face Inference Providers (consume crédito HF) — opcional.
+
+Objetivo: ELEGIR voz. Para producción en masa NO se usa HF (sale caro); el modelo
+abierto ganador se corre en LOCAL (RTX 5060 Ti) gratis. Ver análisis en #76.
+
+Uso:
+ # Solo la referencia local (gratis):
+ python3 scripts/tts_eval.py
+ # Con modelos HF (necesita token; gasta unos céntimos del crédito):
+ HF_TOKEN=hf_xxx python3 scripts/tts_eval.py --hf
+
+Salida: ./tts-eval/.mp3 (escúchalos y elige).
+"""
+from __future__ import annotations
+import argparse, os, subprocess, sys
+from pathlib import Path
+
+SAMPLE = (
+ "Bienvenido a Fe Adulta. La humanidad abriga una esperanza: verse liberada de la "
+ "esclavitud y alcanzar la libertad de los hijos de Dios. Una fe adulta es una fe "
+ "personal, valiente, sin miedos infantiles. Detente un instante y respira."
+)
+OUT = Path(__file__).resolve().parent.parent / "tts-eval"
+EDGE = os.path.expanduser("~/.hermes/hermes-agent/venv/bin/edge-tts")
+
+# Candidatos vía HF Inference Providers (provider, model). Verifica disponibilidad en la
+# pestaña "Inference Providers" de cada modelo en huggingface.co — el routing cambia.
+HF_CANDIDATES = [
+ ("fal-ai", "fal-ai/f5-tts"),
+ ("fal-ai", "fal-ai/chatterbox/text-to-speech"),
+ ("hf-inference", "myshell-ai/MeloTTS-Spanish"),
+]
+
+
+def edge_samples():
+ OUT.mkdir(exist_ok=True)
+ for voz in ("es-ES-XimenaNeural", "es-ES-ElviraNeural", "es-MX-JorgeNeural"):
+ dst = OUT / f"edge-{voz}.mp3"
+ print(f"edge-tts {voz} ...", flush=True)
+ subprocess.run([EDGE, "--voice", voz, "--text", SAMPLE, "--write-media", str(dst)],
+ capture_output=True)
+ print(f" -> {OUT}")
+
+
+def hf_samples():
+ try:
+ from huggingface_hub import InferenceClient
+ except ImportError:
+ sys.exit("Falta huggingface_hub: pip install huggingface_hub")
+ token = os.environ.get("HF_TOKEN")
+ if not token:
+ sys.exit("Define HF_TOKEN para usar --hf")
+ OUT.mkdir(exist_ok=True)
+ for provider, model in HF_CANDIDATES:
+ name = model.replace("/", "_")
+ try:
+ client = InferenceClient(provider=provider, api_key=token)
+ audio = client.text_to_speech(SAMPLE, model=model)
+ dst = OUT / f"hf-{name}.mp3"
+ dst.write_bytes(audio)
+ print(f"OK {provider}:{model} -> {dst.name}")
+ except Exception as exc: # noqa: BLE001
+ print(f"FALLO {provider}:{model} -> {exc}")
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--hf", action="store_true", help="También generar con modelos HF (gasta crédito).")
+ args = ap.parse_args()
+ edge_samples()
+ if args.hf:
+ hf_samples()
+ print("\nEscucha los .mp3 en", OUT, "y elige. Para producción: correr el modelo abierto ganador en local.")
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/tts_kokoro.py b/scripts/tts_kokoro.py
new file mode 100644
index 0000000..9242f3b
--- /dev/null
+++ b/scripts/tts_kokoro.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+"""Genera la muestra de feadulta con Kokoro (TTS local, gratis). Issue #76.
+
+Voces español: ef_dora (fem), em_alex / em_santa (masc). lang_code 'e' = español.
+Salida en uploads/tts-samples/ como kokoro-.wav (+ mp3 si hay ffmpeg).
+"""
+import subprocess
+import sys
+from pathlib import Path
+
+import numpy as np
+import soundfile as sf
+from kokoro import KPipeline
+
+SAMPLE = (
+ "Bienvenido a Fe Adulta. La humanidad abriga una esperanza: verse liberada de la "
+ "esclavitud y alcanzar la libertad de los hijos de Dios. Una fe adulta es una fe "
+ "personal, valiente, sin miedos infantiles. Detente un instante y respira."
+)
+OUT = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts-samples"
+SR = 24000
+VOICES = sys.argv[1:] or ["ef_dora", "em_alex"]
+
+
+def main():
+ OUT.mkdir(parents=True, exist_ok=True)
+ pipe = KPipeline(lang_code="e") # español
+ for voice in VOICES:
+ chunks = [audio for _, _, audio in pipe(SAMPLE, voice=voice)]
+ audio = np.concatenate(chunks) if len(chunks) > 1 else chunks[0]
+ wav = OUT / f"kokoro-{voice}.wav"
+ sf.write(wav, audio, SR)
+ mp3 = OUT / f"kokoro-{voice}.mp3"
+ subprocess.run(["ffmpeg", "-y", "-i", str(wav), "-b:a", "96k", str(mp3)],
+ capture_output=True)
+ dur = len(audio) / SR
+ print(f"OK {voice}: {dur:.1f}s -> {mp3.name}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/tts_xtts.py b/scripts/tts_xtts.py
new file mode 100644
index 0000000..34ab09f
--- /dev/null
+++ b/scripts/tts_xtts.py
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+"""Clona una voz con XTTS-v2 (local) y locuta la muestra de feadulta. Issue #76.
+
+Uso:
+ tts_xtts.py [nombre_salida]
+La muestra: 6-20s de voz limpia en español. Salida en uploads/tts-samples/.
+
+NOTA: XTTS-v2 tiene licencia no comercial (CPML). En CPU tarda ~1-2 min por
+muestra; con GPU sería casi instantáneo.
+"""
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+os.environ.setdefault("COQUI_TOS_AGREED", "1") # acepta licencia CPML no-interactivo
+
+import torch # noqa: E402
+from TTS.api import TTS # noqa: E402
+
+DEVICE = "cuda" if torch.cuda.is_available() and not os.environ.get("FEA_CPU") else "cpu"
+
+SAMPLE = (
+ "Bienvenido a Fe Adulta. La humanidad abriga una esperanza: verse liberada de la "
+ "esclavitud y alcanzar la libertad de los hijos de Dios. Una fe adulta es una fe "
+ "personal, valiente, sin miedos infantiles. Detente un instante y respira."
+)
+OUT = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts-samples"
+
+
+def main():
+ if len(sys.argv) < 2:
+ sys.exit("uso: tts_xtts.py [nombre_salida]")
+ spk = sys.argv[1]
+ name = sys.argv[2] if len(sys.argv) > 2 else "xtts-clon"
+ OUT.mkdir(parents=True, exist_ok=True)
+
+ print(f"Cargando XTTS-v2 en {DEVICE}…", flush=True)
+ tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(DEVICE)
+ raw = OUT / f"{name}.raw.wav"
+ print(f"Clonando voz de {spk} y locutando…", flush=True)
+ tts.tts_to_file(
+ text=SAMPLE, speaker_wav=spk, language="es", file_path=str(raw),
+ temperature=0.65, # menos aleatoriedad → más estable
+ length_penalty=1.0,
+ repetition_penalty=5.0, # reduce artefactos/balbuceos en español
+ top_k=50,
+ top_p=0.85,
+ enable_text_splitting=True, # parte por frases → mejor prosodia
+ )
+ # Comfort noise: ruido marrón suave y constante que rellena los silencios de
+ # comas/puntos para que no contrasten con el suelo de ruido del habla clonada.
+ wav = OUT / f"{name}.wav"
+ if os.environ.get("FEA_NO_COMFORT"):
+ subprocess.run(["ffmpeg", "-y", "-i", str(raw), str(wav)], capture_output=True)
+ else:
+ subprocess.run([
+ "ffmpeg", "-y", "-i", str(raw), "-filter_complex",
+ "anoisesrc=color=brown:amplitude=0.004:sample_rate=24000[n];"
+ "[n]highpass=f=120,lowpass=f=3800[nf];"
+ "[0:a][nf]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[a]",
+ "-map", "[a]", "-ar", "24000", str(wav),
+ ], capture_output=True)
+ raw.unlink(missing_ok=True)
+ mp3 = OUT / f"{name}.mp3"
+ subprocess.run(["ffmpeg", "-y", "-i", str(wav), "-b:a", "96k", str(mp3)],
+ capture_output=True)
+ print(f"OK -> {mp3}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/unpublish_date_slug_posts.php b/scripts/unpublish_date_slug_posts.php
new file mode 100644
index 0000000..d13d5b7
--- /dev/null
+++ b/scripts/unpublish_date_slug_posts.php
@@ -0,0 +1,153 @@
+posts;
+ $backup_table = $wpdb->prefix . 'fea_date_slug_posts_backup';
+
+ $count = (int)$wpdb->get_var("SELECT COUNT(*) FROM $posts_table WHERE $where");
+ echo "Matching published posts: $count\n";
+
+ $sample = $wpdb->get_results("
+ SELECT ID, post_date, post_title, post_name
+ FROM $posts_table
+ WHERE $where
+ ORDER BY post_date DESC
+ LIMIT 20
+ ", ARRAY_A);
+
+ foreach ($sample as $row) {
+ echo sprintf(
+ " #%d %s %s (%s)\n",
+ $row['ID'],
+ $row['post_date'],
+ $row['post_title'],
+ $row['post_name']
+ );
+ }
+
+ if ($dry_run) {
+ echo "\nNo changes made. Re-run with APPLY=1 to set these posts to draft.\n";
+ return;
+ }
+
+ $wpdb->query("
+ CREATE TABLE IF NOT EXISTS $backup_table (
+ post_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
+ old_status VARCHAR(20) NOT NULL,
+ old_modified DATETIME NOT NULL,
+ backed_up_at DATETIME NOT NULL
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
+ ");
+
+ $backed_up = $wpdb->query("
+ INSERT IGNORE INTO $backup_table (post_id, old_status, old_modified, backed_up_at)
+ SELECT ID, post_status, post_modified, NOW()
+ FROM $posts_table
+ WHERE $where
+ ");
+
+ $updated = $wpdb->query("
+ UPDATE $posts_table
+ SET post_status = 'draft',
+ post_modified = NOW(),
+ post_modified_gmt = UTC_TIMESTAMP()
+ WHERE $where
+ ");
+
+ echo "\nBacked up rows in $backup_table: $backed_up\n";
+ echo "Posts set to draft: $updated\n";
+ return;
+}
+
+$db_host = getenv('WORDPRESS_DB_HOST') ?: 'wordpress-db';
+$db_name = getenv('WORDPRESS_DB_NAME') ?: 'wordpress_db';
+$db_user = getenv('WORDPRESS_DB_USER') ?: 'wordpress_user';
+$db_pass = getenv('WORDPRESS_DB_PASSWORD') ?: 'wordpress_pass';
+
+$pdo = new PDO("mysql:host=$db_host;dbname=$db_name;charset=utf8mb4", $db_user, $db_pass, [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+]);
+
+$count = (int)$pdo->query("SELECT COUNT(*) FROM wp_posts WHERE $where")->fetchColumn();
+echo "Matching published posts: $count\n";
+
+$sample = $pdo->query("
+ SELECT ID, post_date, post_title, post_name
+ FROM wp_posts
+ WHERE $where
+ ORDER BY post_date DESC
+ LIMIT 20
+")->fetchAll(PDO::FETCH_ASSOC);
+
+foreach ($sample as $row) {
+ echo sprintf(
+ " #%d %s %s (%s)\n",
+ $row['ID'],
+ $row['post_date'],
+ $row['post_title'],
+ $row['post_name']
+ );
+}
+
+if ($dry_run) {
+ echo "\nNo changes made. Re-run with --apply to set these posts to draft.\n";
+ exit(0);
+}
+
+$pdo->exec("
+ CREATE TABLE IF NOT EXISTS $backup_table (
+ post_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
+ old_status VARCHAR(20) NOT NULL,
+ old_modified DATETIME NOT NULL,
+ backed_up_at DATETIME NOT NULL
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
+");
+
+$pdo->beginTransaction();
+
+$backed_up = $pdo->exec("
+ INSERT IGNORE INTO $backup_table (post_id, old_status, old_modified, backed_up_at)
+ SELECT ID, post_status, post_modified, NOW()
+ FROM wp_posts
+ WHERE $where
+");
+
+$updated = $pdo->exec("
+ UPDATE wp_posts
+ SET post_status = 'draft',
+ post_modified = NOW(),
+ post_modified_gmt = UTC_TIMESTAMP()
+ WHERE $where
+");
+
+$pdo->commit();
+
+echo "\nBacked up rows in $backup_table: $backed_up\n";
+echo "Posts set to draft: $updated\n";
diff --git a/tools/e2e/shot_avatars.cjs b/tools/e2e/shot_avatars.cjs
new file mode 100644
index 0000000..7147353
--- /dev/null
+++ b/tools/e2e/shot_avatars.cjs
@@ -0,0 +1,19 @@
+const { chromium } = require('playwright');
+(async () => {
+ const b = await chromium.launch();
+ const p = await b.newPage({ viewport: { width: 1200, height: 2200 } });
+ // portada
+ await p.goto('http://localhost:8081/', { waitUntil: 'networkidle' });
+ // primer avatar (Fray Marcos suele estar entre los primeros). Capturamos rejilla.
+ const grid = await p.$('.fea-grid, .fea-section');
+ await p.screenshot({ path: '/tmp/shot_portada.png', fullPage: true });
+ // recorte de cada card avatar
+ const avs = await p.$$('.fea-card-avatar');
+ let i = 0;
+ for (const a of avs.slice(0, 12)) {
+ await a.screenshot({ path: `/tmp/av_${i}.png` }).catch(()=>{});
+ i++;
+ }
+ console.log('avatars capturados:', i);
+ await b.close();
+})();
diff --git a/tools/e2e/shot_colab.cjs b/tools/e2e/shot_colab.cjs
new file mode 100644
index 0000000..c4e9fb7
--- /dev/null
+++ b/tools/e2e/shot_colab.cjs
@@ -0,0 +1,11 @@
+const { chromium } = require('playwright');
+(async () => {
+ const b = await chromium.launch();
+ const p = await b.newPage({ viewport: { width: 1100, height: 1600 }, deviceScaleFactor: 2 });
+ await p.goto('http://localhost:8081/colaboradores/', { waitUntil: 'networkidle' });
+ const img = p.locator('img[src*="marcos_1"]').first();
+ await img.scrollIntoViewIfNeeded();
+ await img.screenshot({ path: '/tmp/colab_marcos.png' }).catch(e=>console.log('err',e.message));
+ console.log('ok');
+ await b.close();
+})();
diff --git a/tools/e2e/shot_eed.cjs b/tools/e2e/shot_eed.cjs
new file mode 100644
index 0000000..2c36521
--- /dev/null
+++ b/tools/e2e/shot_eed.cjs
@@ -0,0 +1,21 @@
+const { chromium } = require('playwright');
+(async () => {
+ const b = await chromium.launch({ args: ['--ignore-certificate-errors'] });
+ const p = await b.newPage({ viewport: { width: 1200, height: 1400 }, ignoreHTTPSErrors: true, deviceScaleFactor: 2 });
+ await p.goto('https://farmer.taild3aaf6.ts.net/fea/evangelio-de-cada-dia/', { waitUntil: 'networkidle' });
+ const m = await p.evaluate(() => ({
+ date: document.querySelector('.fea-eed-date')?.textContent,
+ tabs: [...document.querySelectorAll('.fea-eed-labels label')].map(l=>l.textContent),
+ nav: [...document.querySelectorAll('.fea-eed-nav')].map(a=>a.textContent.trim()),
+ textoVisible: getComputedStyle(document.querySelector('.fea-eed-panel-texto')).display,
+ videoVisible: getComputedStyle(document.querySelector('.fea-eed-panel-video')).display,
+ hasIframe: !!document.querySelector('.fea-eed-panel-video iframe'),
+ }));
+ console.log(JSON.stringify(m,null,2));
+ await p.screenshot({ path: '/tmp/eed_texto.png', fullPage: false });
+ // click video tab
+ await p.click('label[for=fed-video]');
+ await p.waitForTimeout(300);
+ await p.screenshot({ path: '/tmp/eed_video.png', fullPage: false });
+ await b.close();
+})();
diff --git a/tools/e2e/shot_en.cjs b/tools/e2e/shot_en.cjs
new file mode 100644
index 0000000..a461818
--- /dev/null
+++ b/tools/e2e/shot_en.cjs
@@ -0,0 +1,10 @@
+const { chromium } = require('playwright');
+(async () => {
+ const b = await chromium.launch({ args: ['--ignore-certificate-errors'] });
+ const p = await b.newPage({ viewport: { width: 1440, height: 1500 }, ignoreHTTPSErrors: true, deviceScaleFactor: 2 });
+ await p.goto('https://farmer.taild3aaf6.ts.net/fea/en/numeros-en/', { waitUntil: 'networkidle' });
+ await p.evaluate(() => document.querySelectorAll('details.fea-don').forEach(d => d.open = true));
+ await p.waitForTimeout(200);
+ await p.screenshot({ path: '/tmp/numeros_en.png', fullPage: false });
+ await b.close();
+})();
diff --git a/tools/e2e/shot_grid.cjs b/tools/e2e/shot_grid.cjs
new file mode 100644
index 0000000..7f28254
--- /dev/null
+++ b/tools/e2e/shot_grid.cjs
@@ -0,0 +1,12 @@
+const { chromium } = require('playwright');
+(async () => {
+ const b = await chromium.launch();
+ const p = await b.newPage({ viewport: { width: 1100, height: 1600 }, deviceScaleFactor: 2 });
+ await p.goto('http://localhost:8081/', { waitUntil: 'networkidle' });
+ // primera sección de tarjetas (artículos de la semana)
+ const sec = p.locator('.fea-grid').first();
+ await sec.scrollIntoViewIfNeeded();
+ await sec.screenshot({ path: '/tmp/portada_grid.png' });
+ console.log('ok');
+ await b.close();
+})();
diff --git a/tools/e2e/shot_lecturas.cjs b/tools/e2e/shot_lecturas.cjs
new file mode 100644
index 0000000..5cc60ad
--- /dev/null
+++ b/tools/e2e/shot_lecturas.cjs
@@ -0,0 +1,48 @@
+const { chromium } = require('playwright');
+(async () => {
+ const url = 'https://farmer.taild3aaf6.ts.net/fea/primeras-lecturas/';
+ const b = await chromium.launch({ args: ['--ignore-certificate-errors'] });
+ const p = await b.newPage({ viewport: { width: 1440, height: 1600 }, ignoreHTTPSErrors: true, deviceScaleFactor: 2 });
+ await p.goto(url, { waitUntil: 'networkidle' });
+
+ // medidas: ancho del contenedor, ancho del viewport, alineación del título
+ const m1 = await p.evaluate(() => {
+ const lect = document.querySelector('.fea-lect');
+ const title = document.querySelector('.wp-block-post-title');
+ const ec = document.querySelector('.entry-content');
+ return {
+ viewport: window.innerWidth,
+ fea_lect_width: lect ? Math.round(lect.getBoundingClientRect().width) : null,
+ entry_content_width: ec ? Math.round(ec.getBoundingClientRect().width) : null,
+ title_text: title ? title.textContent.trim() : null,
+ title_align: title ? getComputedStyle(title).textAlign : null,
+ title_centered_box: title ? (Math.abs((title.getBoundingClientRect().left) - (window.innerWidth - title.getBoundingClientRect().right)) < 8) : null,
+ };
+ });
+ console.log('COLAPSADO:', JSON.stringify(m1, null, 2));
+ await p.screenshot({ path: '/tmp/lecturas_collapsed.png', fullPage: false });
+
+ // abrir la 1ª sección (1ª lectura) y el primer libro
+ await p.evaluate(() => {
+ const tops = document.querySelectorAll('.fea-lect > details.fea-lect-top');
+ if (tops[0]) tops[0].open = true;
+ });
+ await p.waitForTimeout(150);
+ await p.evaluate(() => {
+ const first = document.querySelector('.fea-lect > details.fea-lect-top details');
+ if (first) first.open = true;
+ });
+ await p.waitForTimeout(150);
+
+ const m2 = await p.evaluate(() => {
+ const ul = document.querySelector('.fea-lect details details[open] ul');
+ return {
+ ul_columns: ul ? getComputedStyle(ul).columnWidth + ' / count ' + getComputedStyle(ul).columnCount : null,
+ ul_width: ul ? Math.round(ul.getBoundingClientRect().width) : null,
+ };
+ });
+ console.log('EXPANDIDO:', JSON.stringify(m2, null, 2));
+ await p.screenshot({ path: '/tmp/lecturas_expanded.png', fullPage: false });
+
+ await b.close();
+})();
diff --git a/tools/e2e/shot_led.cjs b/tools/e2e/shot_led.cjs
new file mode 100644
index 0000000..5ea950c
--- /dev/null
+++ b/tools/e2e/shot_led.cjs
@@ -0,0 +1,10 @@
+const { chromium } = require('playwright');
+(async () => {
+ const b = await chromium.launch({ args: ['--ignore-certificate-errors'] });
+ const p = await b.newPage({ viewport: { width: 1440, height: 1100 }, ignoreHTTPSErrors: true, deviceScaleFactor: 2 });
+ await p.goto('https://farmer.taild3aaf6.ts.net/fea/numeros/', { waitUntil: 'networkidle' });
+ await p.evaluate(() => document.querySelector('.fea-ledger-title').scrollIntoView());
+ await p.waitForTimeout(300);
+ await p.screenshot({ path: '/tmp/numeros_ledger.png', fullPage: false });
+ await b.close();
+})();
diff --git a/tools/e2e/shot_numeros.cjs b/tools/e2e/shot_numeros.cjs
new file mode 100644
index 0000000..73976e9
--- /dev/null
+++ b/tools/e2e/shot_numeros.cjs
@@ -0,0 +1,33 @@
+const { chromium } = require('playwright');
+(async () => {
+ const url = 'https://farmer.taild3aaf6.ts.net/fea/numeros/';
+ const b = await chromium.launch({ args: ['--ignore-certificate-errors'] });
+ const p = await b.newPage({ viewport: { width: 1440, height: 1700 }, ignoreHTTPSErrors: true, deviceScaleFactor: 2 });
+ await p.goto(url, { waitUntil: 'networkidle' });
+ await p.screenshot({ path: '/tmp/numeros_top.png', fullPage: false });
+
+ // abrir las 3 cards
+ await p.evaluate(() => { document.querySelectorAll('details.fea-don').forEach(d => d.open = true); });
+ await p.waitForTimeout(200);
+ await p.screenshot({ path: '/tmp/numeros_open.png', fullPage: false });
+
+ const m = await p.evaluate(() => {
+ const wrap = document.querySelector('.fea-don-wrap');
+ const led = document.querySelector('.fea-ledger');
+ const rec = document.querySelector('.fea-opt-rec');
+ const rows = document.querySelectorAll('.fea-ledger tr');
+ // detectar fechas que parten en 2 lineas en la primera columna
+ let wrappedDates = 0;
+ rows.forEach(r => { const td = r.querySelector('td'); if (td && td.getClientRects().length > 1) wrappedDates++; });
+ return {
+ viewport: window.innerWidth,
+ wrap_width: wrap ? Math.round(wrap.getBoundingClientRect().width) : null,
+ ledger_width: led ? Math.round(led.getBoundingClientRect().width) : null,
+ ledger_rows: rows.length,
+ rec_btns: document.querySelectorAll('.fea-rec-btns a').length,
+ wrapped_date_cells: wrappedDates,
+ };
+ });
+ console.log(JSON.stringify(m, null, 2));
+ await b.close();
+})();
diff --git a/tools/e2e/shot_one.cjs b/tools/e2e/shot_one.cjs
new file mode 100644
index 0000000..7746328
--- /dev/null
+++ b/tools/e2e/shot_one.cjs
@@ -0,0 +1,29 @@
+const { chromium } = require('playwright');
+(async () => {
+ const b = await chromium.launch();
+ const p = await b.newPage({ viewport: { width: 1200, height: 2200 }, deviceScaleFactor: 3 });
+ await p.goto('http://localhost:8081/', { waitUntil: 'networkidle' });
+ // localizar card cuyo autor sea Fray Marcos
+ const card = p.locator('.fea-card', { hasText: 'Fray Marcos' }).first();
+ await card.scrollIntoViewIfNeeded();
+ await card.screenshot({ path: '/tmp/card_fraymarcos.png' });
+ // estilos calculados del span avatar y del img
+ const info = await card.evaluate((el) => {
+ const span = el.querySelector('.fea-card-avatar');
+ const img = el.querySelector('img');
+ const cs = getComputedStyle(span);
+ const ci = getComputedStyle(img);
+ return {
+ span_borderRadius: cs.borderRadius, span_overflow: cs.overflow,
+ span_boxShadow: cs.boxShadow, span_bg: cs.backgroundColor,
+ img_src: img.currentSrc, img_borderRadius: ci.borderRadius,
+ img_bg: ci.backgroundColor, img_objectFit: ci.objectFit,
+ img_natural: img.naturalWidth + 'x' + img.naturalHeight,
+ };
+ });
+ console.log(JSON.stringify(info, null, 2));
+ // tambien una card de iniciales para comparar
+ const card2 = p.locator('.fea-card', { hasText: 'María Gómez' }).first();
+ await card2.screenshot({ path: '/tmp/card_initials.png' }).catch(()=>{});
+ await b.close();
+})();
diff --git a/tools/e2e/shot_portada_link.cjs b/tools/e2e/shot_portada_link.cjs
new file mode 100644
index 0000000..8e12eb7
--- /dev/null
+++ b/tools/e2e/shot_portada_link.cjs
@@ -0,0 +1,15 @@
+const { chromium } = require('playwright');
+(async () => {
+ const b = await chromium.launch({ args: ['--ignore-certificate-errors'] });
+ const p = await b.newPage({ viewport: { width: 1280, height: 1500 }, ignoreHTTPSErrors: true, deviceScaleFactor: 2 });
+ await p.goto('https://farmer.taild3aaf6.ts.net/fea/', { waitUntil: 'networkidle' });
+ const m = await p.evaluate(() => {
+ const a = document.querySelector('.fea-eed-link a');
+ return { found: !!a, text: a? a.textContent.replace(/\s+/g,' ').trim():null, href: a? a.getAttribute('href'):null };
+ });
+ console.log(JSON.stringify(m));
+ const el = await p.$('.fea-eed-link');
+ if (el) await el.scrollIntoViewIfNeeded();
+ await p.screenshot({ path: '/tmp/portada_link.png', fullPage: false });
+ await b.close();
+})();
diff --git a/tools/e2e/shot_tablon.cjs b/tools/e2e/shot_tablon.cjs
new file mode 100644
index 0000000..a599959
--- /dev/null
+++ b/tools/e2e/shot_tablon.cjs
@@ -0,0 +1,14 @@
+const { chromium } = require('playwright');
+(async () => {
+ const b = await chromium.launch({ args: ['--ignore-certificate-errors'] });
+ const p = await b.newPage({ viewport: { width: 1200, height: 1600 }, ignoreHTTPSErrors: true, deviceScaleFactor: 2 });
+ await p.goto('https://farmer.taild3aaf6.ts.net/fea/category/tablon-de-anuncios/', { waitUntil: 'networkidle' });
+ const m = await p.evaluate(() => {
+ const arts = document.querySelectorAll('article, .fea-card, .wp-block-post');
+ const titles = [...document.querySelectorAll('h2 a, h3 a, .entry-title a, article a')].map(a=>a.textContent.trim()).filter(Boolean).slice(0,30);
+ return { articleNodes: arts.length, sampleTitles: titles.slice(0,12) };
+ });
+ console.log(JSON.stringify(m,null,2));
+ await p.screenshot({ path: '/tmp/tablon.png', fullPage: false });
+ await b.close();
+})();
diff --git a/wordpress/wp-content/mu-plugins/carta-semana-plugin.php b/wordpress/wp-content/mu-plugins/carta-semana-plugin.php
old mode 100644
new mode 100755
index ce98e2d..98a4dba
--- a/wordpress/wp-content/mu-plugins/carta-semana-plugin.php
+++ b/wordpress/wp-content/mu-plugins/carta-semana-plugin.php
@@ -2,7 +2,7 @@
/**
* Plugin Name: Fe Adulta — Carta de la Semana
* Description: Redirige las URLs de carta al archivo de categoría correspondiente.
- * Version: 1.7
+ * Version: 1.8
*/
// Redirigir las páginas custom a las categorías
@@ -17,38 +17,59 @@ add_action("template_redirect", function() {
}
});
-// Si la categoría tiene un solo artículo, ir directamente a él en el idioma actual
-// (stop-redirects.php desactiva redirect_canonical que haría esto automáticamente)
+// Las categorías de carta actual/anterior siempre llevan al post traducido que
+// corresponde a la categoría española canónica. No dependemos del count ni de
+// las relaciones traducidas, que pueden quedar desfasadas durante una importación.
add_action("template_redirect", function() {
if (!is_category()) return;
$cat = get_queried_object();
- if (!$cat || $cat->count != 1) return;
- $posts = get_posts([
- 'cat' => $cat->term_id,
- 'numberposts' => 1,
- 'post_status' => 'publish',
- ]);
- if (!$posts) return;
- $post_id = $posts[0]->ID;
- // Buscar la traducción al idioma actual
+ if (!$cat || empty($cat->term_id)) return;
+
+ $source_cat_id = (int) $cat->term_id;
+ if (function_exists('pll_get_term')) {
+ $spanish_cat_id = (int) pll_get_term($source_cat_id, 'es');
+ if ($spanish_cat_id) $source_cat_id = $spanish_cat_id;
+ }
+ if (!in_array($source_cat_id, [6, 22], true)) return;
+
+ global $wpdb;
+ $source_post_id = (int) $wpdb->get_var($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 tt.taxonomy = 'category' AND tt.term_id = %d
+ AND p.post_type = 'post' AND p.post_status = 'publish'
+ ORDER BY p.post_date DESC, p.ID DESC
+ LIMIT 1",
+ $source_cat_id
+ ));
+ if (!$source_post_id) return;
+
+ $post_id = $source_post_id;
if (function_exists('pll_current_language') && function_exists('pll_get_post')) {
$lang = pll_current_language();
- if ($lang) {
- $translated = pll_get_post($post_id, $lang);
- if ($translated) $post_id = $translated;
- }
+ $translated = $lang ? (int) pll_get_post($source_post_id, $lang) : 0;
+ if ($translated) $post_id = $translated;
}
- wp_redirect(get_permalink($post_id), 302);
+
+ $url = get_permalink($post_id);
+ if (!$url) return;
+ wp_safe_redirect($url, 302);
exit;
}, 9);
// Mostrar 50 artículos por página en los archivos de cartas
add_action("pre_get_posts", function($query) {
if (!$query->is_main_query() || is_admin()) return;
- if ($query->is_category(["cartasemana", "cartas-de-otras-semanas", "carta-semana-pasada",
- "cartas-de-otras-semanas-en", "cartas-de-otras-semanas-fr",
- "cartas-de-otras-semanas-it", "cartas-de-otras-semanas-pt",
- "cartasemana-en", "cartasemana-fr", "cartasemana-it", "cartasemana-pt"])) {
+ if ($query->is_category([
+ "cartasemana", "carta-semana-pasada", "cartas-de-otras-semanas",
+ "letter-of-the-week", "lettre-de-la-semaine", "lettera-della-settimana", "carta-da-semana",
+ "carta-semana-pasada-en", "carta-semana-pasada-fr",
+ "carta-semana-pasada-it", "carta-semana-pasada-pt",
+ "letters-from-other-weeks", "lettres-des-autres-semaines",
+ "lettere-delle-altre-settimane", "cartas-de-outras-semanas",
+ ])) {
$query->set("posts_per_page", 50);
}
});
diff --git a/wordpress/wp-content/mu-plugins/fea-avatar-cachebust.php b/wordpress/wp-content/mu-plugins/fea-avatar-cachebust.php
new file mode 100644
index 0000000..a3545e2
--- /dev/null
+++ b/wordpress/wp-content/mu-plugins/fea-avatar-cachebust.php
@@ -0,0 +1,17 @@
+ a las URLs de avatar servidas desde
+ * uploads/avatares/autores/autor-.png. Como al actualizar la foto se
+ * reescribe el MISMO fichero, sin esto el navegador/Cloudflare siguen sirviendo
+ * la versión cacheada. Corre tras el filtro de fea-homepage (prioridad 20).
+ */
+if (!defined('ABSPATH')) exit;
+
+add_filter('get_avatar_url', function ($url, $id_or_email, $args) {
+ if (!is_string($url) || strpos($url, '/avatares/autores/autor-') === false) return $url;
+ $rel = preg_replace('~\?.*$~', '', substr($url, strpos($url, '/avatares/')));
+ $path = wp_get_upload_dir()['basedir'] . $rel;
+ if (file_exists($path)) $url = add_query_arg('v', filemtime($path), $url);
+ return $url;
+}, 20, 3);
diff --git a/wordpress/wp-content/mu-plugins/fea-beta-feedback.php b/wordpress/wp-content/mu-plugins/fea-beta-feedback.php
new file mode 100644
index 0000000..1c29560
--- /dev/null
+++ b/wordpress/wp-content/mu-plugins/fea-beta-feedback.php
@@ -0,0 +1,291 @@
+ [
+ 'name' => 'Beta Feedback',
+ 'singular_name' => 'Feedback',
+ 'menu_name' => 'Beta Feedback',
+ ],
+ 'public' => false,
+ 'show_ui' => true,
+ 'show_in_menu' => true,
+ 'menu_icon' => 'dashicons-feedback',
+ 'menu_position' => 26,
+ 'capability_type' => 'post',
+ 'capabilities' => ['create_posts' => 'do_not_allow'], // solo se crean por API
+ 'map_meta_cap' => true,
+ 'supports' => ['title', 'editor'],
+ 'exclude_from_search' => true,
+ ]);
+});
+
+/* ── 2a) Endpoint de consulta de idioma Polylang (para publicabot / integración externa) ── */
+add_action('rest_api_init', function () {
+ register_rest_route('fea/v1', '/lang/(?P\d+)', [
+ 'methods' => 'GET',
+ 'permission_callback' => '__return_true',
+ 'callback' => function (WP_REST_Request $req) {
+ $id = (int) $req->get_param('id');
+ $post = get_post($id);
+ if (!$post || $post->post_type !== 'post') {
+ return new WP_REST_Response(['error' => 'post not found'], 404);
+ }
+ $langs = wp_get_object_terms($id, 'language', ['fields' => 'slugs']);
+ $lang = (!is_wp_error($langs) && !empty($langs)) ? $langs[0] : null;
+ return new WP_REST_Response(['id' => $id, 'lang' => $lang], 200);
+ },
+ ]);
+});
+
+/* ── 2) Endpoint REST para recibir el voto ────────────────────────────────── */
+add_action('rest_api_init', function () {
+ register_rest_route('fea/v1', '/feedback', [
+ 'methods' => 'POST',
+ 'permission_callback' => '__return_true', // público (Beta); protegido con honeypot + rate-limit
+ 'callback' => 'fea_feedback_submit',
+ ]);
+});
+
+function fea_feedback_submit(WP_REST_Request $req) {
+ // Honeypot: si el campo oculto viene relleno, es un bot.
+ if (trim((string) $req->get_param('website')) !== '') {
+ return new WP_REST_Response(['ok' => true], 200); // fingir éxito
+ }
+
+ $vote = $req->get_param('vote') === 'up' ? 'up' : ($req->get_param('vote') === 'down' ? 'down' : '');
+ if ($vote === '') {
+ return new WP_REST_Response(['ok' => false, 'error' => 'voto inválido'], 400);
+ }
+
+ // Rate-limit por IP.
+ $ip = fea_feedback_client_ip();
+ $key = 'fea_fb_rl_' . md5($ip);
+ $n = (int) get_transient($key);
+ if ($n >= FEA_FB_RATE_MAX) {
+ return new WP_REST_Response(['ok' => false, 'error' => 'demasiados envíos'], 429);
+ }
+ set_transient($key, $n + 1, HOUR_IN_SECONDS);
+
+ $comment = trim((string) $req->get_param('comment'));
+ $comment = mb_substr(wp_strip_all_tags($comment), 0, FEA_FB_COMMENT_MAX);
+ $url = esc_url_raw((string) $req->get_param('url'));
+ $src_id = (int) $req->get_param('post_id');
+ $lang = preg_replace('/[^a-z]/', '', (string) $req->get_param('lang'));
+ $title = (string) $req->get_param('title');
+
+ $emoji = $vote === 'up' ? '👍' : '👎';
+ $post_id = wp_insert_post([
+ 'post_type' => FEA_FB_CPT,
+ 'post_status' => 'private',
+ 'post_title' => sprintf('%s %s', $emoji, $title ?: $url),
+ 'post_content' => $comment,
+ ], true);
+
+ if (is_wp_error($post_id)) {
+ return new WP_REST_Response(['ok' => false, 'error' => 'no se pudo guardar'], 500);
+ }
+
+ update_post_meta($post_id, '_fea_fb_vote', $vote);
+ update_post_meta($post_id, '_fea_fb_url', $url);
+ update_post_meta($post_id, '_fea_fb_source_id', $src_id);
+ update_post_meta($post_id, '_fea_fb_lang', $lang);
+ update_post_meta($post_id, '_fea_fb_ua', mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255));
+ update_post_meta($post_id, '_fea_fb_ip', md5($ip)); // hash, no IP en claro
+
+ return new WP_REST_Response(['ok' => true], 200);
+}
+
+function fea_feedback_client_ip(): string {
+ foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'] as $k) {
+ if (!empty($_SERVER[$k])) return trim(explode(',', $_SERVER[$k])[0]);
+ }
+ return '0.0.0.0';
+}
+
+/* ── 3) Columnas en el listado del wp-admin ───────────────────────────────── */
+add_filter('manage_' . FEA_FB_CPT . '_posts_columns', function ($cols) {
+ return [
+ 'cb' => $cols['cb'] ?? '',
+ 'fb_vote' => 'Voto',
+ 'fb_url' => 'Página',
+ 'fb_lang' => 'Idioma',
+ 'fb_comment'=> 'Comentario',
+ 'date' => 'Fecha',
+ ];
+});
+add_action('manage_' . FEA_FB_CPT . '_posts_custom_column', function ($col, $post_id) {
+ if ($col === 'fb_vote') {
+ echo get_post_meta($post_id, '_fea_fb_vote', true) === 'up' ? '👍' : '👎';
+ } elseif ($col === 'fb_url') {
+ $u = get_post_meta($post_id, '_fea_fb_url', true);
+ if ($u) echo '' . esc_html(wp_parse_url($u, PHP_URL_PATH) ?: $u) . ' ';
+ } elseif ($col === 'fb_lang') {
+ echo esc_html(strtoupper(get_post_meta($post_id, '_fea_fb_lang', true) ?: '—'));
+ } elseif ($col === 'fb_comment') {
+ echo esc_html(wp_trim_words(get_post_field('post_content', $post_id), 24));
+ }
+}, 10, 2);
+
+/* ── 4) Barra Beta sutil (persistente) + tarjeta de feedback (a demanda) ──── */
+/** Etiquetas del widget Beta por idioma (Polylang). */
+function fea_beta_labels(): array {
+ $all = [
+ 'es' => ['region'=>'Aviso Beta','intro'=>'Estamos en','help'=>'¿Nos ayudas a mejorar FeAdulta?',
+ 'opinion'=>'Dar mi opinión','collab'=>'Colaborar','dismiss'=>'Cerrar aviso','fbregion'=>'Feedback de la página',
+ 'close'=>'Cerrar','q'=>'¿Se ve bien esta página?','up'=>'Sí, se ve bien','down'=>'No, hay algo mal',
+ 'ph'=>'¿Algo falla o se ve mal? Cuéntanoslo (opcional)','send'=>'Enviar','thanks'=>'¡Gracias por ayudar! 🙏'],
+ 'en' => ['region'=>'Beta notice','intro'=>'We are in','help'=>'Will you help us improve FeAdulta?',
+ 'opinion'=>'Give feedback','collab'=>'Collaborate','dismiss'=>'Close notice','fbregion'=>'Page feedback',
+ 'close'=>'Close','q'=>'Does this page look right?','up'=>'Yes, looks good','down'=>'No, something is wrong',
+ 'ph'=>'Something broken or off? Tell us (optional)','send'=>'Send','thanks'=>'Thanks for helping! 🙏'],
+ 'fr' => ['region'=>'Avis Bêta','intro'=>'Nous sommes en','help'=>'Voulez-vous nous aider à améliorer FeAdulta ?',
+ 'opinion'=>'Donner mon avis','collab'=>'Collaborer','dismiss'=>'Fermer l’avis','fbregion'=>'Retour sur la page',
+ 'close'=>'Fermer','q'=>'Cette page s’affiche-t-elle bien ?','up'=>'Oui, c’est bien','down'=>'Non, il y a un problème',
+ 'ph'=>'Un souci ou un affichage incorrect ? Dites-le-nous (facultatif)','send'=>'Envoyer','thanks'=>'Merci de votre aide ! 🙏'],
+ 'it' => ['region'=>'Avviso Beta','intro'=>'Siamo in','help'=>'Ci aiuti a migliorare FeAdulta?',
+ 'opinion'=>'Dai la tua opinione','collab'=>'Collabora','dismiss'=>'Chiudi avviso','fbregion'=>'Feedback della pagina',
+ 'close'=>'Chiudi','q'=>'Questa pagina si vede bene?','up'=>'Sì, si vede bene','down'=>'No, c’è qualcosa che non va',
+ 'ph'=>'Qualcosa non va o si vede male? Faccelo sapere (facoltativo)','send'=>'Invia','thanks'=>'Grazie per l’aiuto! 🙏'],
+ 'pt' => ['region'=>'Aviso Beta','intro'=>'Estamos em','help'=>'Ajuda-nos a melhorar a FeAdulta?',
+ 'opinion'=>'Dar a minha opinião','collab'=>'Colaborar','dismiss'=>'Fechar aviso','fbregion'=>'Feedback da página',
+ 'close'=>'Fechar','q'=>'Esta página vê-se bem?','up'=>'Sim, vê-se bem','down'=>'Não, há algo errado',
+ 'ph'=>'Algo falha ou vê-se mal? Conta-nos (opcional)','send'=>'Enviar','thanks'=>'Obrigado por ajudar! 🙏'],
+ ];
+ $lang = function_exists('pll_current_language') ? (string) pll_current_language() : 'es';
+ return $all[$lang] ?? $all['es'];
+}
+
+add_action('wp_footer', function () {
+ if (is_admin()) return;
+ $rest = esc_url_raw(rest_url('fea/v1/feedback'));
+ $t = fea_beta_labels();
+ ?>
+
+
+
+
+
+
+
×
+
+
+ 👍
+ 👎
+
+
+
+
+
+
+
+
+
+
+
+ \d+)', [
+ [
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => 'fea_carta_id_api_get',
+ 'permission_callback' => 'fea_carta_id_api_can_edit_post',
+ 'args' => fea_carta_id_api_route_args(),
+ ],
+ [
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => 'fea_carta_id_api_update',
+ 'permission_callback' => 'fea_carta_id_api_can_edit_post',
+ 'args' => array_merge(fea_carta_id_api_route_args(), [
+ 'carta_id' => [
+ 'required' => true,
+ ],
+ ]),
+ ],
+ [
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => 'fea_carta_id_api_delete',
+ 'permission_callback' => 'fea_carta_id_api_can_edit_post',
+ 'args' => fea_carta_id_api_route_args(),
+ ],
+ ]);
+});
+
+function fea_carta_id_api_route_args() {
+ return [
+ 'post_id' => [
+ 'required' => true,
+ ],
+ ];
+}
+
+function fea_carta_id_api_can_edit_post(WP_REST_Request $request) {
+ if (!is_user_logged_in()) {
+ return new WP_Error(
+ 'fea_carta_id_not_authenticated',
+ 'Debes autenticarte para leer o modificar _carta_id.',
+ ['status' => 401]
+ );
+ }
+
+ $post_id = (int) $request['post_id'];
+ if (!fea_carta_id_api_post_exists($post_id)) {
+ return true;
+ }
+
+ if (!current_user_can('edit_post', $post_id)) {
+ return new WP_Error(
+ 'fea_carta_id_forbidden',
+ 'No tienes permiso para editar este post.',
+ ['status' => 403]
+ );
+ }
+
+ return true;
+}
+
+function fea_carta_id_api_get(WP_REST_Request $request) {
+ $post_id = (int) $request['post_id'];
+ $error = fea_carta_id_api_validate_post_id($post_id);
+ if ($error) return $error;
+
+ return fea_carta_id_api_response($post_id);
+}
+
+function fea_carta_id_api_update(WP_REST_Request $request) {
+ $post_id = (int) $request['post_id'];
+ $error = fea_carta_id_api_validate_post_id($post_id);
+ if ($error) return $error;
+
+ $carta_id = fea_carta_id_api_parse_positive_int($request->get_param('carta_id'));
+ if (!$carta_id) {
+ return new WP_Error(
+ 'fea_carta_id_invalid',
+ 'carta_id debe ser un entero positivo.',
+ ['status' => 400]
+ );
+ }
+
+ if (!fea_carta_id_api_post_exists($carta_id)) {
+ return new WP_Error(
+ 'fea_carta_id_not_found',
+ 'carta_id debe corresponder a un post existente.',
+ ['status' => 400]
+ );
+ }
+
+ update_post_meta($post_id, '_carta_id', $carta_id);
+
+ return fea_carta_id_api_response($post_id);
+}
+
+function fea_carta_id_api_delete(WP_REST_Request $request) {
+ $post_id = (int) $request['post_id'];
+ $error = fea_carta_id_api_validate_post_id($post_id);
+ if ($error) return $error;
+
+ delete_post_meta($post_id, '_carta_id');
+
+ return fea_carta_id_api_response($post_id);
+}
+
+function fea_carta_id_api_validate_post_id($post_id) {
+ if (!fea_carta_id_api_post_exists($post_id)) {
+ return new WP_Error(
+ 'fea_carta_id_post_not_found',
+ 'post_id debe corresponder a un post existente.',
+ ['status' => 404]
+ );
+ }
+
+ return null;
+}
+
+function fea_carta_id_api_post_exists($post_id) {
+ $post = get_post($post_id);
+ return $post && $post->post_type === 'post';
+}
+
+function fea_carta_id_api_parse_positive_int($value) {
+ $int = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
+ return $int === false ? null : (int) $int;
+}
+
+function fea_carta_id_api_response($post_id) {
+ $carta_id = fea_carta_id_api_parse_positive_int(get_post_meta($post_id, '_carta_id', true));
+
+ return rest_ensure_response([
+ 'post_id' => (int) $post_id,
+ 'carta_id' => $carta_id,
+ ]);
+}
diff --git a/wordpress/wp-content/mu-plugins/fea-carta-portada.php b/wordpress/wp-content/mu-plugins/fea-carta-portada.php
index 74ac01f..4376041 100644
--- a/wordpress/wp-content/mu-plugins/fea-carta-portada.php
+++ b/wordpress/wp-content/mu-plugins/fea-carta-portada.php
@@ -140,9 +140,21 @@ function fea_url_to_post_id($url) {
// Enlace interno WP: deriva el slug del path, relativo al home. Agnóstico al
// entorno → funciona en local (home en .../fea) y en prod (home en la raíz).
// No depende de un prefijo /fea/ hardcodeado (issue #91).
- $host = wp_parse_url($url, PHP_URL_HOST);
- $home_host = wp_parse_url(home_url(), PHP_URL_HOST);
- if ($host && $home_host && strcasecmp($host, $home_host) !== 0) {
+ //
+ // Durante el cutover de dominio (issue #158) el `home_url()` de WP sigue en
+ // wp-nuevo.feadulta.com pero el contenido ya enlaza a www.feadulta.com. Para
+ // no romper la resolución mientras el cutover no esté cerrado, se acepta
+ // también una lista fija de hosts propios del sitio, no solo home_url().
+ // TODO: cuando #158 quede resuelto y home_url() sea www.feadulta.com, esta
+ // lista se puede simplificar a solo home_url().
+ $own_hosts = array_filter(array_unique([
+ strtolower((string) wp_parse_url(home_url(), PHP_URL_HOST)),
+ 'www.feadulta.com',
+ 'feadulta.com',
+ 'wp-nuevo.feadulta.com',
+ ]));
+ $host = wp_parse_url($url, PHP_URL_HOST);
+ if ($host && !in_array(strtolower($host), $own_hosts, true)) {
return null; // host externo → no es un artículo nuestro
}
diff --git a/wordpress/wp-content/mu-plugins/fea-cloudflare-realip.php b/wordpress/wp-content/mu-plugins/fea-cloudflare-realip.php
new file mode 100644
index 0000000..0d85baf
--- /dev/null
+++ b/wordpress/wp-content/mu-plugins/fea-cloudflare-realip.php
@@ -0,0 +1,76 @@
+ 0 && 0 !== substr_compare( $ip_bin, $subnet_bin, 0, $bytes ) ) {
+ return false;
+ }
+ if ( $rem > 0 ) {
+ $mask = ~( ( 1 << ( 8 - $rem ) ) - 1 ) & 0xff;
+ if ( ( ord( $ip_bin[ $bytes ] ) & $mask ) !== ( ord( $subnet_bin[ $bytes ] ) & $mask ) ) {
+ return false;
+ }
+ }
+ return true;
+}
+
+$fea_ranges = ( false !== strpos( $fea_remote, ':' ) ) ? $fea_cf_ipv6 : $fea_cf_ipv4;
+$fea_from_cf = false;
+foreach ( $fea_ranges as $fea_cidr ) {
+ if ( fea_ip_in_cidr( $fea_remote, $fea_cidr ) ) {
+ $fea_from_cf = true;
+ break;
+ }
+}
+
+// Solo confiamos en CF-Connecting-IP si la conexión proviene de Cloudflare,
+// y solo si el valor es una IP válida (evita inyecciones).
+if ( $fea_from_cf && false !== filter_var( $fea_client, FILTER_VALIDATE_IP ) ) {
+ $_SERVER['REMOTE_ADDR'] = $fea_client;
+}
diff --git a/wordpress/wp-content/mu-plugins/fea-compact-entry-spacing.php b/wordpress/wp-content/mu-plugins/fea-compact-entry-spacing.php
new file mode 100644
index 0000000..4179b4b
--- /dev/null
+++ b/wordpress/wp-content/mu-plugins/fea-compact-entry-spacing.php
@@ -0,0 +1,67 @@
+
+
+ remove_menu('comments');
+ }
+});
diff --git a/wordpress/wp-content/mu-plugins/fea-gsc-verification.php b/wordpress/wp-content/mu-plugins/fea-gsc-verification.php
new file mode 100644
index 0000000..193ae50
--- /dev/null
+++ b/wordpress/wp-content/mu-plugins/fea-gsc-verification.php
@@ -0,0 +1,13 @@
+ WordPress (la plantilla de
+ * Joomla la tenía hardcodeada; en WP no había ningún sitio equivalente).
+ */
+if (!defined('ABSPATH')) exit;
+
+add_action('wp_head', function () {
+ if (is_admin()) return;
+ echo ' ' . "\n";
+}, 1);
diff --git a/wordpress/wp-content/mu-plugins/fea-hide-bad-tag.php b/wordpress/wp-content/mu-plugins/fea-hide-bad-tag.php
new file mode 100644
index 0000000..fbbde0d
--- /dev/null
+++ b/wordpress/wp-content/mu-plugins/fea-hide-bad-tag.php
@@ -0,0 +1,66 @@
+taxonomy, $term->slug, $term->name)
+ && $term->taxonomy === 'post_tag'
+ && ($term->slug === '1' || $term->name === '1');
+}
+
+add_filter('get_the_terms', function($terms, $post_id, $taxonomy) {
+ if (is_admin() || $taxonomy !== 'post_tag' || empty($terms) || is_wp_error($terms)) {
+ return $terms;
+ }
+
+ return array_values(array_filter($terms, function($term) {
+ return !fea_is_bad_imported_tag($term);
+ }));
+}, 10, 3);
+
+add_filter('get_terms', function($terms, $taxonomies) {
+ if (is_admin() || is_wp_error($terms) || empty($terms) || !in_array('post_tag', (array)$taxonomies, true)) {
+ return $terms;
+ }
+
+ return array_values(array_filter($terms, function($term) {
+ return !fea_is_bad_imported_tag($term);
+ }));
+}, 10, 2);
+
+add_filter('redirect_canonical', function($redirect_url) {
+ return fea_is_bad_imported_request_path() ? false : $redirect_url;
+}, 10);
+
+add_filter('do_redirect_guess_404_permalink', function($do_redirect) {
+ return fea_is_bad_imported_request_path() ? false : $do_redirect;
+}, 10);
+
+add_filter('wp_redirect', function($location) {
+ return fea_is_bad_imported_request_path() ? false : $location;
+}, 0);
+
+add_action('template_redirect', function() {
+ if (!is_tag('1') && !fea_is_bad_imported_request_path()) {
+ return;
+ }
+
+ global $wp_query;
+ $wp_query->set_404();
+ status_header(404);
+ nocache_headers();
+}, 0);
diff --git a/wordpress/wp-content/mu-plugins/fea-homepage.php b/wordpress/wp-content/mu-plugins/fea-homepage.php
index b93254c..ef24ff3 100755
--- a/wordpress/wp-content/mu-plugins/fea-homepage.php
+++ b/wordpress/wp-content/mu-plugins/fea-homepage.php
@@ -790,9 +790,40 @@ function fea_title(string $title): string {
$out = preg_replace_callback('/([\/:¿¡] *)(\p{Ll})/u', function ($m) {
return $m[1] . mb_strtoupper($m[2], 'UTF-8');
}, $out);
+ $out = fea_recapitalizar_nombres_propios($out);
return $out;
}
+/**
+ * Nombres propios (personas, lugares, "Dios"/"Papa") que deben mantener mayúscula
+ * inicial en los títulos de portada. Los títulos originales se guardan en MAYÚSCULAS
+ * y fea_title() los minusculiza salvo la primera letra, así que esta señal se pierde
+ * para cualquier nombre propio que no sea la primera palabra del título. Añadir aquí
+ * cuando una carta nueva mencione un nombre que salga mal capitalizado en portada.
+ * Ver issue rafa/feadulta#160.
+ */
+function fea_nombres_propios(): array {
+ return [
+ 'Aguirre', 'Crepin', 'Monga', 'Papa', 'Lampedusa', 'Europa', 'España', 'Dios',
+ ];
+}
+
+function fea_recapitalizar_nombres_propios(string $texto): string {
+ static $regex = null, $map = null;
+ if ($regex === null) {
+ $nombres = fea_nombres_propios();
+ usort($nombres, fn($a, $b) => mb_strlen($b, 'UTF-8') - mb_strlen($a, 'UTF-8'));
+ $alt = implode('|', array_map(fn($n) => preg_quote($n, '/'), $nombres));
+ $regex = '/(? $size, 'default' => 'identicon']);
}
diff --git a/wordpress/wp-content/mu-plugins/fea-legacy-redirect.php b/wordpress/wp-content/mu-plugins/fea-legacy-redirect.php
new file mode 100644
index 0000000..83a7614
--- /dev/null
+++ b/wordpress/wp-content/mu-plugins/fea-legacy-redirect.php
@@ -0,0 +1,24 @@
+ [en, fr, it, pt]. Claves normalizadas con trim. */
+function fea_menu_map(): array {
+ static $m = null;
+ if ($m !== null) return $m;
+ $m = [
+ // ── Menú principal (header) ──
+ 'PORTADA' => ['Home', 'Accueil', 'Home', 'Início'],
+ 'Quiénes somos' => ['About us', 'À propos', 'Chi siamo', 'Quem somos'],
+ 'Colaboradores' => ['Contributors', 'Collaborateurs', 'Collaboratori', 'Colaboradores'],
+ 'Este portal' => ['This portal', 'Ce portail', 'Questo portale', 'Este portal'],
+ 'Para poner al día la Fe' => ['Bringing faith up to date', 'Mettre la foi à jour', 'Aggiornare la fede', 'Atualizar a fé'],
+ 'Cartas' => ['Letters', 'Lettres', 'Lettere', 'Cartas'],
+ 'Esta semana' => ['This week', 'Cette semaine', 'Questa settimana', 'Esta semana'],
+ 'Semana pasada' => ['Last week', 'Semaine dernière', 'Settimana scorsa', 'Semana passada'],
+ 'Otras semanas' => ['Other weeks', 'Autres semaines', 'Altre settimane', 'Outras semanas'],
+ 'Acceso a webs anteriores:' => ['Previous websites:', 'Anciens sites :', 'Siti precedenti:', 'Sites anteriores:'],
+ 'Web V1 — FrontPage (2006-2012)' => ['Site V1 — FrontPage (2006-2012)', 'Site V1 — FrontPage (2006-2012)', 'Sito V1 — FrontPage (2006-2012)', 'Site V1 — FrontPage (2006-2012)'],
+ 'Web V2 — Joomla (2012-2026)' => ['Site V2 — Joomla (2012-2026)', 'Site V2 — Joomla (2012-2026)', 'Sito V2 — Joomla (2012-2026)', 'Site V2 — Joomla (2012-2026)'],
+ 'Nueva política de datos' => ['New data policy', 'Nouvelle politique de données', 'Nuova politica sui dati', 'Nova política de dados'],
+ 'Contactar' => ['Contact', 'Contact', 'Contatti', 'Contactar'],
+ 'Para contactar con nosotros' => ['Contact us', 'Nous contacter', 'Per contattarci', 'Para contactar-nos'],
+ 'Para recibir la carta de novedades' => ['Subscribe to the newsletter', 'Recevoir la newsletter', 'Ricevere la newsletter', 'Receber a newsletter'],
+ 'Para inscribirse en la Escuela' => ['Enrol in the School', 'S\'inscrire à l\'École', 'Iscriversi alla Scuola', 'Inscrever-se na Escola'],
+ '🎓 Escuela' => ['🎓 School', '🎓 École', '🎓 Scuola', '🎓 Escola'],
+ '📚 Librería 🛒' => ['📚 Bookshop 🛒', '📚 Librairie 🛒', '📚 Libreria 🛒', '📚 Livraria 🛒'],
+ 'Buscar' => ['Search', 'Rechercher', 'Cerca', 'Pesquisar'],
+ // ── Menús del pie ──
+ 'Cartas que nos llegan' => ['Letters we receive', 'Lettres que nous recevons', 'Lettere che riceviamo', 'Cartas que recebemos'],
+ 'Tablón de anuncios' => ['Notice board', 'Tableau d\'annonces', 'Bacheca', 'Mural de avisos'],
+ 'Asociación FeAdulta' => ['FeAdulta Association', 'Association FeAdulta', 'Associazione FeAdulta', 'Associação FeAdulta'],
+ 'La suma de todos' => ['The sum of all', 'La somme de tous', 'La somma di tutti', 'A soma de todos'],
+ 'Comunidades cristianas' => ['Christian communities', 'Communautés chrétiennes', 'Comunità cristiane', 'Comunidades cristãs'],
+ 'El Evangelio de cada día' => ['The daily Gospel', 'L\'Évangile de chaque jour', 'Il Vangelo di ogni giorno', 'O Evangelho de cada dia'],
+ 'Índice cronológico' => ['Chronological index', 'Index chronologique', 'Indice cronologico', 'Índice cronológico'],
+ 'Índice cronológico' => ['Chronological index', 'Index chronologique', 'Indice cronologico', 'Índice cronológico'],
+ 'Evangelios y comentarios' => ['Gospels and commentaries', 'Évangiles et commentaires', 'Vangeli e commenti', 'Evangelhos e comentários'],
+ 'Oraciones eucarísticas' => ['Eucharistic prayers', 'Prières eucharistiques', 'Preghiere eucaristiche', 'Orações eucarísticas'],
+ 'A modo de salmos' => ['In the manner of psalms', 'À la manière de psaumes', 'A mo\' di salmi', 'À maneira de salmos'],
+ 'Preces y oraciones varias' => ['Prayers and various orations', 'Prières et oraisons diverses', 'Preci e orazioni varie', 'Preces e orações várias'],
+ 'Primeras lecturas' => ['First readings', 'Premières lectures', 'Prime letture', 'Primeiras leituras'],
+ 'Autores' => ['Authors', 'Auteurs', 'Autori', 'Autores'],
+ 'Temas' => ['Topics', 'Thèmes', 'Temi', 'Temas'],
+ 'Multimedia' => ['Multimedia', 'Multimédia', 'Multimedia', 'Multimédia'],
+ 'Índice de pensamientos' => ['Index of reflections', 'Index des pensées', 'Indice dei pensieri', 'Índice de pensamentos'],
+ 'Cantoral' => ['Hymnal', 'Recueil de chants', 'Canzoniere', 'Cancioneiro'],
+ 'Películas' => ['Films', 'Films', 'Film', 'Filmes'],
+ 'Reseñas de libros' => ['Book reviews', 'Critiques de livres', 'Recensioni di libri', 'Resenhas de livros'],
+ 'In memoriam' => ['In memoriam', 'In memoriam', 'In memoriam', 'In memoriam'],
+ ];
+ return $m;
+}
+
+/** Índice de columna por idioma. */
+function fea_menu_lang_col(string $lang): int {
+ return ['en' => 0, 'fr' => 1, 'it' => 2, 'pt' => 3][$lang] ?? -1;
+}
+
+function fea_menu_tr(string $label, int $col): ?string {
+ $map = fea_menu_map();
+ $key = trim($label);
+ if (isset($map[$key][$col]) && $map[$key][$col] !== '') return $map[$key][$col];
+ return null;
+}
+
+/**
+ * Remapea una URL ES al destino traducido si existe (post/página/categoría).
+ * Devuelve la URL original si no hay traducción o no se resuelve.
+ */
+function fea_menu_localize_url(string $url, string $lang): string {
+ if ($url === '' || preg_match('~^(mailto:|tel:|javascript:|#)~i', $url)) return $url;
+ // Solo enlaces internos de ESTE sitio (no Librería, no webs anteriores externas).
+ $host = parse_url($url, PHP_URL_HOST);
+ if ($host) {
+ $site_host = parse_url(home_url('/'), PHP_URL_HOST);
+ if ($host !== $site_host) return $url; // externo
+ }
+ $path = trim((string) parse_url($url, PHP_URL_PATH), '/');
+ // quitar subcarpeta local (fea) y prefijo de idioma (es/en/…), con o sin barra final
+ $path = preg_replace('#^fea(/|$)#', '', $path);
+ $path = preg_replace('#^(es|en|fr|it|pt)(/|$)#', '', $path);
+ if ($path === '') {
+ // raíz del sitio → portada del idioma
+ return function_exists('pll_home_url') ? pll_home_url($lang) : $url;
+ }
+
+ // categoría: category/. El slug del menú es ES; buscamos SIN filtro de
+ // idioma de Polylang (lang => '') porque el render va en otro idioma.
+ if (preg_match('#^category/([^/]+)/?$#', $path, $mm)) {
+ $terms = get_terms(['taxonomy' => 'category', 'slug' => $mm[1], 'hide_empty' => false, 'number' => 1, 'lang' => '']);
+ $term = (!is_wp_error($terms) && $terms) ? $terms[0] : null;
+ if ($term && function_exists('pll_get_term')) {
+ $tr = pll_get_term($term->term_id, $lang);
+ if ($tr) {
+ $link = get_category_link($tr);
+ if ($link && !is_wp_error($link)) return $link;
+ }
+ }
+ return $url;
+ }
+
+ // página/post por último segmento (lang => '' para no filtrar por idioma actual)
+ $slug = basename($path);
+ $q = get_posts(['name' => $slug, 'post_type' => ['post', 'page'], 'numberposts' => 1, 'post_status' => 'publish', 'lang' => '']);
+ $page = $q ? $q[0] : null;
+ if ($page && function_exists('pll_get_post')) {
+ $tr = pll_get_post($page->ID, $lang);
+ if ($tr) {
+ $link = get_permalink($tr);
+ if ($link) return $link;
+ }
+ }
+ return $url;
+}
+
+add_filter('render_block', function ($content, $block) {
+ if (empty($block['blockName'])) return $content;
+ if ($block['blockName'] !== 'core/navigation-link' && $block['blockName'] !== 'core/navigation-submenu') return $content;
+ if (!function_exists('pll_current_language')) return $content;
+
+ $lang = pll_current_language();
+ $col = fea_menu_lang_col((string) $lang);
+ if ($col < 0) return $content; // es o desconocido → sin tocar
+
+ $label = isset($block['attrs']['label']) ? (string) $block['attrs']['label'] : '';
+ $url = isset($block['attrs']['url']) ? (string) $block['attrs']['url'] : '';
+
+ // 1) etiqueta
+ if ($label !== '') {
+ $tr = fea_menu_tr($label, $col);
+ if ($tr !== null && $tr !== $label) {
+ $content = str_replace('>' . esc_html($label) . '<', '>' . esc_html($tr) . '<', $content);
+ }
+ }
+ // 2) URL — localizar el href realmente renderizado (robusto frente a discrepancias attr/HTML)
+ $content = preg_replace_callback('/href=("|\')([^"\']*)\1/i', function ($m) use ($lang) {
+ $href = html_entity_decode($m[2], ENT_QUOTES);
+ $loc = fea_menu_localize_url($href, (string) $lang);
+ return 'href=' . $m[1] . esc_url($loc) . $m[1];
+ }, $content, 1);
+ return $content;
+}, 10, 2);
diff --git a/wordpress/wp-content/mu-plugins/fea-pensamientos.php b/wordpress/wp-content/mu-plugins/fea-pensamientos.php
new file mode 100644
index 0000000..84d2acc
--- /dev/null
+++ b/wordpress/wp-content/mu-plugins/fea-pensamientos.php
@@ -0,0 +1,448 @@
+ 'Pensamientos',
+ 'per_page' => (string) FEA_GALLERY_PER_PAGE,
+ 'order' => 'desc',
+ ], $atts, 'fea_galeria');
+
+ $dir = fea_gallery_safe_dir((string) $atts['dir']);
+ if ($dir === '') return '';
+
+ $order = strtolower((string) $atts['order']) === 'asc' ? 'asc' : 'desc';
+ $files = fea_gallery_files($dir, $order);
+ if (!$files) {
+ return 'Galería no disponible.
';
+ }
+
+ $per_page = max(12, min(144, (int) $atts['per_page']));
+ $total = count($files);
+ $pages = max(1, (int) ceil($total / $per_page));
+ $param = fea_gallery_page_param($dir);
+ $page = isset($_GET[$param]) ? max(1, (int) $_GET[$param]) : 1;
+ $page = min($page, $pages);
+ $offset = ($page - 1) * $per_page;
+ $visible = array_slice($files, $offset, $per_page);
+
+ $html = '';
+ $html .= '
';
+ foreach ($visible as $file) {
+ $url = fea_gallery_url($dir, $file);
+ $alt = preg_replace('/\.[^.]+$/', '', $file);
+ $html .= '
';
+ $html .= ' ';
+ $html .= ' ';
+ }
+ $html .= '
';
+
+ if ($pages > 1) {
+ $html .= '
';
+ if ($page > 1) {
+ $html .= 'Anterior ';
+ }
+ $html .= 'Página ' . esc_html((string) $page) . ' de ' . esc_html((string) $pages) . ' ';
+ if ($page < $pages) {
+ $html .= 'Siguiente ';
+ }
+ $html .= ' ';
+ }
+
+ $html .= '
';
+ return $html;
+}
+
+add_shortcode('fea_galeria', 'fea_gallery_render');
+
+add_filter('the_content', function ($content) {
+ if (is_admin() || stripos($content, '{gallery}') === false) return $content;
+
+ return preg_replace_callback(
+ '/\{gallery\}\s*([^{}]+?)\s*\{\/gallery\}/i',
+ function ($m) {
+ return fea_gallery_render(['dir' => trim($m[1])]);
+ },
+ $content
+ );
+}, 8);
+
+function fea_random_thought_html(): string {
+ $files = fea_gallery_files('Pensamientos', 'desc');
+ if (!$files) return '';
+
+ $file = $files[array_rand($files)];
+ $url = fea_gallery_url('Pensamientos', $file);
+
+ return '';
+}
+
+function fea_random_thought_excluded(): bool {
+ if (!is_singular('post')) return true;
+
+ $post_id = get_the_ID();
+
+ $excluded_ids = array_filter(array_map('intval', explode(',', (string) FEA_RANDOM_THOUGHT_EXCLUDED_IDS)));
+ if ($post_id && in_array((int) $post_id, $excluded_ids, true)) return true;
+
+ if ($post_id) {
+ $raw = (string) get_post_field('post_content', $post_id);
+ if (stripos($raw, '{gallery}') !== false || stripos($raw, '[fea_galeria') !== false) {
+ return true;
+ }
+ }
+
+ $ids = array_filter(array_map('intval', explode(',', (string) FEA_RANDOM_THOUGHT_EXCLUDED_CATS)));
+ foreach ($ids as $id) {
+ if ($id > 0 && has_category($id)) return true;
+ }
+ return false;
+}
+
+add_shortcode('fea_reflexion_aleatoria', function () {
+ return fea_random_thought_html();
+});
+
+add_filter('the_content', function ($content) {
+ if (is_admin() || !is_main_query() || !in_the_loop() || fea_random_thought_excluded()) {
+ return $content;
+ }
+ if (strpos($content, 'fea-random-thought') !== false) return $content;
+
+ $thought = fea_random_thought_html();
+ return $thought ? $content . $thought : $content;
+}, 18);
+
+function fea_pensamientos_assets_needed(): bool {
+ if (!is_singular()) return false;
+
+ $post_id = get_queried_object_id();
+ $raw = $post_id ? (string) get_post_field('post_content', $post_id) : '';
+ if (stripos($raw, '{gallery}') !== false || stripos($raw, '[fea_galeria') !== false || stripos($raw, '[fea_reflexion_aleatoria') !== false) {
+ return true;
+ }
+
+ return !fea_random_thought_excluded();
+}
+
+add_action('wp_head', function () {
+ if (!fea_pensamientos_assets_needed()) return;
+
+ ?>
+
+
+ term_id : 0;
+}
+
+function fea_recop_title(string $raw): string {
+ return function_exists('fea_title') ? fea_title($raw) : $raw;
+}
+
+function fea_recop_render(array $atts = []): string {
+ $atts = shortcode_atts([
+ 'cat' => '',
+ 'per_page' => (string) FEA_RECOP_DEFAULT_PER_PAGE,
+ 'group' => 'year',
+ 'order' => 'desc',
+ ], $atts, 'fea_recopilatorio');
+
+ $term_id = fea_recop_resolve_term($atts['cat']);
+ if (!$term_id) return 'Recopilatorio no disponible.
';
+
+ $per_page = max(20, min(500, (int) $atts['per_page']));
+ $order = strtolower($atts['order']) === 'asc' ? 'ASC' : 'DESC';
+ $paged = isset($_GET['recop']) ? max(1, (int) $_GET['recop']) : 1;
+
+ $q = new WP_Query([
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ 'cat' => $term_id,
+ 'posts_per_page' => $per_page,
+ 'paged' => $paged,
+ 'orderby' => 'date',
+ 'order' => $order,
+ 'ignore_sticky_posts' => true,
+ 'no_found_rows' => false,
+ ]);
+
+ if (!$q->have_posts()) {
+ wp_reset_postdata();
+ return 'Todavía no hay entradas en esta sección.
';
+ }
+
+ $by_year = ($atts['group'] === 'year');
+ $html = '';
+ $cur_year = null;
+ $open = false;
+
+ while ($q->have_posts()) {
+ $q->the_post();
+ if ($by_year) {
+ $y = get_the_date('Y');
+ if ($y !== $cur_year) {
+ if ($open) $html .= '';
+ $html .= '
' . esc_html($y) . ' ';
+ $cur_year = $y; $open = true;
+ }
+ } elseif (!$open) {
+ $html .= ''; $open = true;
+ }
+ $title = fea_recop_title(get_the_title());
+ $html .= '' . esc_html($title) . ' '
+ . ' ' . esc_html(get_the_date('j M Y')) . ' ';
+ }
+ if ($open) $html .= ' ';
+ wp_reset_postdata();
+
+ // Paginación propia
+ $total_pages = (int) $q->max_num_pages;
+ if ($total_pages > 1) {
+ $html .= '';
+ if ($paged > 1) {
+ $html .= 'Anterior ';
+ }
+ $html .= 'Página ' . $paged . ' de ' . $total_pages . ' ';
+ if ($paged < $total_pages) {
+ $html .= 'Siguiente ';
+ }
+ $html .= ' ';
+ }
+ $html .= ' ';
+ return $html;
+}
+add_shortcode('fea_recopilatorio', 'fea_recop_render');
+
+// ── [fea_multimedia_indice] — galería visual de multimedia (issue #110) ──────
+// Sustituye la página intermedia /multimedia/ (4 enlaces) por la lista directa
+// de los artículos de las categorías multimedia, con preview visual (miniatura
+// de YouTube o primera imagen del contenido) + extracto, para invitar al clic.
+
+/** Extrae una preview del contenido: ['type'=>'video'|'image'|'none','src'=>url]. */
+function fea_mm_preview(string $content): array {
+ // 1) Vídeo de YouTube embebido → miniatura hqdefault
+ if (preg_match('~(?:youtube(?:-nocookie)?\.com/(?:embed/|watch\?v=)|youtu\.be/)([A-Za-z0-9_-]{6,})~', $content, $m)) {
+ return ['type' => 'video', 'src' => 'https://img.youtube.com/vi/' . $m[1] . '/hqdefault.jpg'];
+ }
+ // 2) Vimeo → sin thumbnail server-side fiable; marcar como vídeo sin src
+ if (preg_match('~vimeo\.com/(?:video/)?(\d+)~', $content)) {
+ return ['type' => 'video', 'src' => ''];
+ }
+ // 3) Primera imagen del contenido
+ if (preg_match('~ ]+src=["\']([^"\']+)["\']~i', $content, $m)) {
+ return ['type' => 'image', 'src' => $m[1]];
+ }
+ return ['type' => 'none', 'src' => ''];
+}
+
+function fea_mm_indice_render(array $atts = []): string {
+ $atts = shortcode_atts([
+ 'cats' => '1649,26', // Multimedia + Índice multimedia
+ 'per_page' => '24',
+ ], $atts, 'fea_multimedia_indice');
+
+ $cats = array_filter(array_map('intval', explode(',', $atts['cats'])));
+ if (!$cats) return '';
+ $per_page = max(6, min(60, (int) $atts['per_page']));
+ $paged = isset($_GET['mmpag']) ? max(1, (int) $_GET['mmpag']) : 1;
+
+ $q = new WP_Query([
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ 'category__in' => $cats,
+ 'posts_per_page' => $per_page,
+ 'paged' => $paged,
+ 'orderby' => 'date',
+ 'order' => 'DESC',
+ 'ignore_sticky_posts' => true,
+ ]);
+
+ if (!$q->have_posts()) {
+ wp_reset_postdata();
+ return 'Todavía no hay multimedia disponible.
';
+ }
+
+ $html = '';
+
+ $total_pages = (int) $q->max_num_pages;
+ wp_reset_postdata();
+ if ($total_pages > 1) {
+ $html .= '
';
+ if ($paged > 1) $html .= 'Anterior ';
+ $html .= 'Página ' . $paged . ' de ' . $total_pages . ' ';
+ if ($paged < $total_pages) $html .= 'Siguiente ';
+ $html .= ' ';
+ }
+ $html .= '
'; // .fea-mm-wrap
+ return $html;
+}
+add_shortcode('fea_multimedia_indice', 'fea_mm_indice_render');
+
+add_action('wp_head', function () {
+ if (is_admin()) return;
+ ?>
+
+ 'page',
+ 'post_status' => ['publish', 'draft', 'private'],
+ 'posts_per_page' => 1,
+ 'meta_key' => '_wp_page_template',
+ 'meta_value' => FEA_SUPPORT_TEMPLATE,
+ ]);
+
+ if (!$pages) {
+ $slug_page = get_page_by_path('apoya-feadulta', OBJECT, 'page');
+ $page = $slug_page instanceof WP_Post ? $slug_page : null;
+ return $page;
+ }
+
+ $page = $pages[0];
+ return $page;
+}
+
+function fea_support_campaign_url(): string {
+ $page = fea_support_campaign_page();
+ if (!$page || $page->post_status !== 'publish') {
+ return '';
+ }
+
+ return (string) get_permalink($page);
+}
+
+function fea_support_meta(int $page_id, string $key, $default = '') {
+ $value = get_post_meta($page_id, $key, true);
+ return ($value === '' || $value === null) ? $default : $value;
+}
+
+function fea_support_campaign_data(?int $page_id = null): array {
+ $page_id = $page_id ?: (fea_support_campaign_page()?->ID ?? 0);
+ if (!$page_id) {
+ return [];
+ }
+
+ $goal = (float) fea_support_meta($page_id, 'fea_support_goal', 2000);
+ $raised = (float) fea_support_meta($page_id, 'fea_support_raised', 0);
+ $goal = $goal > 0 ? $goal : 2000;
+ $raised = max(0, $raised);
+
+ return [
+ 'page_id' => $page_id,
+ 'eyebrow' => (string) fea_support_meta($page_id, 'fea_support_eyebrow', 'Apoya Fe Adulta'),
+ 'hero_title' => (string) fea_support_meta($page_id, 'fea_support_hero_title', 'Ayúdanos a sostener la nueva web de Fe Adulta'),
+ 'hero_intro' => (string) fea_support_meta($page_id, 'fea_support_hero_intro', 'La migración de Fe Adulta ha requerido meses de trabajo y un coste aproximado de 2000€. Si puedes colaborar, por pequeña que sea la aportación, nos ayudas a sostener este esfuerzo compartido.'),
+ 'progress_note' => (string) fea_support_meta($page_id, 'fea_support_progress_note', 'Objetivo aproximado para cubrir el trabajo técnico y la migración.'),
+ 'banner_title' => (string) fea_support_meta($page_id, 'fea_support_banner_title', 'Estamos sosteniendo la nueva web entre todos'),
+ 'banner_text' => (string) fea_support_meta($page_id, 'fea_support_banner_text', 'La migración ha supuesto meses de trabajo y unos 2000€ de coste. Si puedes colaborar, nos ayudas a cuidar Fe Adulta.'),
+ 'goal' => $goal,
+ 'raised' => $raised,
+ 'stripe_url' => (string) fea_support_meta($page_id, 'fea_support_stripe_url', ''),
+ 'cajamar_url' => (string) fea_support_meta($page_id, 'fea_support_cajamar_url', ''),
+ 'paypal_url' => (string) fea_support_meta($page_id, 'fea_support_paypal_url', ''),
+ ];
+}
+
+function fea_support_amount(float $amount): string {
+ if (floor($amount) === $amount) {
+ return number_format_i18n($amount, 0) . '€';
+ }
+
+ return number_format_i18n($amount, 2) . '€';
+}
+
+function fea_support_progress_percent(array $data): float {
+ $goal = (float) ($data['goal'] ?? 0);
+ $raised = (float) ($data['raised'] ?? 0);
+
+ if ($goal <= 0) {
+ return 0;
+ }
+
+ return max(0, min(100, ($raised / $goal) * 100));
+}
+
+function fea_support_buttons_html(array $data, string $context = 'page'): string {
+ $buttons = [
+ 'stripe_url' => ['label' => 'Donar con Stripe', 'class' => 'is-primary'],
+ 'cajamar_url' => ['label' => 'Donar con Cajamar', 'class' => 'is-secondary'],
+ 'paypal_url' => ['label' => 'Donar con PayPal', 'class' => 'is-secondary'],
+ ];
+
+ $html = '';
+ foreach ($buttons as $key => $config) {
+ if (empty($data[$key])) {
+ continue;
+ }
+
+ $html .= '
'
+ . esc_html($config['label']) . ' ';
+ }
+ $html .= '
';
+
+ return $html;
+}
+
+function fea_support_progress_html(array $data, string $context = 'page'): string {
+ $percent = fea_support_progress_percent($data);
+ $summary = fea_support_amount((float) $data['raised']) . ' de ' . fea_support_amount((float) $data['goal']);
+
+ $html = '';
+ $html .= '
';
+ $html .= '' . esc_html($summary) . ' ';
+ $html .= '' . esc_html(number_format_i18n($percent, 0)) . '% ';
+ $html .= '
';
+ $html .= '
';
+ $html .= ' ';
+ $html .= '
';
+ if (!empty($data['progress_note'])) {
+ $html .= '
' . esc_html($data['progress_note']) . '
';
+ }
+ $html .= '
';
+
+ return $html;
+}
+
+function fea_support_banner_html(): string {
+ $page = fea_support_campaign_page();
+ if (!$page || $page->post_status !== 'publish') {
+ return '';
+ }
+
+ $data = fea_support_campaign_data($page->ID);
+ $url = get_permalink($page);
+
+ $html = '';
+ $html .= '';
+ $html .= '
' . esc_html($data['eyebrow']) . ' ';
+ $html .= '
' . esc_html($data['banner_title']) . ' ';
+ $html .= '
' . esc_html($data['banner_text']) . '
';
+ $html .= '
';
+ $html .= '';
+ $html .= fea_support_progress_html($data, 'banner');
+ $html .= '
';
+ $html .= '
';
+ $html .= ' ';
+
+ return $html;
+}
+
+add_filter('theme_page_templates', function(array $templates, WP_Theme $theme, ?WP_Post $post, string $post_type): array {
+ if ($post_type === 'page') {
+ $templates[FEA_SUPPORT_TEMPLATE] = FEA_SUPPORT_TEMPLATE_LABEL;
+ }
+
+ return $templates;
+}, 10, 4);
+
+add_filter('template_include', function(string $template): string {
+ if (!is_page()) {
+ return $template;
+ }
+
+ $page = get_queried_object();
+ if (!$page instanceof WP_Post) {
+ return $template;
+ }
+
+ if (get_page_template_slug($page) !== FEA_SUPPORT_TEMPLATE) {
+ return $template;
+ }
+
+ return fea_support_template_path();
+});
+
+add_action('acf/init', function() {
+ if (!function_exists('acf_add_local_field_group')) {
+ return;
+ }
+
+ acf_add_local_field_group([
+ 'key' => 'group_fea_support_campaign',
+ 'title' => 'Campaña de apoyo económico',
+ 'fields' => [
+ [
+ 'key' => 'field_fea_support_goal',
+ 'label' => 'Objetivo económico',
+ 'name' => 'fea_support_goal',
+ 'type' => 'number',
+ 'instructions' => 'Importe objetivo de la campaña.',
+ 'default_value' => 2000,
+ 'min' => 1,
+ 'step' => 1,
+ ],
+ [
+ 'key' => 'field_fea_support_raised',
+ 'label' => 'Importe recaudado',
+ 'name' => 'fea_support_raised',
+ 'type' => 'number',
+ 'instructions' => 'Cantidad actual recaudada.',
+ 'default_value' => 0,
+ 'min' => 0,
+ 'step' => 0.01,
+ ],
+ [
+ 'key' => 'field_fea_support_eyebrow',
+ 'label' => 'Antetítulo',
+ 'name' => 'fea_support_eyebrow',
+ 'type' => 'text',
+ 'default_value' => 'Apoya Fe Adulta',
+ ],
+ [
+ 'key' => 'field_fea_support_hero_title',
+ 'label' => 'Título principal',
+ 'name' => 'fea_support_hero_title',
+ 'type' => 'text',
+ 'default_value' => 'Ayúdanos a sostener la nueva web de Fe Adulta',
+ ],
+ [
+ 'key' => 'field_fea_support_hero_intro',
+ 'label' => 'Texto principal',
+ 'name' => 'fea_support_hero_intro',
+ 'type' => 'textarea',
+ 'rows' => 4,
+ 'new_lines' => 'br',
+ 'default_value' => 'La migración de Fe Adulta ha requerido meses de trabajo y un coste aproximado de 2000€. Si puedes colaborar, por pequeña que sea la aportación, nos ayudas a sostener este esfuerzo compartido.',
+ ],
+ [
+ 'key' => 'field_fea_support_progress_note',
+ 'label' => 'Nota bajo la barra',
+ 'name' => 'fea_support_progress_note',
+ 'type' => 'text',
+ 'default_value' => 'Objetivo aproximado para cubrir el trabajo técnico y la migración.',
+ ],
+ [
+ 'key' => 'field_fea_support_banner_title',
+ 'label' => 'Título del banner de portada',
+ 'name' => 'fea_support_banner_title',
+ 'type' => 'text',
+ 'default_value' => 'Estamos sosteniendo la nueva web entre todos',
+ ],
+ [
+ 'key' => 'field_fea_support_banner_text',
+ 'label' => 'Texto del banner de portada',
+ 'name' => 'fea_support_banner_text',
+ 'type' => 'textarea',
+ 'rows' => 3,
+ 'new_lines' => 'br',
+ 'default_value' => 'La migración ha supuesto meses de trabajo y unos 2000€ de coste. Si puedes colaborar, nos ayudas a cuidar Fe Adulta.',
+ ],
+ [
+ 'key' => 'field_fea_support_stripe_url',
+ 'label' => 'URL Stripe',
+ 'name' => 'fea_support_stripe_url',
+ 'type' => 'url',
+ ],
+ [
+ 'key' => 'field_fea_support_cajamar_url',
+ 'label' => 'URL TPV Cajamar',
+ 'name' => 'fea_support_cajamar_url',
+ 'type' => 'url',
+ ],
+ [
+ 'key' => 'field_fea_support_paypal_url',
+ 'label' => 'URL PayPal',
+ 'name' => 'fea_support_paypal_url',
+ 'type' => 'url',
+ ],
+ ],
+ 'location' => [[
+ ['param' => 'page_template', 'operator' => '==', 'value' => FEA_SUPPORT_TEMPLATE],
+ ]],
+ 'position' => 'normal',
+ 'style' => 'default',
+ 'label_placement' => 'top',
+ ]);
+});
+
+add_filter('the_content', function(string $content): string {
+ // DESACTIVADO temporalmente: la campaña de apoyo (Codex) aún no está lista
+ // (barra 0€/2.000€, sin enlaces de donación). No mostrar el banner en portada.
+ // Reactivar quitando este return cuando la campaña esté terminada.
+ return $content;
+
+ if (is_admin() || !is_main_query() || !in_the_loop()) {
+ return $content;
+ }
+
+ if (!function_exists('fea_is_front_page') || !fea_is_front_page()) {
+ return $content;
+ }
+
+ if (!fea_support_is_spanish_context()) {
+ return $content;
+ }
+
+ $banner = fea_support_banner_html();
+ if (!$banner) {
+ return $content;
+ }
+
+ return $content . $banner;
+}, 40);
+
+add_shortcode('fea_support_campaign_banner', function() {
+ return fea_support_banner_html();
+});
diff --git a/wordpress/wp-content/mu-plugins/fea-support-campaign/template.php b/wordpress/wp-content/mu-plugins/fea-support-campaign/template.php
new file mode 100755
index 0000000..24bb94f
--- /dev/null
+++ b/wordpress/wp-content/mu-plugins/fea-support-campaign/template.php
@@ -0,0 +1,244 @@
+
+
+
+
+
+
+
+
+ Objetivo de la campaña
+
+
+
+
+
+
+
+
+
+
+