From 69e849d38e96c05b995d976c36db34c000d77b80 Mon Sep 17 00:00:00 2001 From: rafa Date: Wed, 15 Jul 2026 20:03:21 -0400 Subject: [PATCH] Sync: guardar en el historial el trabajo de las ultimas semanas que solo vivia en el disco Este repo local tenia origin apuntando al Gitea local (localhost:3000), que Rafa declaro archivado el 2026-06-28 (commit 962f33a, desarrollo movido a gitea.feadulta.com). Ese memo nunca llego a este checkout: main quedo congelado y todo el trabajo real de las ultimas 3+ semanas se fue commiteando solo en la rama fix/multiidioma-portada-132 (ya fusionada a main sin perdida, commit 2504666), mientras que ademas se acumulaban 78 cambios sin commitear en el working tree que nunca llegaron a NINGUN historial de git. Este commit consolida esos cambios sueltos: TTS multi-voz (tts_*.py), scripts de traduccion (translate_haiku.py, pretranslate_en_haiku.py, sync_translations_to_prod.py), mu-plugins nuevos desplegados a prod (fea-beta-feedback, fea-cloudflare-realip, fea-legacy-redirect, fea-gsc-verification, fea-support-campaign, fea-ui, etc.), scripts de mantenimiento de enlaces/cartas, capturas E2E (tools/e2e/shot_*.cjs) y documentacion de sesiones recientes. Excluido deliberadamente (no es codigo versionable): tts-voices/ (3.3GB de muestras de audio para clonacion de voz, anadido a .gitignore), logs/ (logs de ejecucion, anadido a .gitignore), y 2 ficheros vacios accidentales + 2 copias duplicadas sueltas en la raiz que ya existen en su ubicacion correcta. --- .gitignore | 7 + README.md | 20 +- backups/README.md | 152 ++---- docker-compose.yml | 1 + docs/guia-publicacion-carta-inma.md | 168 +++++-- docs/guia-tts-traduccion-inma.md | 121 +++++ ...-tecnico-rafa-issues-174-175-2026-07-15.md | 180 +++++++ docs/revision-issues-2026-07-15.md | 94 ++++ docs/revision-issues-live-gitea-2026-07-15.md | 155 ++++++ scripts/carta-semana-plugin.php | 69 ++- scripts/create_lecturas.php | 25 +- scripts/demote_old_cartasemana.php | 44 +- scripts/detect_untranslated.php | 77 +++ scripts/fix_carta_content_links.php | 65 +++ scripts/fix_carta_joomla_links.php | 2 +- scripts/fix_carta_links.php | 104 ++++ scripts/fix_k2_authors.php | 2 +- scripts/gen_avatars_81b.py | 33 ++ scripts/import_avatars_143.php | 2 +- scripts/import_avatars_75.php | 59 +++ scripts/import_avatars_90.php | 55 +++ scripts/import_new_cartas.py | 34 +- scripts/import_new_content.py | 9 +- scripts/import_new_k2_items.py | 21 +- scripts/pretranslate_en_haiku.py | 125 +++++ scripts/prettify_carta_links.php | 44 ++ scripts/publish_carta.php | 4 +- scripts/remap_carta_tr_links.php | 40 ++ scripts/remap_translation_cats.php | 42 ++ scripts/repoint_carta_links.php | 2 +- scripts/reprocess_en_haiku.py | 128 +++++ scripts/rotate_cartas.php | 83 ++++ scripts/sync_translations_to_prod.py | 325 +++++++++++++ scripts/translate_gap.sh | 76 +++ scripts/translate_haiku.py | 87 ++++ scripts/translate_lectura_titles.php | 2 +- scripts/translate_post.py | 44 +- scripts/tts_carta.py | 99 ++++ scripts/tts_carta_edge.py | 51 ++ scripts/tts_eval.py | 82 ++++ scripts/tts_kokoro.py | 41 ++ scripts/tts_xtts.py | 72 +++ scripts/unpublish_date_slug_posts.php | 153 ++++++ tools/e2e/shot_avatars.cjs | 19 + tools/e2e/shot_colab.cjs | 11 + tools/e2e/shot_eed.cjs | 21 + tools/e2e/shot_en.cjs | 10 + tools/e2e/shot_grid.cjs | 12 + tools/e2e/shot_lecturas.cjs | 48 ++ tools/e2e/shot_led.cjs | 10 + tools/e2e/shot_numeros.cjs | 33 ++ tools/e2e/shot_one.cjs | 29 ++ tools/e2e/shot_portada_link.cjs | 15 + tools/e2e/shot_tablon.cjs | 14 + .../mu-plugins/carta-semana-plugin.php | 63 ++- .../mu-plugins/fea-avatar-cachebust.php | 17 + .../mu-plugins/fea-beta-feedback.php | 291 ++++++++++++ .../mu-plugins/fea-carta-id-api.php | 142 ++++++ .../mu-plugins/fea-carta-portada.php | 18 +- .../mu-plugins/fea-cloudflare-realip.php | 76 +++ .../mu-plugins/fea-compact-entry-spacing.php | 67 +++ .../mu-plugins/fea-disable-comments.php | 29 ++ .../mu-plugins/fea-gsc-verification.php | 13 + .../mu-plugins/fea-hide-bad-tag.php | 66 +++ .../wp-content/mu-plugins/fea-homepage.php | 41 ++ .../mu-plugins/fea-legacy-redirect.php | 24 + .../wp-content/mu-plugins/fea-menu-i18n.php | 159 +++++++ .../mu-plugins/fea-pensamientos.php | 448 ++++++++++++++++++ .../mu-plugins/fea-recopilatorios.php | 277 +++++++++++ .../mu-plugins/fea-support-campaign.php | 336 +++++++++++++ .../fea-support-campaign/template.php | 244 ++++++++++ wordpress/wp-content/mu-plugins/fea-ui.php | 101 ++++ 72 files changed, 5413 insertions(+), 220 deletions(-) create mode 100644 docs/guia-tts-traduccion-inma.md create mode 100644 docs/handoff-tecnico-rafa-issues-174-175-2026-07-15.md create mode 100644 docs/revision-issues-2026-07-15.md create mode 100644 docs/revision-issues-live-gitea-2026-07-15.md mode change 100644 => 100755 scripts/carta-semana-plugin.php mode change 100644 => 100755 scripts/demote_old_cartasemana.php create mode 100644 scripts/detect_untranslated.php create mode 100644 scripts/fix_carta_content_links.php create mode 100644 scripts/fix_carta_links.php create mode 100644 scripts/gen_avatars_81b.py create mode 100644 scripts/import_avatars_75.php create mode 100644 scripts/import_avatars_90.php create mode 100644 scripts/pretranslate_en_haiku.py create mode 100644 scripts/prettify_carta_links.php create mode 100644 scripts/remap_carta_tr_links.php create mode 100644 scripts/remap_translation_cats.php create mode 100644 scripts/reprocess_en_haiku.py create mode 100644 scripts/rotate_cartas.php create mode 100644 scripts/sync_translations_to_prod.py create mode 100755 scripts/translate_gap.sh create mode 100644 scripts/translate_haiku.py create mode 100644 scripts/tts_carta.py create mode 100644 scripts/tts_carta_edge.py create mode 100644 scripts/tts_eval.py create mode 100644 scripts/tts_kokoro.py create mode 100644 scripts/tts_xtts.py create mode 100644 scripts/unpublish_date_slug_posts.php create mode 100644 tools/e2e/shot_avatars.cjs create mode 100644 tools/e2e/shot_colab.cjs create mode 100644 tools/e2e/shot_eed.cjs create mode 100644 tools/e2e/shot_en.cjs create mode 100644 tools/e2e/shot_grid.cjs create mode 100644 tools/e2e/shot_lecturas.cjs create mode 100644 tools/e2e/shot_led.cjs create mode 100644 tools/e2e/shot_numeros.cjs create mode 100644 tools/e2e/shot_one.cjs create mode 100644 tools/e2e/shot_portada_link.cjs create mode 100644 tools/e2e/shot_tablon.cjs mode change 100644 => 100755 wordpress/wp-content/mu-plugins/carta-semana-plugin.php create mode 100644 wordpress/wp-content/mu-plugins/fea-avatar-cachebust.php create mode 100644 wordpress/wp-content/mu-plugins/fea-beta-feedback.php create mode 100644 wordpress/wp-content/mu-plugins/fea-carta-id-api.php create mode 100644 wordpress/wp-content/mu-plugins/fea-cloudflare-realip.php create mode 100644 wordpress/wp-content/mu-plugins/fea-compact-entry-spacing.php create mode 100644 wordpress/wp-content/mu-plugins/fea-disable-comments.php create mode 100644 wordpress/wp-content/mu-plugins/fea-gsc-verification.php create mode 100644 wordpress/wp-content/mu-plugins/fea-hide-bad-tag.php create mode 100644 wordpress/wp-content/mu-plugins/fea-legacy-redirect.php create mode 100644 wordpress/wp-content/mu-plugins/fea-menu-i18n.php create mode 100644 wordpress/wp-content/mu-plugins/fea-pensamientos.php create mode 100644 wordpress/wp-content/mu-plugins/fea-recopilatorios.php create mode 100755 wordpress/wp-content/mu-plugins/fea-support-campaign.php create mode 100755 wordpress/wp-content/mu-plugins/fea-support-campaign/template.php create mode 100644 wordpress/wp-content/mu-plugins/fea-ui.php 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 ''; + } + + $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 = ''; + 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 .= ''; + } + $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 = '
'; + while ($q->have_posts()) { + $q->the_post(); + $content = get_the_content(); + $prev = fea_mm_preview($content); + $title = fea_recop_title(get_the_title()); + $url = get_permalink(); + $excerpt = wp_trim_words(trim(preg_replace('/\s+/', ' ', wp_strip_all_tags($content))), 22, '…'); + + $thumb = ''; + if ($prev['src'] !== '') { + $thumb = ''; + } + $cls = 'fea-mm-thumb' . ($prev['src'] === '' ? ' fea-mm-noimg' : ''); + $play = $prev['type'] === 'video' + ? '' : ''; + + $html .= '' + . '' . $thumb . $play . '' + . '' + . '' . esc_html($title) . '' + . '' . esc_html(get_the_date('j M Y')) . '' + . '' . esc_html($excerpt) . '' + . ''; + } + $html .= '
'; + + $total_pages = (int) $q->max_num_pages; + wp_reset_postdata(); + if ($total_pages > 1) { + $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 .= ''; + 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 @@ + + + +
+
+
+ +

+

+
+ + +
+ +
+
+ +
+ + +
+
+ + + +