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.
This commit is contained in:
@@ -40,6 +40,13 @@ __pycache__/
|
|||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.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 Code local settings
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
@@ -2,18 +2,18 @@
|
|||||||
|
|
||||||
Working tree del proyecto feadulta.org. WordPress nuevo, scripts de migración K2 → WP, mu-plugins custom.
|
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
|
## Enlaces rápidos
|
||||||
|
|
||||||
- [Wiki — Home](http://localhost:3000/rafa/feadulta/wiki)
|
- [Wiki — Home](https://gitea.feadulta.com/rafa/feadulta/wiki)
|
||||||
- [Credenciales y accesos](http://localhost:3000/rafa/feadulta/wiki/Credenciales-y-accesos)
|
- [Credenciales y accesos](https://gitea.feadulta.com/rafa/feadulta/wiki/Credenciales-y-accesos)
|
||||||
- [Infraestructura](http://localhost:3000/rafa/feadulta/wiki/Infraestructura)
|
- [Infraestructura](https://gitea.feadulta.com/rafa/feadulta/wiki/Infraestructura)
|
||||||
- [Arquitectura WordPress](http://localhost:3000/rafa/feadulta/wiki/Arquitectura-WordPress)
|
- [Arquitectura WordPress](https://gitea.feadulta.com/rafa/feadulta/wiki/Arquitectura-WordPress)
|
||||||
- [Sincronización local → producción](http://localhost:3000/rafa/feadulta/wiki/Sincronizacion-local-prod)
|
- [Sincronización local → producción](https://gitea.feadulta.com/rafa/feadulta/wiki/Sincronizacion-local-prod)
|
||||||
- [Limitaciones del servidor de producción](http://localhost:3000/rafa/feadulta/wiki/Limitaciones-servidor-prod)
|
- [Limitaciones del servidor de producción](https://gitea.feadulta.com/rafa/feadulta/wiki/Limitaciones-servidor-prod)
|
||||||
- [Roadmap](http://localhost:3000/rafa/feadulta/wiki/Roadmap)
|
- [Roadmap](https://gitea.feadulta.com/rafa/feadulta/wiki/Roadmap)
|
||||||
- [Issues](http://localhost:3000/rafa/feadulta/issues)
|
- [Issues](https://gitea.feadulta.com/rafa/feadulta/issues)
|
||||||
|
|
||||||
## Runbooks locales
|
## Runbooks locales
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ Working tree del proyecto feadulta.org. WordPress nuevo, scripts de migración K
|
|||||||
├── tools/
|
├── tools/
|
||||||
│ ├── e2e/ Suite Playwright + Gemma vision
|
│ ├── e2e/ Suite Playwright + Gemma vision
|
||||||
│ └── akeeba-kickstart/ Kickstart.php para restaurar .jpa
|
│ └── 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)
|
├── archive/ Scripts setup-inicial y logs migración (gitignored)
|
||||||
├── analisis-cartas/ Histórico de cartas semanales
|
├── analisis-cartas/ Histórico de cartas semanales
|
||||||
├── evangelios_html/ HTML de los evangelios
|
├── evangelios_html/ HTML de los evangelios
|
||||||
|
|||||||
+51
-101
@@ -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'<pass>' --skip-ssl --single-transaction --quick --default-character-set=utf8mb4
|
||||||
|
<DB_NAME>"`. `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).
|
||||||
|
|
||||||
```
|
## local-pre-mirror/
|
||||||
backups/
|
Backup de seguridad de la BD local **antes** de sobrescribirla con un import de prod (por si el
|
||||||
├── feadulta-20260111-pre-incidente/ 2.8 G Akeeba Enero (último limpio antes del malware)
|
import sale mal o hacía falta algo que solo estaba en local). `mysqldump` dentro del contenedor:
|
||||||
├── feadulta-20260306-akeeba/ 2.8 G Akeeba Marzo (referencia migración)
|
`docker exec wordpress-mysql sh -c 'exec mysqldump -uroot -pwordpress_root_pass wordpress_db'`.
|
||||||
├── 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
|
|
||||||
|
|
||||||
|
## 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
|
```bash
|
||||||
cd /home/rafa/joomla-migration/backups
|
docker exec -u 0 wordpress-web bash -c "apt-get update -qq && apt-get install -y -qq rsync openssh-client sshpass"
|
||||||
md5sum -c <<'EOF'
|
docker exec -u 0 -e SSHPASS='<pass>' wordpress-web bash -c "
|
||||||
43ff49190faecd5c49f6668d7ec3b818 feadulta-20260111-pre-incidente/site-www.feadulta.com-20260111-201614-DP_213gSrB86EMc_.j01
|
sshpass -e rsync -e 'ssh -o StrictHostKeyChecking=accept-new' -avz \
|
||||||
0b41e9559e064c211ba52f7d5bb5fc80 feadulta-20260111-pre-incidente/site-www.feadulta.com-20260111-201614-DP_213gSrB86EMc_.jpa
|
feadulta@134.0.10.170:/web/wp-nuevo/wp-content/uploads/ \
|
||||||
4859ea1e8f707a96ab553830f5123146 feadulta-20260306-akeeba/site-www.feadulta.com-20260306-011457-1os52_Qdg5-vCycH.j01
|
/var/www/html/wp-content/uploads/"
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ services:
|
|||||||
- "8081:80"
|
- "8081:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./wordpress:/var/www/html
|
- ./wordpress:/var/www/html
|
||||||
|
- ./joomla/images:/var/www/joomla-images:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- wordpress-db
|
- wordpress-db
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -19,11 +19,13 @@
|
|||||||
|
|
||||||
| Entorno | URL | Estado |
|
| Entorno | URL | Estado |
|
||||||
|---------|-----|--------|
|
|---------|-----|--------|
|
||||||
| **Beta (actual)** | `https://wp-nuevo.feadulta.com` | Es donde se trabaja **ahora** |
|
| **Producción** | `https://www.feadulta.com` | El sitio en vivo, WordPress. Cutover completado el 2026-07-08 |
|
||||||
| Producción final | `https://feadulta.com` | Tras el "cutover" de DNS (cambiará la URL base) |
|
| 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
|
**El cutover de dominio ya se hizo (2026-07-08):** `www.feadulta.com` es WordPress y es donde
|
||||||
dominio, solo hay que sustituir la URL base en los ejemplos de abajo.
|
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
|
### 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):**
|
**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,
|
Inma **ya tiene usuario con rol Editor** en el sitio (suficiente para crear, editar,
|
||||||
publicar y programar entradas). Con ese usuario:
|
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"**.
|
2. Ir a **Usuarios → Perfil** → bajar hasta **"Contraseñas de aplicación"**.
|
||||||
3. Escribir un nombre (p. ej. `cowork-cartas`) y pulsar **Añadir**.
|
3. Escribir un nombre (p. ej. `cowork-cartas`) y pulsar **Añadir**.
|
||||||
4. WordPress muestra una contraseña de 24 caracteres con espacios
|
4. WordPress muestra una contraseña de 24 caracteres con espacios
|
||||||
@@ -45,10 +47,10 @@ usuario):
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -u "USUARIO:xxxx xxxx xxxx xxxx xxxx xxxx" \
|
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
|
> **⚠️ 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
|
> 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 |
|
| ID | Nombre | Slug | Significado |
|
||||||
|----|--------|------|-------------|
|
|----|--------|------|-------------|
|
||||||
| **6** | Carta de la semana | `cartasemana` | La carta **vigente**. Debe estar solo la actual. |
|
| **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. |
|
| **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 (todas las anteriores). |
|
| **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)
|
### 3.2 Categorías temáticas (para los artículos de dentro de la carta)
|
||||||
|
|
||||||
@@ -128,7 +130,7 @@ estén listos, publicarlos.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -u "USUARIO:APP_PASSWORD" \
|
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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"title": "Título del artículo",
|
"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).
|
Repetir para cada artículo, usando la categoría temática que corresponda (§3.2).
|
||||||
|
|
||||||
|
> ⚠️ **No adivinéis la URL a partir del título — usad siempre el `link` real de la API,
|
||||||
|
> y solo DESPUÉS de publicar.** En la carta 734 los artículos se quedaron en `draft` con el
|
||||||
|
> título prefijado `[PRUEBA]`, y la carta se compuso enlazando slugs *adivinados* a mano
|
||||||
|
> (`.../prueba-silencio/`, etc.) a partir de ese título provisional. Al publicar de verdad
|
||||||
|
> con el título limpio, WordPress genera el slug desde el título final y **le añade un
|
||||||
|
> sufijo (`-2`, `-4`…) si ya existe otro post con ese mismo slug** — algo frecuente en
|
||||||
|
> temas recurrentes ("Silencio", "La palabra", "Te encontré"…). Resultado: 21 de 22
|
||||||
|
> enlaces de la carta apuntaban a una URL que nunca existió. Regla: **publicad cada
|
||||||
|
> artículo (quitando `[PRUEBA]` del título) en cuanto esté listo**, y componed los
|
||||||
|
> enlaces de la carta con el `link` que devuelve la API **tras ese publish**, no antes.
|
||||||
|
|
||||||
|
### Paso 1.5 — Asignar `_carta_id` a cada artículo de la semana (IMPORTANTE)
|
||||||
|
|
||||||
|
Cada artículo de la semana debe llevar el meta `_carta_id` = ID del post de la carta a la
|
||||||
|
que pertenece. **Sin esto, el sistema no puede localizar "todos los artículos de esta
|
||||||
|
carta" para publicarlos/traducirlos/locutarlos en bloque.**
|
||||||
|
|
||||||
|
`_carta_id` es un meta interno y **no se puede asignar por el endpoint estándar**
|
||||||
|
`/wp/v2/posts/<id>` (WordPress lo bloquea por empezar por `_`). Para esto hay un
|
||||||
|
**endpoint REST propio**, desplegado el 2026-07-07:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Leer el _carta_id actual de un artículo
|
||||||
|
curl -s -u "USUARIO:APP_PASSWORD" \
|
||||||
|
https://www.feadulta.com/wp-json/fea/v1/carta-id/ID_DEL_ARTICULO
|
||||||
|
|
||||||
|
# Asignar (o corregir) el _carta_id de un artículo
|
||||||
|
curl -s -u "USUARIO:APP_PASSWORD" \
|
||||||
|
-X POST https://www.feadulta.com/wp-json/fea/v1/carta-id/ID_DEL_ARTICULO \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"carta_id": ID_DE_LA_CARTA}'
|
||||||
|
|
||||||
|
# Borrar el _carta_id (si os habéis equivocado de artículo)
|
||||||
|
curl -s -u "USUARIO:APP_PASSWORD" \
|
||||||
|
-X DELETE https://www.feadulta.com/wp-json/fea/v1/carta-id/ID_DEL_ARTICULO
|
||||||
|
```
|
||||||
|
|
||||||
|
Respuesta en los tres casos: `{"post_id": ID, "carta_id": ID|null}`.
|
||||||
|
|
||||||
|
Notas:
|
||||||
|
- Solo podéis asignar `_carta_id` a artículos **que ya podéis editar** (mismo criterio que
|
||||||
|
el resto de la API: vuestro usuario Editor).
|
||||||
|
- `carta_id` tiene que ser el ID de **un post que exista** (normalmente el de la carta,
|
||||||
|
aunque en el momento de llamar a este endpoint la carta puede que aún esté en borrador —
|
||||||
|
eso no importa, solo tiene que existir el post).
|
||||||
|
- Podéis llamarlo justo después de crear cada artículo (Paso 1) o al final, después de tener
|
||||||
|
el ID de la carta (Paso 3) — el orden entre pasos 1 y 1.5 y 3 no importa mientras al acabar
|
||||||
|
todos los artículos de la semana tengan el `_carta_id` correcto.
|
||||||
|
- Código fuente: `wp-content/mu-plugins/fea-carta-id-api.php` (mu-plugin, activo siempre).
|
||||||
|
|
||||||
### Paso 2 — Componer el artículo-carta
|
### Paso 2 — Componer el artículo-carta
|
||||||
|
|
||||||
La carta es un post HTML con:
|
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
|
### 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"`.
|
- **Publicar ya:** `"status": "publish"`.
|
||||||
- **Programar:** `"status": "future"` + `"date"` con la fecha/hora local del sitio en
|
- **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
|
```bash
|
||||||
curl -s -u "USUARIO:APP_PASSWORD" \
|
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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"title": "Carta de la semana — 29 de junio",
|
"title": "Carta de la semana — 29 de junio",
|
||||||
"content": "<p>…cuerpo de la carta con sus secciones y enlaces…</p>",
|
"content": "<p>…cuerpo de la carta con sus secciones y enlaces…</p>",
|
||||||
"status": "future",
|
"status": "future",
|
||||||
"date": "2026-06-29T08:00:00",
|
"date": "2026-06-29T08:00:00",
|
||||||
"categories": [6]
|
"categories": [6, 21]
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Paso 4 — Rotar la carta anterior (IMPORTANTE, manual)
|
### 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
|
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**
|
1. A la carta que **dejaba** de ser actual: quitarle la categoría **6** y ponerle la **22**
|
||||||
("La semana pasada").
|
("La semana pasada"). Mantiene la **21** que ya tenía.
|
||||||
2. A la que estaba en **22**: pasarla a la **21** ("Cartas de otras semanas").
|
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
|
```bash
|
||||||
|
# 1. La carta que deja de ser actual: 6 → 22 (conserva 21)
|
||||||
curl -s -u "USUARIO:APP_PASSWORD" \
|
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" \
|
-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
|
> 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:
|
> 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).
|
> `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
|
- 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.
|
(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.
|
- 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:**
|
**Ejemplo mínimo de cuerpo de carta:**
|
||||||
|
|
||||||
@@ -228,23 +306,23 @@ de portada indicado:
|
|||||||
|
|
||||||
<p><strong style="color:red">Evangelio y comentarios al Evangelio</strong></p>
|
<p><strong style="color:red">Evangelio y comentarios al Evangelio</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="https://wp-nuevo.feadulta.com/comentario-evangelio-domingo/">Comentario al evangelio</a></li>
|
<li><a href="https://www.feadulta.com/comentario-evangelio-domingo/">Comentario al evangelio</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p><strong style="color:red">Artículos seleccionados para la semana</strong></p>
|
<p><strong style="color:red">Artículos seleccionados para la semana</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="https://wp-nuevo.feadulta.com/articulo-uno/">Primer artículo</a></li>
|
<li><a href="https://www.feadulta.com/articulo-uno/">Primer artículo</a></li>
|
||||||
<li><a href="https://wp-nuevo.feadulta.com/articulo-dos/">Segundo artículo</a></li>
|
<li><a href="https://www.feadulta.com/articulo-dos/">Segundo artículo</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p><strong style="color:red">Para unas eucaristías más participativas y actuales</strong></p>
|
<p><strong style="color:red">Para unas eucaristías más participativas y actuales</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="https://wp-nuevo.feadulta.com/eucaristia-domingo/">Material para la eucaristía</a></li>
|
<li><a href="https://www.feadulta.com/eucaristia-domingo/">Material para la eucaristía</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p><strong style="color:red">Material multimedia</strong></p>
|
<p><strong style="color:red">Material multimedia</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="https://wp-nuevo.feadulta.com/video-semana/">Vídeo de la semana</a></li>
|
<li><a href="https://www.feadulta.com/video-semana/">Vídeo de la semana</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -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
|
**Corregido 2026-07-12: esto todavía NO es automático.** El cron que traduciría y generaría
|
||||||
programado (cron) que:
|
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.
|
**Cómo pedirlo hoy:** escribir al grupo de WhatsApp mencionando "Hermes" (o por Telegram),
|
||||||
2. Los **traduce** a EN/FR/IT/PT y los enlaza como traducciones (Polylang).
|
indicando la carta/artículo. Hermes ejecuta los scripts correspondientes en el servidor de Rafa.
|
||||||
3. Genera el **audio TTS** (voz, MiniMax) de la carta y sus artículos.
|
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
|
**Por eso sigue siendo importante subir siempre el contenido en español** — la traducción parte
|
||||||
el resto. Si una carta urgente necesita traducción inmediata, avisar a Rafa.
|
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
|
> *Este punto es responsabilidad de Rafa (infraestructura). Se incluye aquí para que el
|
||||||
> asistente de Inma sepa que no debe duplicar ese trabajo.*
|
> 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)
|
## 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.
|
- [ ] 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**.
|
- [ ] Publicar o programar la carta en las categorías **6 y 21** (§3.1, §4 Paso 3).
|
||||||
- [ ] Rotar la carta anterior: 6 → 22, y la de 22 → 21.
|
- [ ] 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).
|
- [ ] 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}` |
|
| Editar artículo/carta | `POST /wp-json/wp/v2/posts/{id}` |
|
||||||
| Ver carta vigente | `GET /wp-json/wp/v2/posts?categories=6` |
|
| Ver carta vigente | `GET /wp-json/wp/v2/posts?categories=6` |
|
||||||
| Subir imagen | `POST /wp-json/wp/v2/media` (cabecera `Content-Disposition`) |
|
| 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`),
|
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).
|
`date` (ISO 8601 para programar), `categories` (array de IDs), `featured_media` (ID de adjunto).
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Guía de traducción y audio (TTS) para Inma / Mixbot — feadulta.com
|
||||||
|
|
||||||
|
> **Para quién es este documento:** para Inma y su asistente (Mixbot / Cowork), y como
|
||||||
|
> referencia para Hermes cuando se le pide que traduzca o locute un artículo/carta.
|
||||||
|
>
|
||||||
|
> **Estado real a 2026-07-12 (importante, corrige la guía de publicación §7 de versiones
|
||||||
|
> anteriores): esto NO es automático.** No hay ningún cron corriendo hoy que traduzca o genere
|
||||||
|
> audio solo al publicar — esa automatización es la propuesta abierta
|
||||||
|
> [issue #23](https://gitea.feadulta.com/rafa/feadulta/issues/23), sin implementar. Todo lo de
|
||||||
|
> abajo es un proceso que **hay que pedir**, hoy solo ejecutable en el servidor/PC de Rafa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Quién puede hacer qué, hoy
|
||||||
|
|
||||||
|
| Tarea | ¿Quién puede hacerla sin Rafa presente? |
|
||||||
|
|---|---|
|
||||||
|
| Publicar carta/artículos en español | **Sí, Inma/Mixbot solos** — API REST ya funciona (ver `docs/guia-publicacion-carta-inma.md`). No depende del PC de Rafa. |
|
||||||
|
| Pedir traducción o TTS | **Solo indirectamente**: hay que pedírselo a **Hermes** (WhatsApp/Telegram). Los scripts que traducen y locutan viven únicamente en el PC/servidor de Rafa (Docker local + credenciales locales) — Mixbot no tiene acceso directo a ellos. |
|
||||||
|
| Traducir/locutar si Hermes tampoco está disponible | **Hoy, no.** Es la limitación real que hay que conocer: si el PC de Rafa está apagado o Hermes está caído, ni Inma ni Mixbot pueden disparar esto por su cuenta. Ver §5 (qué falta para que esto no dependa de Hermes). |
|
||||||
|
|
||||||
|
## 2. Cómo pedir una traducción o un audio (mientras Hermes esté disponible)
|
||||||
|
|
||||||
|
Escribir al grupo de WhatsApp `Feadulta_webmaster` mencionando "Hermes" (o por Telegram),
|
||||||
|
indicando qué carta/artículo (ID de WordPress o título+fecha si no se tiene el ID) y qué se
|
||||||
|
necesita: traducción, audio, o ambos. Ejemplos:
|
||||||
|
|
||||||
|
> "Hermes, tradúceme la carta 54XXX a los 4 idiomas"
|
||||||
|
> "Hermes, genera el audio de los artículos de la carta de esta semana"
|
||||||
|
|
||||||
|
Hermes ejecuta los scripts de abajo en el servidor de Rafa. No hace falta que Inma/Mixbot sepan
|
||||||
|
los nombres de los scripts ni los IDs internos — es información para cuando Hermes (o Rafa)
|
||||||
|
necesite el detalle técnico.
|
||||||
|
|
||||||
|
## 3. Traducción — motores disponibles
|
||||||
|
|
||||||
|
`scripts/translate_post.py` (repo `joomla-migration`) soporta tres motores via `FEA_ENGINE`:
|
||||||
|
|
||||||
|
| Motor | Coste | Cuándo usarlo |
|
||||||
|
|---|---|---|
|
||||||
|
| **`gemma`** (por defecto) | Gratis (modelo local, LM Studio en el PC de Rafa) | Opción por defecto. Requiere que el PC/GPU de Rafa esté encendido. |
|
||||||
|
| **`minimax`** | De pago, acotado (misma cuenta que el TTS) | Alternativa cuando Gemma no está disponible o la calidad no basta. |
|
||||||
|
| **`haiku`** | ⚠️ **De pago vía API directa de Anthropic** (no es cuota de sesión) | **No usar por defecto ni de forma autónoma.** Choca con la política de no gastar API de pago sin que Rafa confirme cada vez. Reservado para cuando Rafa lo ejecuta él mismo o da autorización puntual. |
|
||||||
|
|
||||||
|
Comando (lo ejecuta Hermes o Rafa, no Inma/Mixbot directamente):
|
||||||
|
```bash
|
||||||
|
cd /home/rafa/joomla-migration
|
||||||
|
python3 scripts/translate_post.py --carta <ID_CARTA> --langs en,fr,it,pt --status draft
|
||||||
|
# o para un solo artículo:
|
||||||
|
python3 scripts/translate_post.py --post-id <ID_POST> --langs en,fr,it,pt --status draft
|
||||||
|
```
|
||||||
|
`--status draft` dejar en borrador para revisión; `--status publish` publica directo. Tras
|
||||||
|
traducir, hace falta el paso de enlaces internos (`scripts/fix_carta_joomla_links.php`) y, si se
|
||||||
|
publica, degradar la carta anterior (`scripts/demote_old_cartasemana.php`) — Hermes ya conoce
|
||||||
|
este flujo (ver skill `feadulta-webmaster`, `references/procedures.md`).
|
||||||
|
|
||||||
|
## 4. Audio (TTS) — voces por autor
|
||||||
|
|
||||||
|
`scripts/minimax_tts.py` + `scripts/tts_produce.py` (genera y escribe en WP local) +
|
||||||
|
`scripts/sync_audio_to_prod.py` (sube a prod, soporta `--rollback` para deshacer). Modelo MiniMax
|
||||||
|
`speech-2.8-hd`.
|
||||||
|
|
||||||
|
**Voz por defecto:** `NicoFeadulta2026` (todos los autores sin voz clonada).
|
||||||
|
|
||||||
|
**Voces clonadas por autor** (issue #152 — solo estos 4 autores usan su propia voz, el resto cae
|
||||||
|
a Nico):
|
||||||
|
|
||||||
|
| Autor | WP user_id | voice_id |
|
||||||
|
|---|---|---|
|
||||||
|
| Fray Marcos | 382 | `FrayMarcosFeadulta2026` |
|
||||||
|
| José Antonio Pagola | 383 | `PagolaFeadulta2026` |
|
||||||
|
| José Luis Sicre | 774 | `SicreFeadulta2026` |
|
||||||
|
| José Arregi | 386 | `ArregiFeadulta2026` |
|
||||||
|
|
||||||
|
Añadir un autor nuevo a esta lista requiere clonar su voz primero (grabación limpia 2-5 min, sin
|
||||||
|
música/ruido de fondo — verificar con espectrograma antes de clonar, ver memoria
|
||||||
|
`feadulta-tts-voz-fraymarcos-202607` para el procedimiento y los descartes por música colada) y
|
||||||
|
añadirlo a `AUTHOR_VOICES` en `scripts/minimax_tts.py`. Esto sí requiere que Rafa (o alguien con
|
||||||
|
acceso al repo y a MiniMax) lo haga — no es autoservicio para Inma/Mixbot hoy.
|
||||||
|
|
||||||
|
**Generar audio de una carta concreta** (por defecto `tts_produce.py` procesa una cola larga de
|
||||||
|
cartas pendientes — para priorizar una carta concreta, sobreescribir la cola):
|
||||||
|
```bash
|
||||||
|
cd /home/rafa/joomla-migration
|
||||||
|
FEA_TTS_CARTAS="<ID_CARTA>" python3 scripts/tts_produce.py
|
||||||
|
```
|
||||||
|
Reanudable (no repite lo ya hecho, meta `fea_audio_done`) y con freno automático si la cuota de
|
||||||
|
MiniMax se agota (para tras fallos seguidos, no se queda colgado).
|
||||||
|
|
||||||
|
**Publicar el audio en prod** (el paso anterior solo escribe en el WordPress local):
|
||||||
|
```bash
|
||||||
|
python3 scripts/sync_audio_to_prod.py --carta <ID_CARTA>
|
||||||
|
# deshacer si algo suena mal:
|
||||||
|
python3 scripts/sync_audio_to_prod.py --rollback --carta <ID_CARTA>
|
||||||
|
```
|
||||||
|
Runbook de rollback ya documentado para que Hermes lo ejecute sin Rafa presente: issue #163.
|
||||||
|
|
||||||
|
## 5. La API key de MiniMax — decisión pendiente (de Rafa, no resuelta en este documento)
|
||||||
|
|
||||||
|
Hoy la key vive en un fichero local de Rafa, usada para TTS (y podría usarse para traducción
|
||||||
|
`FEA_ENGINE=minimax`). Para que Inma/Mixbot puedan disparar esto sin pasar por Hermes, harían
|
||||||
|
falta tanto acceso a esta key como acceso al entorno donde corren los scripts (Docker local del
|
||||||
|
PC de Rafa) — hoy ninguna de las dos cosas es cierta. Ver el issue maestro
|
||||||
|
[#172](https://gitea.feadulta.com/rafa/feadulta/issues/172) para las opciones que se están
|
||||||
|
valorando (key separada para Inma, gestor de secretos compartido, o mantener todo detrás de
|
||||||
|
Hermes). Mientras no se decida, la vía real es §2: pedírselo a Hermes.
|
||||||
|
|
||||||
|
## 6. Qué falta para que esto sea de verdad independiente de Hermes/Rafa
|
||||||
|
|
||||||
|
Siendo honestos: hoy, si el PC de Rafa está apagado (viaje, avería, lo que sea) y Hermes no
|
||||||
|
responde, **no hay forma de que Inma/Mixbot generen traducción o audio por su cuenta** — los
|
||||||
|
scripts y el WordPress local que usan como paso intermedio solo existen ahí. Para que esto
|
||||||
|
cambiara de verdad haría falta uno de:
|
||||||
|
- Mover el pipeline de traducción/TTS a un sitio alcanzable por Mixbot directamente (ej. correr
|
||||||
|
contra prod en vez de contra el WordPress local, y alojar los scripts en un servidor
|
||||||
|
accesible, no en el PC personal de Rafa).
|
||||||
|
- O implementar de una vez el cron automático (issue #23) para que ni siquiera haga falta
|
||||||
|
pedirlo — se dispara solo al publicar en español.
|
||||||
|
|
||||||
|
Ninguna de las dos está hecha. Documentado aquí para que la decisión de priorizarlo (o no) sea
|
||||||
|
consciente, no un descuido.
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# Handoff técnico para Rafa — issues #174 y #175
|
||||||
|
|
||||||
|
## Alcance
|
||||||
|
Preparación técnica solamente. **No aplicar en producción desde aquí.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue #174 — cierre autónomo de la carta (publicar + rotar)
|
||||||
|
|
||||||
|
### Regla funcional confirmada por Inma/Mixbot
|
||||||
|
La rotación que ya funcionó hoy manualmente por WP REST API para la carta 735 es esta, y **solo para el post ES**:
|
||||||
|
|
||||||
|
1. **nueva carta** → añadir cat **6** (`carta actual`)
|
||||||
|
2. **la que estaba en cat 6** → quitar cat 6, añadir cat **22** (`semana pasada`), manteniendo cat **21** (`archivo acumulativo`)
|
||||||
|
3. **la que estaba en cat 22** → quitar cat 22, dejar solo cat **21**
|
||||||
|
4. **no tocar traducciones** en este paso
|
||||||
|
|
||||||
|
### Decisión pendiente para Rafa
|
||||||
|
Hay dos caminos válidos:
|
||||||
|
|
||||||
|
#### Opción A — adaptar `rotate_cartas.php` a ES-only
|
||||||
|
Pros:
|
||||||
|
- reutiliza lógica ya existente y conocida en el repo
|
||||||
|
- más coherente con el toolkit actual de scripts
|
||||||
|
- fácil dejar `DRY-RUN` + `APPLY=1`
|
||||||
|
|
||||||
|
Contras:
|
||||||
|
- hay que modificar el script actual porque hoy rota todos los idiomas
|
||||||
|
- si Mixbot publica por REST y la rotación la hace otro script aparte, el flujo queda repartido en dos piezas
|
||||||
|
|
||||||
|
#### Opción B — que Mixbot haga la rotación por WP REST API
|
||||||
|
Pros:
|
||||||
|
- ya está validado en vivo hoy
|
||||||
|
- mantiene toda la operación editorial en el mismo sitio donde ya se publica la carta
|
||||||
|
- evita acoplar la decisión editorial a un script server-side adicional
|
||||||
|
|
||||||
|
Contras:
|
||||||
|
- la lógica queda fuera del repo de scripts PHP si no se documenta bien
|
||||||
|
- conviene dejarla muy explícita para no divergirse del comportamiento esperado
|
||||||
|
|
||||||
|
### Recomendación técnica
|
||||||
|
Mi lectura: **cualquiera vale**, pero elegiría según quién vaya a mantenerlo:
|
||||||
|
|
||||||
|
- si Rafa quiere la lógica **versionada y centralizada en el repo**, mejor **A: `rotate_cartas.php` ES-only**
|
||||||
|
- si Inma/Mixbot ya tienen el flujo sólido y prefieren autonomía total desde su lado, mejor **B: REST API desde Mixbot**, con la lógica documentada en el issue/runbook
|
||||||
|
|
||||||
|
### Si Rafa elige A (script ES-only), propuesta concreta
|
||||||
|
Crear una variante mínima o adaptar `rotate_cartas.php` con estas reglas:
|
||||||
|
|
||||||
|
- entrada: `CARTA=<es_id>`
|
||||||
|
- lookup solo en idioma `es`
|
||||||
|
- localizar:
|
||||||
|
- `actual_es` = post con cat 6
|
||||||
|
- `pasada_es` = post con cat 22
|
||||||
|
- `nueva_es` = `CARTA`
|
||||||
|
- operaciones:
|
||||||
|
- `pasada_es` pierde 22, conserva 21
|
||||||
|
- `actual_es` pierde 6, gana 22, conserva 21
|
||||||
|
- `nueva_es` gana 6 y conserva/gana 21
|
||||||
|
- **nunca tocar** posts EN/FR/IT/PT
|
||||||
|
- mantener modo `DRY-RUN`
|
||||||
|
|
||||||
|
### Dry-run deseable para Rafa
|
||||||
|
Salida ideal del dry-run:
|
||||||
|
- `nueva_es=#54495 -> +6 (+21 si faltaba)`
|
||||||
|
- `actual_es=#54254 -> -6 +22 (mantiene 21)`
|
||||||
|
- `pasada_es=#53984 -> -22 (mantiene 21)`
|
||||||
|
- `translations untouched`
|
||||||
|
|
||||||
|
### Decisión funcional que conviene dejar escrita
|
||||||
|
Para evitar ambigüedad futura, dejar explícito en el issue o en el script:
|
||||||
|
- `21` es acumulativa y la conservan todas las cartas archivables
|
||||||
|
- `6` y `22` son mutuamente excluyentes
|
||||||
|
- la rotación editorial inicial afecta **solo al ES**
|
||||||
|
- las traducciones rotan/publican en un paso posterior independiente
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue #175 — endpoint `subir-avatar`
|
||||||
|
|
||||||
|
### Base ya existente
|
||||||
|
El sistema actual ya tiene casi todo:
|
||||||
|
- frontend lee `user_meta('foto_perfil')`
|
||||||
|
- `face_crop_avatar.py` ya resuelve recorte/centrado de cara
|
||||||
|
- `regen_avatars.php` ya existe para regeneración/ajustes
|
||||||
|
- caso real listo para probar: **Silvia Martínez Cano**, `user_id=1112`, `slug=silvia-mtnz`
|
||||||
|
|
||||||
|
### Contrato recomendado
|
||||||
|
Yo le propondría a Rafa este contrato por simplicidad operativa:
|
||||||
|
|
||||||
|
#### Endpoint
|
||||||
|
`POST /wp-json/fea/v1/subir-avatar`
|
||||||
|
|
||||||
|
#### Auth
|
||||||
|
La misma Application Password que ya usa `crear-autor` (`#166`), con el mismo criterio de permisos.
|
||||||
|
|
||||||
|
#### Identificador
|
||||||
|
**`user_id`** mejor que `slug`.
|
||||||
|
|
||||||
|
Motivo:
|
||||||
|
- Mixbot ya lo tiene en el roster
|
||||||
|
- evita ambigüedades por slug cambiado / transliteraciones / colisiones
|
||||||
|
- simplifica el lookup
|
||||||
|
|
||||||
|
#### Formato de entrada
|
||||||
|
**multipart/form-data** mejor que base64.
|
||||||
|
|
||||||
|
Motivo:
|
||||||
|
- más natural para subir imágenes
|
||||||
|
- menos overhead
|
||||||
|
- encaja mejor con `media_handle_sideload` / manejo típico WP
|
||||||
|
- más fácil de depurar que base64
|
||||||
|
|
||||||
|
#### Campo esperado
|
||||||
|
- `user_id` (obligatorio)
|
||||||
|
- `file` (obligatorio)
|
||||||
|
|
||||||
|
Opcionalmente:
|
||||||
|
- `crop=server|client` si Rafa quiere dejar ambas puertas abiertas, aunque probablemente es overkill de entrada
|
||||||
|
|
||||||
|
### Recomendación de procesamiento
|
||||||
|
Mi recomendación clara: **que el endpoint NO haga face-crop complejo** en la primera versión.
|
||||||
|
|
||||||
|
Mejor v1:
|
||||||
|
- Mixbot/Inma mandan la imagen **ya cuadrada**
|
||||||
|
- estándar objetivo: **1254x1254** (el que ya usáis)
|
||||||
|
- el endpoint solo:
|
||||||
|
1. valida usuario
|
||||||
|
2. guarda attachment
|
||||||
|
3. actualiza `foto_perfil`
|
||||||
|
4. devuelve `attachment_id`, `user_id`, `url`
|
||||||
|
|
||||||
|
Pros:
|
||||||
|
- mucho menos riesgo y menos dependencias server-side
|
||||||
|
- evita meter OpenCV/Pillow/lógica pesada dentro del endpoint
|
||||||
|
- más fácil de testear e idempotente
|
||||||
|
- si mañana queréis face-crop automático, se añade en v2
|
||||||
|
|
||||||
|
### Comportamiento recomendado del endpoint
|
||||||
|
- requiere login + permiso tipo `edit_others_posts` (igual filosofía que `crear-autor`)
|
||||||
|
- valida que `user_id` exista
|
||||||
|
- acepta solo imagen (`jpg`, `jpeg`, `png`, `webp` si WP lo admite ahí)
|
||||||
|
- crea attachment en Media Library
|
||||||
|
- hace `update_user_meta($user_id, 'foto_perfil', $attachment_id)`
|
||||||
|
- respuesta JSON:
|
||||||
|
- `user_id`
|
||||||
|
- `display_name`
|
||||||
|
- `attachment_id`
|
||||||
|
- `url`
|
||||||
|
- `updated: true`
|
||||||
|
|
||||||
|
### Idempotencia mínima útil
|
||||||
|
No hace falta obsesionarse en v1, pero sí conviene:
|
||||||
|
- si se vuelve a subir otra foto para el mismo `user_id`, simplemente reemplazar el `foto_perfil` al nuevo attachment
|
||||||
|
- opcional: si el hash del fichero coincide con el ya asignado, devolver `updated:false`
|
||||||
|
|
||||||
|
### Caso piloto sugerido
|
||||||
|
Primera prueba con:
|
||||||
|
- `user_id=1112`
|
||||||
|
- `slug=silvia-mtnz`
|
||||||
|
- imagen ya recortada por Inma/Mixbot a **1254x1254**
|
||||||
|
|
||||||
|
Así la prueba valida solo el endpoint, no el pipeline de crop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cierres administrativos
|
||||||
|
Por estado funcional, salvo sorpresa:
|
||||||
|
- `#166` → cerrable
|
||||||
|
- `#168` → cerrable
|
||||||
|
- `#170` → cerrable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recomendación final para Rafa
|
||||||
|
Si quiere minimizar trabajo y riesgo:
|
||||||
|
- **#174**: usar la lógica que ya validó Mixbot hoy, y solo portar al repo si él prefiere centralizar
|
||||||
|
- **#175**: endpoint mínimo con `user_id` + multipart + imagen ya cuadrada a 1254x1254
|
||||||
|
|
||||||
|
Eso deja la autonomía mucho más cerca sin meterse en una refactor rara.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Revisión rápida de issues mencionados por Inma/Rafa — 2026-07-15
|
||||||
|
|
||||||
|
## Alcance
|
||||||
|
Revisión en modo **diagnóstico + preparación**. **Sin cambios en producción**.
|
||||||
|
|
||||||
|
## Observación importante sobre numeración
|
||||||
|
En el repo actual de Gitea `rafa/feadulta` los issues llegan hoy hasta **#146**. Por tanto, las referencias del mensaje (`#166`, `#168`, `#170`, `#174`, `#175`) no existen en el tracker actual y probablemente pertenecen a una numeración anterior o a notas habladas.
|
||||||
|
|
||||||
|
Para no quedarnos bloqueados, revisé los issues **actuales y más cercanos al tema de autonomía / operación**:
|
||||||
|
- `#144` Cron servidor: auto-traducción (Haiku) + TTS (MiniMax)
|
||||||
|
- `#145` Habilitar acceso a la REST API para publicar cartas
|
||||||
|
- `#146` Propuesta: arquitectura multiagente con Ringer, Goose, OB1, Hermes y OpenClaw
|
||||||
|
- además `#126` y el histórico `#121` / `#125` por dependencia operativa
|
||||||
|
|
||||||
|
## Hallazgos verificados
|
||||||
|
|
||||||
|
### 1) Issue #126 — seguridad login post-cutover
|
||||||
|
Verificación server-side en prod (`/web`) hecha por SSH + `wp eval`:
|
||||||
|
- `llar_active=1`
|
||||||
|
- `mu_exists=1`
|
||||||
|
- mu-plugin presente: `fea-cloudflare-realip.php`
|
||||||
|
|
||||||
|
Conclusión:
|
||||||
|
- **La parte operativa del checklist parece ya presente en prod.**
|
||||||
|
- El issue probablemente necesita **actualización/cierre**, no trabajo técnico urgente.
|
||||||
|
|
||||||
|
### 2) Gap antiguo de repo sobre `fea-cloudflare-realip.php`
|
||||||
|
El comentario viejo de `#126` decía que el mu-plugin no estaba trackeado en git.
|
||||||
|
|
||||||
|
Estado actual en el repo local:
|
||||||
|
- Sí existe: `wordpress/wp-content/mu-plugins/fea-cloudflare-realip.php`
|
||||||
|
|
||||||
|
Conclusión:
|
||||||
|
- Ese comentario ya quedó **desactualizado**.
|
||||||
|
|
||||||
|
### 3) Issue #144 — base técnica para autonomía traducción/TTS
|
||||||
|
Comprobado en el repo: existen los bloques principales mencionados por el issue:
|
||||||
|
- `scripts/detect_untranslated.php`
|
||||||
|
- `scripts/translate_post.py`
|
||||||
|
- `scripts/fix_carta_joomla_links.php`
|
||||||
|
- `scripts/demote_old_cartasemana.php`
|
||||||
|
- `scripts/minimax_tts.py`
|
||||||
|
- `scripts/sync_audio_to_prod.py`
|
||||||
|
|
||||||
|
Además ejecuté el detector en el **WordPress local Docker** (no en prod):
|
||||||
|
|
||||||
|
#### Ejecución local
|
||||||
|
`docker exec wordpress-web php /tmp/detect_untranslated.php 0.12 draft`
|
||||||
|
- Resultado: **0 ofensores draft**
|
||||||
|
|
||||||
|
`docker exec wordpress-web php /tmp/detect_untranslated.php 0.12 any`
|
||||||
|
- Resultado: **12 ofensores sospechosos** sobre publicados/cualquier estado
|
||||||
|
- Resumen por idioma:
|
||||||
|
- `en: 4/1148`
|
||||||
|
- `fr: 3/1148`
|
||||||
|
- `it: 3/1147`
|
||||||
|
- `pt: 2/1147`
|
||||||
|
|
||||||
|
IDs señalados por el detector:
|
||||||
|
- `47978, 47981, 47980, 47979`
|
||||||
|
- `47756, 47153`
|
||||||
|
- `54304, 47285, 54307, 43278, 54306, 54305`
|
||||||
|
|
||||||
|
Notas útiles:
|
||||||
|
- El script **no corre bien desde host** porque exige `/var/www/html/wp-load.php`; hay que lanzarlo dentro del contenedor.
|
||||||
|
- Esto es buena pista para futura automatización en `#144`: ya hay piezas, pero conviene empaquetarlas en un wrapper reproducible y con contexto de ejecución claro.
|
||||||
|
|
||||||
|
### 4) Issue #145 — REST API para Inma
|
||||||
|
No hice verificación externa definitiva porque eso requiere:
|
||||||
|
- credencial real de aplicación, y
|
||||||
|
- prueba end-to-end frente a Cloudflare
|
||||||
|
|
||||||
|
Estado documental actual:
|
||||||
|
- la skill y la documentación operativa indican que la REST API **ya fue habilitada para Inma**, pero no he revalidado hoy ese punto desde fuera.
|
||||||
|
|
||||||
|
Conclusión:
|
||||||
|
- Antes del viaje a Madrid conviene hacer un **smoke test real** (`/wp-json/wp/v2/users/me`) desde fuera del server.
|
||||||
|
|
||||||
|
### 5) Issue #146 — autonomía / multiagente
|
||||||
|
Lo revisado aquí es principalmente discusión/arquitectura; no detecté una acción local obvia de código en este repo que mereciera tocar hoy sin alinear primero el objetivo.
|
||||||
|
|
||||||
|
## Recomendación práctica
|
||||||
|
1. **Aclarar la numeración** de `#166/#168/#170/#174/#175` para no revisar los issues equivocados.
|
||||||
|
2. Si los “nuevos de autonomía” eran realmente los del tracker actual, yo priorizaría así:
|
||||||
|
- `#145`: verificar de verdad que Inma puede publicar sin Rafa delante.
|
||||||
|
- `#144`: encapsular el flujo traducción/TTS en un wrapper/cron operativo.
|
||||||
|
- `#146`: dejarlo como diseño/roadmap, no como siguiente cambio técnico directo.
|
||||||
|
3. `#126` huele a **issue de limpieza/cierre** si nadie ve un fleco pendiente.
|
||||||
|
|
||||||
|
## Qué NO hice
|
||||||
|
- No apliqué cambios en producción.
|
||||||
|
- No toqué Joomla legacy.
|
||||||
|
- No abrí/cerré/edité issues en Gitea.
|
||||||
|
- No modifiqué código del repo en esta revisión.
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Segunda pasada — issues correctos en Gitea vivo (`gitea.feadulta.com`) — 2026-07-15
|
||||||
|
|
||||||
|
## Alcance
|
||||||
|
Revisión en modo **diagnóstico + preparación** sobre la instancia viva:
|
||||||
|
- Repo: `https://gitea.feadulta.com/rafa/feadulta`
|
||||||
|
- Issues revisados: `#166`, `#168`, `#170`, `#174`, `#175`
|
||||||
|
- **Sin cambios en producción**
|
||||||
|
- **Sin edición de issues**
|
||||||
|
|
||||||
|
## Corrección del desajuste anterior
|
||||||
|
La pasada previa consultó el Gitea local archivado (`localhost:3000`), que se queda en `#146`. La fuente correcta para el trabajo actual es **`gitea.feadulta.com`**, tal como ya reflejan:
|
||||||
|
- la skill `feadulta-editorial-workflows`
|
||||||
|
- `feadulta/webmaster/references/environment.md`
|
||||||
|
|
||||||
|
## Estado por issue
|
||||||
|
|
||||||
|
### `#166` — Alta de autores / `crear-autor`
|
||||||
|
**Estado funcional real:** resuelto e integrado.
|
||||||
|
|
||||||
|
Verificado en el issue vivo:
|
||||||
|
- Rafa comentó que decidió la **opción B** (endpoint acotado).
|
||||||
|
- Rafa comentó después: **"Desplegado en prod y verificado en vivo"**.
|
||||||
|
- Inma comentó el 14-jul que ya está **integrado en Mixbot**.
|
||||||
|
|
||||||
|
Verificado además en el repo local:
|
||||||
|
- existe `wordpress/wp-content/mu-plugins/fea-crear-autor-api.php`
|
||||||
|
- registra `POST /wp-json/fea/v1/crear-autor`
|
||||||
|
- fuerza rol fijo `author`
|
||||||
|
- es **idempotente** si el slug ya existe
|
||||||
|
|
||||||
|
Conclusión:
|
||||||
|
- **No hay trabajo técnico pendiente aquí**.
|
||||||
|
- Si queréis limpieza de tablero, este issue ya está para **cerrar** cuando Rafa quiera.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `#168` — Bots en Joomla viejo / Cloudflare
|
||||||
|
**Estado funcional real:** resuelto lado Cloudflare, aunque el issue sigue abierto.
|
||||||
|
|
||||||
|
Verificado en el issue vivo:
|
||||||
|
- el body ya documenta la parte técnica previa (cache Joomla activada y diagnóstico del fatal)
|
||||||
|
- Inma añadió comentario 14-jul: **"HECHO y verificado"** ajustando una regla existente de Cloudflare por el límite de 5 custom rules del plan free
|
||||||
|
|
||||||
|
Conclusión:
|
||||||
|
- Operativamente está **resuelto**.
|
||||||
|
- Si no queda ningún fleco de observabilidad, este issue también está para **cerrar**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `#170` — Crawlers sociales / preview Facebook
|
||||||
|
**Estado funcional real:** resuelto lado Cloudflare, aunque el issue sigue abierto.
|
||||||
|
|
||||||
|
Verificado en el issue vivo:
|
||||||
|
- Inma comentó 14-jul que ya existía una regla **"Permitir Facebook"** y que el crawler de Facebook ya pasa con **200**
|
||||||
|
|
||||||
|
Conclusión:
|
||||||
|
- Operativamente está **resuelto**.
|
||||||
|
- Igual que `#168`, parece issue de **cierre administrativo**, no técnico.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `#174` — Cierre autónomo de la carta (publicar + rotar)
|
||||||
|
**Estado funcional real:** abierto, esperando OK de Rafa. Aquí sí hay trabajo útil preparado, pero no conviene aplicar nada aún.
|
||||||
|
|
||||||
|
Lo importante que he verificado localmente:
|
||||||
|
|
||||||
|
#### 1) Ya existe lógica de rotación en scripts
|
||||||
|
En el repo local existe:
|
||||||
|
- `scripts/rotate_cartas.php`
|
||||||
|
- `scripts/demote_old_cartasemana.php`
|
||||||
|
|
||||||
|
`rotate_cartas.php` implementa esta cascada:
|
||||||
|
1. la que estaba en "semana pasada" → queda solo en "otras semanas"
|
||||||
|
2. la que estaba en "semana actual" → pasa a "semana pasada"
|
||||||
|
3. la nueva → pasa a "semana actual"
|
||||||
|
|
||||||
|
#### 2) Pero la implementación actual rota **todos los idiomas**, no solo ES
|
||||||
|
Esto es importante porque en `#174` justo se pregunta si:
|
||||||
|
- **solo se rota ES al cerrar la carta**, y
|
||||||
|
- las traducciones **no se rotan** hasta que estén publicadas
|
||||||
|
|
||||||
|
El script actual **no sigue ese criterio**: deriva y actúa sobre `es/en/fr/it/pt`.
|
||||||
|
|
||||||
|
#### 3) Dry-run local ejecutado
|
||||||
|
He ejecutado `rotate_cartas.php` en el WordPress local Docker en modo dry-run.
|
||||||
|
|
||||||
|
Resultado:
|
||||||
|
- el script **funciona** como dry-run
|
||||||
|
- pero el espejo local **no está alineado con el caso exacto de la carta 735 en ES** (el mirror local va por otro estado / otra numeración viva en esa parte)
|
||||||
|
- por tanto, **sirve para validar la mecánica del script**, pero **no para afirmar que ya resuelva `#174` tal cual**
|
||||||
|
|
||||||
|
Conclusión técnica:
|
||||||
|
- `#174` **no es greenfield**: ya hay base.
|
||||||
|
- Pero **hay que adaptar** la lógica si la decisión final es "rotar solo ES y dejar derivados/traducciones para después".
|
||||||
|
- No he preparado parche porque todavía falta el **OK funcional de Rafa**, y sin eso sería fácil codificar la lógica equivocada.
|
||||||
|
|
||||||
|
Recomendación:
|
||||||
|
- cuando Rafa confirme la regla exacta, el siguiente paso bueno es preparar un **wrapper ES-only + dry-run** en local, en vez de reaprovechar `rotate_cartas.php` tal cual.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `#175` — Endpoint `subir-avatar`
|
||||||
|
**Estado funcional real:** abierto, esperando OK de Rafa. También tiene muy buena base técnica ya hecha.
|
||||||
|
|
||||||
|
Lo verificado localmente:
|
||||||
|
|
||||||
|
#### 1) El frontend ya usa `foto_perfil`
|
||||||
|
En `wordpress/wp-content/mu-plugins/fea-homepage.php`:
|
||||||
|
- el avatar del autor usa el meta de usuario **`foto_perfil`**
|
||||||
|
- ese meta guarda un attachment ID
|
||||||
|
|
||||||
|
#### 2) Ya existen las piezas de procesamiento
|
||||||
|
En el repo hay:
|
||||||
|
- `scripts/face_crop_avatar.py`
|
||||||
|
- `scripts/regen_avatars.php`
|
||||||
|
|
||||||
|
O sea: el flujo de recorte / regeneración **ya existe**; faltaría encapsularlo detrás de un endpoint seguro.
|
||||||
|
|
||||||
|
#### 3) El caso concreto mencionado en el issue existe y está sin foto en local
|
||||||
|
Verificado en el WP local:
|
||||||
|
- usuario `1112` existe
|
||||||
|
- login: `silvia-mtnz`
|
||||||
|
- display: `Silvia Martínez Cano`
|
||||||
|
- `foto_perfil` está vacío
|
||||||
|
|
||||||
|
Conclusión técnica:
|
||||||
|
- `#175` tampoco es greenfield.
|
||||||
|
- El endpoint encaja bien como hermano de `fea/v1/crear-autor` (`#166`).
|
||||||
|
- La decisión pendiente no es "si se puede", sino **qué contrato exacto quiere Rafa**:
|
||||||
|
- `user_id` vs `slug`
|
||||||
|
- multipart vs base64
|
||||||
|
- si el endpoint recorta o exige imagen ya preparada
|
||||||
|
|
||||||
|
Recomendación:
|
||||||
|
- en cuanto Rafa dé el OK de contrato, el trabajo lógico es clonar el patrón de `fea-crear-autor-api.php` y dejar un endpoint **mínimo, acotado e idempotente**.
|
||||||
|
|
||||||
|
## Resumen ejecutivo
|
||||||
|
- `#166`: **hecho** ✅
|
||||||
|
- `#168`: **hecho** ✅ pero sigue abierto
|
||||||
|
- `#170`: **hecho** ✅ pero sigue abierto
|
||||||
|
- `#174`: **esperando OK funcional de Rafa**; hay base técnica, pero la actual rota todos los idiomas
|
||||||
|
- `#175`: **esperando OK funcional de Rafa**; hay base técnica muy clara y el caso Silvia está identificado
|
||||||
|
|
||||||
|
## Qué hice en esta pasada
|
||||||
|
- Reapunté la revisión a `gitea.feadulta.com`
|
||||||
|
- Leí los issues correctos por API pública
|
||||||
|
- Verifiqué localmente la existencia del endpoint `crear-autor`
|
||||||
|
- Ejecuté un **dry-run** de la lógica de rotación existente
|
||||||
|
- Verifiqué en local el caso `Silvia Martínez Cano / 1112 / foto_perfil vacío`
|
||||||
|
|
||||||
|
## Qué NO hice
|
||||||
|
- No toqué producción
|
||||||
|
- No cerré issues
|
||||||
|
- No comenté en Gitea
|
||||||
|
- No modifiqué código del repo
|
||||||
Regular → Executable
+63
-6
@@ -2,17 +2,74 @@
|
|||||||
/**
|
/**
|
||||||
* Plugin Name: Fe Adulta — Carta de la Semana
|
* Plugin Name: Fe Adulta — Carta de la Semana
|
||||||
* Description: Redirige las URLs de carta al archivo de categoría correspondiente.
|
* 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
|
// Redirigir las páginas custom a las categorías
|
||||||
add_action('template_redirect', function() {
|
add_action("template_redirect", function() {
|
||||||
if (is_page('carta-de-la-semana')) {
|
if (is_page("carta-de-la-semana")) {
|
||||||
wp_redirect(home_url('/category/cartasemana/'), 302);
|
wp_redirect(home_url("/category/cartasemana/"), 302);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
if (is_page('la-semana-pasada')) {
|
if (is_page("la-semana-pasada")) {
|
||||||
wp_redirect(home_url('/category/carta-semana-pasada/'), 302);
|
wp_redirect(home_url("/category/carta-semana-pasada/"), 302);
|
||||||
exit;
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,24 +1,37 @@
|
|||||||
<?php
|
<?php
|
||||||
require '/var/www/html/wp-load.php';
|
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
|
||||||
$ES = 2682; // MATEO 10, 26-33 (es)
|
$ES = (int)(getenv('ES_ID') ?: 2682); // MATEO 10, 26-33 (es)
|
||||||
$src = get_post($ES);
|
$src = get_post($ES);
|
||||||
$tr_html = json_decode(file_get_contents('/tmp/lecturas_mateo.json'), true);
|
$tr_html = json_decode(file_get_contents(getenv('LECTURAS_JSON') ?: '/tmp/lecturas_mateo.json'), true);
|
||||||
$BOOK = ['en'=>'MATTHEW','fr'=>'MATTHIEU','it'=>'MATTEO','pt'=>'MATEUS'];
|
$BOOK = ['en'=>'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'];
|
$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"
|
$tail = preg_replace('~^MATEO~','',$REST); // " 10, 26-33"
|
||||||
$es_cats = wp_get_post_categories($ES);
|
$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];
|
$grp = pll_get_post_translations($ES); if(!$grp) $grp=['es'=>$ES];
|
||||||
foreach (['en','fr','it','pt'] as $lang) {
|
foreach (['en','fr','it','pt'] as $lang) {
|
||||||
$exist = (int)pll_get_post($ES,$lang);
|
$exist = (int)pll_get_post($ES,$lang);
|
||||||
if ($exist && get_post($exist)) { echo "$lang ya existe #$exist — saltado\n"; $grp[$lang]=$exist; continue; }
|
if ($exist && get_post($exist)) { echo "$lang ya existe #$exist — saltado\n"; $grp[$lang]=$exist; continue; }
|
||||||
$title = $BOOK[$lang].$tail;
|
$title = $BOOK[$lang].$tail;
|
||||||
$id = wp_insert_post([
|
$postarr = [
|
||||||
'post_title'=>$title,'post_content'=>$tr_html[$lang],'post_status'=>'publish',
|
'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,
|
'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 (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);
|
pll_set_post_language($id,$lang);
|
||||||
$mapped=[]; foreach($es_cats as $c){ $tc=(int)pll_get_term($c,$lang); $mapped[]=$tc?:$c; }
|
$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)));
|
wp_set_post_categories($id, array_values(array_unique($mapped)));
|
||||||
|
|||||||
Regular → Executable
+35
-9
@@ -1,13 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Ciclo carta nueva — deja en la categoría "carta de la semana" SOLO la carta nueva,
|
* Ciclo carta nueva — sincroniza "esta semana", "semana pasada" y "otras semanas"
|
||||||
* en TODOS los idiomas (ES + EN/FR/IT/PT), moviendo las anteriores a "cartas de otras
|
* en TODOS los idiomas (ES + EN/FR/IT/PT).
|
||||||
* semanas". Necesario para que el menú/categoría de cada idioma tenga count=1 y redirija
|
|
||||||
* directo al single (carta-semana-plugin). En ES suele hacerse al importar; las
|
|
||||||
* traducciones NO lo heredan -> ejecutar esto tras publicar las traducciones.
|
|
||||||
*
|
*
|
||||||
* Deriva los términos por Polylang desde los términos ES base:
|
* 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=<es_id> php demote_old_cartasemana.php (dry-run)
|
* Uso: CARTA=<es_id> php demote_old_cartasemana.php (dry-run)
|
||||||
* APPLY=1 CARTA=<es_id> php demote_old_cartasemana.php
|
* APPLY=1 CARTA=<es_id> php demote_old_cartasemana.php
|
||||||
@@ -19,11 +16,26 @@ if (!$CARTA) { fwrite(STDERR,"Falta CARTA=<es_id>\n"); exit(1); }
|
|||||||
|
|
||||||
$cs_terms = pll_get_term_translations(6); // cartasemana por idioma
|
$cs_terms = pll_get_term_translations(6); // cartasemana por idioma
|
||||||
$otras_terms = pll_get_term_translations(21); // cartas de otras semanas 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);
|
$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) {
|
foreach ($cs_terms as $lang=>$cs) {
|
||||||
$keep = $carta_tr[$lang] ?? 0;
|
$keep = $carta_tr[$lang] ?? 0;
|
||||||
$otras = $otras_terms[$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',
|
$posts = get_posts(['post_type'=>'post','numberposts'=>-1,'post_status'=>'any','fields'=>'ids',
|
||||||
'tax_query'=>[['taxonomy'=>'category','field'=>'term_id','terms'=>$cs]]]);
|
'tax_query'=>[['taxonomy'=>'category','field'=>'term_id','terms'=>$cs]]]);
|
||||||
$moved=0;
|
$moved=0;
|
||||||
@@ -35,9 +47,23 @@ foreach ($cs_terms as $lang=>$cs) {
|
|||||||
}
|
}
|
||||||
$moved++;
|
$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);
|
$t=get_term($cs);
|
||||||
echo sprintf("%s: %s %d | '%s' count=%d keep=#%d\n", strtoupper($lang),
|
echo sprintf("%s: %s %d de actual | anterior=#%d (limpia %d) | '%s' keep=#%d\n",
|
||||||
$APPLY?"movidas":"se moverían", $moved, $t->slug, $t->count, $keep);
|
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";
|
echo $APPLY ? "APLICADO\n" : "DRY-RUN (APPLY=1 para aplicar)\n";
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Issue #80 — detecta traducciones con fragmentos en español sin traducir.
|
||||||
|
* Señal: una frase sin traducir queda IDÉNTICA a la del ES original.
|
||||||
|
* Para cada traducción (en/fr/it/pt) compara sus frases contra el set de frases
|
||||||
|
* del ES enlazado (Polylang) y calcula el % de caracteres que coinciden literal.
|
||||||
|
*
|
||||||
|
* Uso (en contenedor): php detect_untranslated.php [umbral] [status]
|
||||||
|
* umbral: ratio mínimo para marcar (def 0.12)
|
||||||
|
* status: draft (def) | publish | any
|
||||||
|
*/
|
||||||
|
require "/var/www/html/wp-load.php";
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$THRESH = isset($argv[1]) ? (float)$argv[1] : 0.12;
|
||||||
|
$STATUS = $argv[2] ?? 'draft';
|
||||||
|
|
||||||
|
function norm_text($html) {
|
||||||
|
$t = preg_replace('~(?i)</p>|<br\s*/?>|</h[1-6]>~', "\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)));
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Arregla los enlaces internos de la carta que apuntan a contenido com_content
|
||||||
|
* de Joomla (multimedia, vídeos, cantoral...) con forma legacy
|
||||||
|
* es/<seccion>/<joomla_content_id>-<slug>.html
|
||||||
|
* que fix_carta_joomla_links.php NO mapea (solo trata /item/<k2>-...). 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=<es_id> php fix_carta_content_links.php (dry-run)
|
||||||
|
* APPLY=1 CARTA=<es_id> 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=<es_id>\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 <id>-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";
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
require '/var/www/html/wp-load.php';
|
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$APPLY = getenv("APPLY") === "1";
|
$APPLY = getenv("APPLY") === "1";
|
||||||
$BAK = "/tmp/fix_carta_links_bak"; if ($APPLY) @mkdir($BAK,0777,true);
|
$BAK = "/tmp/fix_carta_links_bak"; if ($APPLY) @mkdir($BAK,0777,true);
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Issue #75 — normaliza los enlaces internos de las cartas (ES + traducciones).
|
||||||
|
*
|
||||||
|
* Bugs que arregla:
|
||||||
|
* - Cartas ES con enlaces a http://localhost:8081/<slug>/ (rompen en prod).
|
||||||
|
* - Traducciones con enlaces relativos /<slug>/ (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 <a href>: 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 /<slug>/ 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";
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
* FROM ew4r_k2_items i LEFT JOIN ew4r_users u ON u.id=i.created_by \
|
* FROM ew4r_k2_items i LEFT JOIN ew4r_users u ON u.id=i.created_by \
|
||||||
* WHERE i.id IN ($IDS);" > /tmp/autores143.tsv
|
* 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
|
* 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
|
* APPLY=1 FEA_TSV=/tmp/autores143.tsv wp eval-file scripts/fix_k2_authors.php # aplica
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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})")
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
* user_meta _foto_perfil_pre143. Idempotente (si ya apunta, solo regenera metadata).
|
* user_meta _foto_perfil_pre143. Idempotente (si ya apunta, solo regenera metadata).
|
||||||
*
|
*
|
||||||
* Entrada: TSV «uid<TAB>display_name» (env FEA_TSV, por defecto /tmp/users29.tsv).
|
* Entrada: TSV «uid<TAB>display_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
|
* 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
|
* APPLY=1 FEA_TSV=/tmp/users29.tsv wp eval-file scripts/import_avatars_143.php # aplica
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Issue #81 — avatares nuevos de 6 colaboradores habituales.
|
||||||
|
* Reapunta foto_perfil (user_meta) al PNG uploads/avatares/autores/autor-<uid>.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";
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Issue #90 — avatares nuevos de 2 colaboradores nuevos (Mari Paz Lopez, Africa de la Cruz).
|
||||||
|
* Reapunta foto_perfil (user_meta) al PNG uploads/avatares/autores/autor-<uid>.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";
|
||||||
@@ -20,7 +20,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
JOOMLA_SSH_HOST = "134.0.10.170"
|
JOOMLA_SSH_HOST = "134.0.10.170"
|
||||||
JOOMLA_SSH_USER = "feadulta"
|
JOOMLA_SSH_USER = "feadulta"
|
||||||
JOOMLA_SSH_PASS = "6Rm2qOF@eundwpda"
|
JOOMLA_SSH_PASS = "C6c2A!mAl3Wj.BQF"
|
||||||
JOOMLA_DB_HOST = "127.0.0.1"
|
JOOMLA_DB_HOST = "127.0.0.1"
|
||||||
JOOMLA_DB_USER = "fejoomla3"
|
JOOMLA_DB_USER = "fejoomla3"
|
||||||
JOOMLA_DB_PASS = "5FF-}5^[>7^pK4W9"
|
JOOMLA_DB_PASS = "5FF-}5^[>7^pK4W9"
|
||||||
@@ -31,7 +31,7 @@ WP_DB_USER = "wordpress_user"
|
|||||||
WP_DB_PASS = "wordpress_pass"
|
WP_DB_PASS = "wordpress_pass"
|
||||||
WP_DB_NAME = "wordpress_db"
|
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)
|
# WP term_ids y sus term_taxonomy_ids (se cargan dinámicamente)
|
||||||
CAT_FEADULTA = 71
|
CAT_FEADULTA = 71
|
||||||
@@ -50,7 +50,7 @@ DRY_RUN = '--dry-run' in sys.argv
|
|||||||
# ── Helpers ────────────────────────────────────────────────────────────────────
|
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def joomla_query(query: str) -> list[dict]:
|
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"-p'{JOOMLA_DB_PASS}' {JOOMLA_DB_NAME} "
|
||||||
f"--default-character-set=utf8mb4 -B")
|
f"--default-character-set=utf8mb4 -B")
|
||||||
cmd = ['sshpass', '-p', JOOMLA_SSH_PASS,
|
cmd = ['sshpass', '-p', JOOMLA_SSH_PASS,
|
||||||
@@ -111,6 +111,11 @@ def unhex(val: str) -> str:
|
|||||||
# ── Main ───────────────────────────────────────────────────────────────────────
|
# ── Main ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def 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}) "
|
print(f"=== Import nuevas cartas (ew4r_content id > {LAST_CONTENT_ID}) "
|
||||||
f"{'[DRY RUN]' if DRY_RUN else '[LIVE]'} ===\n")
|
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)
|
created_by = int(item.get('created_by', 0) or 0)
|
||||||
|
|
||||||
content = intro + ('\n<!--more-->\n' + full if full.strip() else '')
|
content = intro + ('\n<!--more-->\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])
|
wp_cats = CATID_TO_WP.get(catid, [CAT_CARTAS_OTRAS, CAT_FEADULTA])
|
||||||
fecha_carta = created[:10] # YYYY-MM-DD
|
fecha_carta = created[:10] # YYYY-MM-DD
|
||||||
|
|
||||||
@@ -237,18 +245,28 @@ def main():
|
|||||||
|
|
||||||
print("\n=== Asignando _carta_id a artículos K2 ===")
|
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 = (
|
k2_query = (
|
||||||
f"SELECT id, HEX(extra_fields) ef "
|
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)
|
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
|
assigned = 0
|
||||||
for k2item in k2_items:
|
for k2item in k2_items:
|
||||||
k2_id = int(k2item['id'])
|
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',''))
|
ef_raw = unhex(k2item.get('ef',''))
|
||||||
|
|
||||||
# Parsear fecha (id 15)
|
# Parsear fecha (id 15)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ DRY_RUN = '--dry-run' in sys.argv
|
|||||||
# ── Helpers ────────────────────────────────────────────────────────────────────
|
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def joomla_query(query: str) -> list[dict]:
|
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"-p'{JOOMLA_DB_PASS}' {JOOMLA_DB_NAME} "
|
||||||
f"--default-character-set=utf8mb4 -B")
|
f"--default-character-set=utf8mb4 -B")
|
||||||
cmd = ['sshpass', '-p', JOOMLA_SSH_PASS,
|
cmd = ['sshpass', '-p', JOOMLA_SSH_PASS,
|
||||||
@@ -184,7 +184,12 @@ def main():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
content = intro + ('\n<!--more-->\n' + full if full.strip() else '')
|
content = intro + ('\n<!--more-->\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])
|
wp_cats = CATID_TO_WP.get(catid, [1])
|
||||||
|
|
||||||
print(f" [{joomla_id}] catid={catid} | {title[:50]}")
|
print(f" [{joomla_id}] catid={catid} | {title[:50]}")
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
JOOMLA_SSH_HOST = "134.0.10.170"
|
JOOMLA_SSH_HOST = "134.0.10.170"
|
||||||
JOOMLA_SSH_USER = "feadulta"
|
JOOMLA_SSH_USER = "feadulta"
|
||||||
JOOMLA_SSH_PASS = "6Rm2qOF@eundwpda"
|
JOOMLA_SSH_PASS = "C6c2A!mAl3Wj.BQF"
|
||||||
JOOMLA_DB_HOST = "127.0.0.1"
|
JOOMLA_DB_HOST = "127.0.0.1"
|
||||||
JOOMLA_DB_USER = "fejoomla3"
|
JOOMLA_DB_USER = "fejoomla3"
|
||||||
JOOMLA_DB_PASS = "5FF-}5^[>7^pK4W9"
|
JOOMLA_DB_PASS = "5FF-}5^[>7^pK4W9"
|
||||||
@@ -37,7 +37,7 @@ WP_DB_PASS = "wordpress_pass"
|
|||||||
WP_DB_NAME = "wordpress_db"
|
WP_DB_NAME = "wordpress_db"
|
||||||
WP_DB_HOST = "wordpress-mysql" # dentro del container
|
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)
|
# WP term_taxonomy_ids (obtenidos con SELECT tt.term_taxonomy_id FROM wp_term_taxonomy tt WHERE tt.term_id=N)
|
||||||
# Precalculados:
|
# Precalculados:
|
||||||
@@ -58,7 +58,7 @@ def ssh_mysql(query: str) -> list[dict]:
|
|||||||
cmd = [
|
cmd = [
|
||||||
'sshpass', '-p', JOOMLA_SSH_PASS,
|
'sshpass', '-p', JOOMLA_SSH_PASS,
|
||||||
'ssh', f'{JOOMLA_SSH_USER}@{JOOMLA_SSH_HOST}',
|
'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'-p{repr(JOOMLA_DB_PASS)} {JOOMLA_DB_NAME} '
|
||||||
f'--default-character-set=utf8mb4 -B -e "{query}"'
|
f'--default-character-set=utf8mb4 -B -e "{query}"'
|
||||||
]
|
]
|
||||||
@@ -207,6 +207,11 @@ def determine_categories(ef: dict, title: str) -> list[int]:
|
|||||||
# ── Import principal ───────────────────────────────────────────────────────────
|
# ── Import principal ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def main():
|
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")
|
print(f"=== Import K2 items > {LAST_K2_ID} → WP local {'[DRY RUN]' if DRY_RUN else '[LIVE]'} ===\n")
|
||||||
|
|
||||||
user_map = load_user_map()
|
user_map = load_user_map()
|
||||||
@@ -238,7 +243,7 @@ def main():
|
|||||||
f"WHERE published=1 AND id > {LAST_K2_ID} ORDER BY id;"
|
f"WHERE published=1 AND id > {LAST_K2_ID} ORDER BY id;"
|
||||||
)
|
)
|
||||||
mysql_cmd = (
|
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"-p'{JOOMLA_DB_PASS}' {JOOMLA_DB_NAME} "
|
||||||
f"--default-character-set=utf8mb4 -B"
|
f"--default-character-set=utf8mb4 -B"
|
||||||
)
|
)
|
||||||
@@ -340,10 +345,12 @@ def main():
|
|||||||
stats['ok'] += 1
|
stats['ok'] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Obtener el ID del post recién insertado
|
# Obtener el ID del post recién insertado. NO usar LAST_INSERT_ID(): cada
|
||||||
new_id_rows = wp_mysql("SELECT LAST_INSERT_ID() as new_id")
|
# 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:
|
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
|
stats['err'] += 1
|
||||||
continue
|
continue
|
||||||
new_wp_id = int(new_id_rows[0]['new_id'])
|
new_wp_id = int(new_id_rows[0]['new_id'])
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Convierte los enlaces internos `...?p=<id>` 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=<id>`, 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=<es_id> php prettify_carta_links.php (dry-run)
|
||||||
|
* APPLY=1 CARTA=<es_id> 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=<es_id>\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";
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
require '/var/www/html/wp-load.php';
|
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
|
||||||
$CARTA = 46956;
|
$CARTA = (int)(getenv('CARTA') ?: 46956);
|
||||||
// IDs objetivo: carta + sus artículos ES, y TODAS sus traducciones Polylang.
|
// IDs objetivo: carta + sus artículos ES, y TODAS sus traducciones Polylang.
|
||||||
$es_ids = [$CARTA];
|
$es_ids = [$CARTA];
|
||||||
$arts = get_posts(['post_type'=>'post','numberposts'=>-1,'post_status'=>'any',
|
$arts = get_posts(['post_type'=>'post','numberposts'=>-1,'post_status'=>'any',
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
// Remapea los enlaces internos de las traducciones de una carta: convierte
|
||||||
|
// permalinks/IDs que apuntan al artículo ES → permalink del artículo en el
|
||||||
|
// idioma de CADA carta (vía Polylang). Necesario cuando la carta ES se tradujo
|
||||||
|
// DESPUÉS de haberle fijado los enlaces a permalink (las traducciones heredan
|
||||||
|
// los permalinks ES). Solo toca traducciones (no la ES). Dry-run por defecto.
|
||||||
|
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
|
||||||
|
global $wpdb;
|
||||||
|
$APPLY = getenv("APPLY") === "1";
|
||||||
|
$BAK = "/tmp/remap_carta_tr_bak"; if ($APPLY) @mkdir($BAK,0777,true);
|
||||||
|
$CARTA = (int)(getenv("CARTA") ?: 53644);
|
||||||
|
|
||||||
|
$tr = pll_get_post_translations($CARTA);
|
||||||
|
$tot = 0;
|
||||||
|
foreach($tr as $lang=>$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";
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Remapea las categorías de las traducciones automáticas (meta traduccion_origen)
|
||||||
|
* a los términos traducidos de su propio idioma. Idempotente y sin llamar a Gemma.
|
||||||
|
*
|
||||||
|
* Arregla las traducciones creadas antes de que fea_translate_helper.php mapeara
|
||||||
|
* categorías (issue #75): p.ej. una carta EN que quedó en la categoría ES `cartasemana`
|
||||||
|
* pasa a la categoría EN `letter-of-the-week`, poblando el archivo de carta por idioma.
|
||||||
|
*
|
||||||
|
* Uso: docker exec wordpress-web php /tmp/remap_translation_cats.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
$_SERVER['REQUEST_URI'] = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
|
$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_HOST'] ?? 'farmer.taild3aaf6.ts.net';
|
||||||
|
require_once '/var/www/html/wp-load.php';
|
||||||
|
|
||||||
|
if (!function_exists('pll_get_term')) { fwrite(STDERR, "Polylang no disponible\n"); exit(2); }
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$ids = $wpdb->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";
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
require '/var/www/html/wp-load.php';
|
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$APPLY = getenv("APPLY")==="1";
|
$APPLY = getenv("APPLY")==="1";
|
||||||
$CARTA = (int)(getenv("CARTA") ?: 46956);
|
$CARTA = (int)(getenv("CARTA") ?: 46956);
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Reprocesa traducciones ROTAS con Claude Haiku 4.5 (API directa).
|
||||||
|
|
||||||
|
Coge el ES original, lo traduce con Haiku y SOBRESCRIBE la traducción ya
|
||||||
|
existente (in-place, sin duplicar). Detecta los rotos por ratio de longitud.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
reprocess_en_haiku.py --auto --langs en --limit 100 # EN rotos
|
||||||
|
reprocess_en_haiku.py --auto --langs fr,it,pt --limit 50 # otros idiomas
|
||||||
|
reprocess_en_haiku.py --ids 44205 --langs en # src concretos
|
||||||
|
añade --apply para ESCRIBIR en la BD (sin él = dry-run).
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from translate_haiku import translate # noqa: E402
|
||||||
|
|
||||||
|
STATE = "/tmp/feadulta-translate-state.json"
|
||||||
|
CONTAINER = "wordpress-web"
|
||||||
|
RATIO_BROKEN = 0.45
|
||||||
|
|
||||||
|
|
||||||
|
def dexec(args):
|
||||||
|
return subprocess.run(["docker", "exec", CONTAINER, *args],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def dcp(remote, local):
|
||||||
|
subprocess.run(["docker", "cp", f"{CONTAINER}:{remote}", local], check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def dcp_to(local, remote):
|
||||||
|
subprocess.run(["docker", "cp", local, f"{CONTAINER}:{remote}"], check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_post(pid):
|
||||||
|
r = dexec(["php", "/tmp/fea_post_io.php", "get", str(pid)])
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(f"get {pid}: {r.stderr.strip()}")
|
||||||
|
dcp("/tmp/fea_es.json", "/tmp/fea_es.json")
|
||||||
|
return json.load(open("/tmp/fea_es.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def strip_len(html):
|
||||||
|
return len(re.sub(r"<[^>]*>", "", 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()
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Ciclo carta nueva — ROTACIÓN de la "carta de la semana" en TODOS los idiomas.
|
||||||
|
*
|
||||||
|
* Rotación (en este orden, para no perder ninguna):
|
||||||
|
* 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 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=<es_id> php rotate_cartas.php (dry-run)
|
||||||
|
* APPLY=1 CARTA=<es_id> 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=<es_id>\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";
|
||||||
@@ -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())
|
||||||
Executable
+76
@@ -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"
|
||||||
@@ -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 <<<INI>>> y <<<FIN>>>, sin nada más."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract(text: str) -> str:
|
||||||
|
# Coge el bloque <<<INI>>>...<<<FIN>>> de contenido MÁS LARGO (robusto al
|
||||||
|
# bug del runner local, donde el modelo a veces re-menciona las marcas).
|
||||||
|
blocks = re.findall(r"<<<INI>>>(.*?)<<<FIN>>>", 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"<<<INI>>>{text}<<<FIN>>>"
|
||||||
|
)
|
||||||
|
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])
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
* Uso (local): docker exec wordpress-web php /var/www/html/scripts/... (o vía cwd)
|
* Uso (local): docker exec wordpress-web php /var/www/html/scripts/... (o vía cwd)
|
||||||
* php scripts/translate_lectura_titles.php # dry-run + reporte
|
* php scripts/translate_lectura_titles.php # dry-run + reporte
|
||||||
* APPLY=1 php scripts/translate_lectura_titles.php # aplica
|
* 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);
|
error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ Pensado para que Codex lo lance en lote sobre la cola priorizada (cartas/destaca
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import fcntl
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -47,6 +48,12 @@ if ENGINE == "haiku":
|
|||||||
MODEL = "claude-haiku-4-5"
|
MODEL = "claude-haiku-4-5"
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
import translate_haiku # carga la API key de portfolio-tracker/.env
|
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_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
|
||||||
HELPER_DST = "/tmp/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"]
|
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:
|
def _extract(text: str) -> str:
|
||||||
"""Extrae la traducción del ÚLTIMO bloque <<<INI>>>…<<<FIN>>>.
|
"""Extrae la traducción del ÚLTIMO bloque <<<INI>>>…<<<FIN>>>.
|
||||||
|
|
||||||
@@ -175,7 +200,8 @@ def translate_text(text: str, lang: str, *, is_title: bool = False) -> str:
|
|||||||
{"role": "user", "content": task},
|
{"role": "user", "content": task},
|
||||||
]
|
]
|
||||||
max_tokens = max(800, int(len(text) * 1.6))
|
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)
|
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")
|
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:
|
def create_translation(es_id: int, lang: str, title: str, content: str, status: str) -> int:
|
||||||
payload = json.dumps({"title": title, "content": content, "model": MODEL})
|
payload = json.dumps({"title": title, "content": content, "model": MODEL})
|
||||||
|
# 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())
|
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]:
|
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 = 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("--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("--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("--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("--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.")
|
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:
|
if args.post_id:
|
||||||
ids = [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:
|
else:
|
||||||
ids = [args.carta] + carta_article_ids(args.carta)
|
ids = [args.carta] + carta_article_ids(args.carta)
|
||||||
log(f"Carta {args.carta}: {len(ids)} posts (carta + {len(ids)-1} artículos)")
|
log(f"Carta {args.carta}: {len(ids)} posts (carta + {len(ids)-1} artículos)")
|
||||||
|
|||||||
@@ -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 <post_id> <muestra_voz.wav> [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)</p>|<br\s*/?>|</h[1-6]>", "\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 <post_id> <muestra_voz.wav> [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()
|
||||||
@@ -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 <post_id> [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)</p>|<br\s*/?>|</h[1-6]>", "\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()
|
||||||
@@ -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/<nombre>.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())
|
||||||
@@ -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-<voz>.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()
|
||||||
@@ -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 <muestra_voz.wav|mp3> [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 <muestra_voz.wav|mp3> [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()
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Unpublishes migrated posts whose title or slug is only a date (dd-mm-yyyy).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* docker cp scripts/unpublish_date_slug_posts.php wordpress-web:/tmp/unpublish_date_slug_posts.php
|
||||||
|
* docker exec wordpress-web php /tmp/unpublish_date_slug_posts.php --dry-run
|
||||||
|
* docker exec wordpress-web php /tmp/unpublish_date_slug_posts.php --apply
|
||||||
|
*/
|
||||||
|
|
||||||
|
$cli_args = array_merge($argv ?? [], $args ?? []);
|
||||||
|
$apply = getenv('APPLY') === '1' || in_array('--apply', $cli_args, true);
|
||||||
|
$dry_run = in_array('--dry-run', $cli_args, true) || !$apply;
|
||||||
|
|
||||||
|
$backup_table = 'wp_fea_date_slug_posts_backup';
|
||||||
|
$where = "
|
||||||
|
post_type = 'post'
|
||||||
|
AND post_status = 'publish'
|
||||||
|
AND (
|
||||||
|
post_title REGEXP '^[0-9]{2}-[0-9]{2}-[0-9]{4}$'
|
||||||
|
OR post_name REGEXP '^[0-9]{2}-[0-9]{2}-[0-9]{4}$'
|
||||||
|
)
|
||||||
|
";
|
||||||
|
|
||||||
|
echo "=== Unpublish date-like migrated posts ===\n";
|
||||||
|
echo $dry_run ? "[DRY RUN]\n\n" : "[APPLY]\n\n";
|
||||||
|
|
||||||
|
if (isset($GLOBALS['wpdb']) && $GLOBALS['wpdb'] instanceof wpdb) {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$posts_table = $wpdb->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";
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
Regular → Executable
+41
-20
@@ -2,7 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* Plugin Name: Fe Adulta — Carta de la Semana
|
* Plugin Name: Fe Adulta — Carta de la Semana
|
||||||
* Description: Redirige las URLs de carta al archivo de categoría correspondiente.
|
* 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
|
// 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
|
// Las categorías de carta actual/anterior siempre llevan al post traducido que
|
||||||
// (stop-redirects.php desactiva redirect_canonical que haría esto automáticamente)
|
// 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() {
|
add_action("template_redirect", function() {
|
||||||
if (!is_category()) return;
|
if (!is_category()) return;
|
||||||
$cat = get_queried_object();
|
$cat = get_queried_object();
|
||||||
if (!$cat || $cat->count != 1) return;
|
if (!$cat || empty($cat->term_id)) return;
|
||||||
$posts = get_posts([
|
|
||||||
'cat' => $cat->term_id,
|
$source_cat_id = (int) $cat->term_id;
|
||||||
'numberposts' => 1,
|
if (function_exists('pll_get_term')) {
|
||||||
'post_status' => 'publish',
|
$spanish_cat_id = (int) pll_get_term($source_cat_id, 'es');
|
||||||
]);
|
if ($spanish_cat_id) $source_cat_id = $spanish_cat_id;
|
||||||
if (!$posts) return;
|
}
|
||||||
$post_id = $posts[0]->ID;
|
if (!in_array($source_cat_id, [6, 22], true)) return;
|
||||||
// Buscar la traducción al idioma actual
|
|
||||||
|
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')) {
|
if (function_exists('pll_current_language') && function_exists('pll_get_post')) {
|
||||||
$lang = pll_current_language();
|
$lang = pll_current_language();
|
||||||
if ($lang) {
|
$translated = $lang ? (int) pll_get_post($source_post_id, $lang) : 0;
|
||||||
$translated = pll_get_post($post_id, $lang);
|
|
||||||
if ($translated) $post_id = $translated;
|
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;
|
exit;
|
||||||
}, 9);
|
}, 9);
|
||||||
|
|
||||||
// Mostrar 50 artículos por página en los archivos de cartas
|
// Mostrar 50 artículos por página en los archivos de cartas
|
||||||
add_action("pre_get_posts", function($query) {
|
add_action("pre_get_posts", function($query) {
|
||||||
if (!$query->is_main_query() || is_admin()) return;
|
if (!$query->is_main_query() || is_admin()) return;
|
||||||
if ($query->is_category(["cartasemana", "cartas-de-otras-semanas", "carta-semana-pasada",
|
if ($query->is_category([
|
||||||
"cartas-de-otras-semanas-en", "cartas-de-otras-semanas-fr",
|
"cartasemana", "carta-semana-pasada", "cartas-de-otras-semanas",
|
||||||
"cartas-de-otras-semanas-it", "cartas-de-otras-semanas-pt",
|
"letter-of-the-week", "lettre-de-la-semaine", "lettera-della-settimana", "carta-da-semana",
|
||||||
"cartasemana-en", "cartasemana-fr", "cartasemana-it", "cartasemana-pt"])) {
|
"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);
|
$query->set("posts_per_page", 50);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fea Avatar Cache-bust (#81)
|
||||||
|
* Description: Añade ?v=<mtime> a las URLs de avatar servidas desde
|
||||||
|
* uploads/avatares/autores/autor-<uid>.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);
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta — Feedback Beta
|
||||||
|
* Description: Barra sutil de aviso "Beta" en todo el sitio + mini formulario (👍/👎 +
|
||||||
|
* comentario opcional) que se abre a demanda, para que el público ayude a
|
||||||
|
* encontrar errores. Guarda cada voto como "Beta Feedback" (CPT propio),
|
||||||
|
* legible en wp-admin en una sola lista. No usa el sistema de comentarios.
|
||||||
|
* Version: 1.1
|
||||||
|
*
|
||||||
|
* Ver issue rafa/feadulta#78.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
const FEA_FB_CPT = 'fea_feedback';
|
||||||
|
const FEA_FB_RATE_MAX = 12; // máximo de envíos por IP por hora
|
||||||
|
const FEA_FB_COMMENT_MAX = 2000;
|
||||||
|
|
||||||
|
/* ── 1) CPT donde se guardan los votos (solo backend) ─────────────────────── */
|
||||||
|
add_action('init', function () {
|
||||||
|
register_post_type(FEA_FB_CPT, [
|
||||||
|
'labels' => [
|
||||||
|
'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<id>\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 '<a href="' . esc_url($u) . '" target="_blank" rel="noopener">' . esc_html(wp_parse_url($u, PHP_URL_PATH) ?: $u) . '</a>';
|
||||||
|
} 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();
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
/* Barra sutil de aviso Beta, abajo, full-width */
|
||||||
|
#fea-beta-bar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 99997;
|
||||||
|
background: #faf6f2; border-top: 1px solid #e6ddd5; color: #4a3b34;
|
||||||
|
font-family: inherit; font-size: .86rem; line-height: 1.3;
|
||||||
|
padding: 8px 44px 8px 16px; text-align: center; }
|
||||||
|
#fea-beta-bar strong { color: #8b1a2e; }
|
||||||
|
#fea-beta-bar .fea-beta-open { margin-left: 10px; cursor: pointer; border: 1px solid #8b1a2e;
|
||||||
|
background: #8b1a2e; color: #fff; border-radius: 6px; padding: 4px 12px; font-size: .82rem; font-weight: 600; }
|
||||||
|
#fea-beta-bar .fea-beta-open:hover { background: #761526; }
|
||||||
|
#fea-beta-bar .fea-beta-collab { margin-left: 8px; cursor: pointer; display: inline-block;
|
||||||
|
border: 1px solid #1b7a34; background: #1b7a34; color: #fff; border-radius: 6px;
|
||||||
|
padding: 4px 12px; font-size: .82rem; font-weight: 600; text-decoration: none; }
|
||||||
|
#fea-beta-bar .fea-beta-collab:hover { background: #15642a; }
|
||||||
|
#fea-beta-bar .fea-beta-dismiss { position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
|
||||||
|
border: 0; background: none; font-size: 1.15rem; cursor: pointer; color: #8a7a72; padding: 2px 6px; line-height: 1; }
|
||||||
|
#fea-beta-bar.hidden { display: none; }
|
||||||
|
|
||||||
|
/* Tarjeta de feedback: oculta hasta que el usuario la abre desde la barra */
|
||||||
|
#fea-fb { position: fixed; right: 16px; bottom: 56px; z-index: 99998; font-family: inherit; max-width: 300px; }
|
||||||
|
#fea-fb[hidden] { display: none; }
|
||||||
|
#fea-fb .fea-fb-card { background:#fff; border:1px solid #e2e2e2; border-radius:12px;
|
||||||
|
box-shadow:0 8px 28px rgba(0,0,0,.16); padding:12px 14px; font-size:.9rem; color:#222; position:relative; }
|
||||||
|
#fea-fb .fea-fb-q { margin:0 0 8px; line-height:1.3; padding-right:16px; }
|
||||||
|
#fea-fb .fea-fb-btns { display:flex; gap:8px; }
|
||||||
|
#fea-fb button.fea-fb-vote { cursor:pointer; border:1px solid #ccc; background:#fafafa; border-radius:8px;
|
||||||
|
padding:6px 12px; font-size:1.05rem; line-height:1; }
|
||||||
|
#fea-fb button.fea-fb-vote:hover { background:#f0f0f0; }
|
||||||
|
#fea-fb button.fea-fb-vote.sel { border-color:#8b1a2e; background:#f7e9ec; }
|
||||||
|
#fea-fb textarea { width:100%; margin:9px 0 8px; border:1px solid #ccc; border-radius:8px; padding:7px;
|
||||||
|
font:inherit; font-size:.85rem; resize:vertical; min-height:58px; box-sizing:border-box; }
|
||||||
|
#fea-fb .fea-fb-send { background:#8b1a2e; color:#fff; border:1px solid #8b1a2e; border-radius:8px;
|
||||||
|
padding:6px 12px; font-size:.85rem; width:100%; cursor:pointer; }
|
||||||
|
#fea-fb .fea-fb-hp { position:absolute; left:-9999px; }
|
||||||
|
#fea-fb .fea-fb-close { position:absolute; top:4px; right:8px; border:0; background:none; font-size:1rem; cursor:pointer; padding:2px 4px; line-height:1; }
|
||||||
|
@media (max-width:600px){ #fea-fb{ right:10px; left:10px; max-width:none; } #fea-beta-bar{ font-size:.8rem; } }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="fea-beta-bar" class="hidden" role="region" aria-label="<?php echo esc_attr($t['region']); ?>">
|
||||||
|
🌱 <?php echo esc_html($t['intro']); ?> <strong>Beta</strong>. <?php echo esc_html($t['help']); ?>
|
||||||
|
<button type="button" class="fea-beta-open"><?php echo esc_html($t['opinion']); ?></button>
|
||||||
|
<a class="fea-beta-collab" href="https://edicionesfeadulta.com/colabora/" target="_blank" rel="noopener"><?php echo esc_html($t['collab']); ?></a>
|
||||||
|
<button type="button" class="fea-beta-dismiss" aria-label="<?php echo esc_attr($t['dismiss']); ?>">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="fea-fb" hidden role="complementary" aria-label="<?php echo esc_attr($t['fbregion']); ?>">
|
||||||
|
<div class="fea-fb-card">
|
||||||
|
<button type="button" class="fea-fb-close" aria-label="<?php echo esc_attr($t['close']); ?>">×</button>
|
||||||
|
<p class="fea-fb-q"><?php echo esc_html($t['q']); ?></p>
|
||||||
|
<div class="fea-fb-btns">
|
||||||
|
<button type="button" class="fea-fb-vote" data-vote="up" aria-label="<?php echo esc_attr($t['up']); ?>">👍</button>
|
||||||
|
<button type="button" class="fea-fb-vote" data-vote="down" aria-label="<?php echo esc_attr($t['down']); ?>">👎</button>
|
||||||
|
</div>
|
||||||
|
<div class="fea-fb-more" hidden>
|
||||||
|
<input type="text" class="fea-fb-hp" tabindex="-1" autocomplete="off" aria-hidden="true" placeholder="No rellenar">
|
||||||
|
<textarea placeholder="<?php echo esc_attr($t['ph']); ?>"></textarea>
|
||||||
|
<button type="button" class="fea-fb-send"><?php echo esc_html($t['send']); ?></button>
|
||||||
|
</div>
|
||||||
|
<div class="fea-fb-thanks" hidden><?php echo esc_html($t['thanks']); ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var bar = document.getElementById('fea-beta-bar');
|
||||||
|
var box = document.getElementById('fea-fb');
|
||||||
|
if(!bar || !box) return;
|
||||||
|
var REST = <?php echo json_encode($rest); ?>;
|
||||||
|
var pid = <?php echo (int) (is_singular() ? get_queried_object_id() : 0); ?>;
|
||||||
|
var lang = <?php echo json_encode(function_exists('pll_current_language') ? (string) pll_current_language() : ''); ?>;
|
||||||
|
var chosen = null;
|
||||||
|
var moreEl = box.querySelector('.fea-fb-more');
|
||||||
|
var votes = box.querySelectorAll('.fea-fb-vote');
|
||||||
|
var thanks = box.querySelector('.fea-fb-thanks');
|
||||||
|
|
||||||
|
// Mostrar la barra salvo que el usuario la haya descartado antes.
|
||||||
|
try { if (!localStorage.getItem('fea_beta_bar_off')) bar.classList.remove('hidden'); }
|
||||||
|
catch(e){ bar.classList.remove('hidden'); }
|
||||||
|
|
||||||
|
function openCard(){ box.hidden = false; }
|
||||||
|
function closeCard(){ box.hidden = true; }
|
||||||
|
|
||||||
|
bar.querySelector('.fea-beta-open').addEventListener('click', openCard);
|
||||||
|
bar.querySelector('.fea-beta-dismiss').addEventListener('click', function(){
|
||||||
|
bar.classList.add('hidden');
|
||||||
|
try { localStorage.setItem('fea_beta_bar_off','1'); } catch(e){}
|
||||||
|
});
|
||||||
|
box.querySelector('.fea-fb-close').addEventListener('click', closeCard);
|
||||||
|
|
||||||
|
votes.forEach(function(b){ b.addEventListener('click', function(){
|
||||||
|
chosen = b.getAttribute('data-vote');
|
||||||
|
votes.forEach(function(x){ x.classList.toggle('sel', x===b); });
|
||||||
|
moreEl.hidden = false;
|
||||||
|
});});
|
||||||
|
|
||||||
|
box.querySelector('.fea-fb-send').addEventListener('click', function(){
|
||||||
|
if(!chosen) return;
|
||||||
|
var hp = box.querySelector('.fea-fb-hp').value;
|
||||||
|
var comment = box.querySelector('textarea').value;
|
||||||
|
fetch(REST, { method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ vote:chosen, comment:comment, url:location.href, post_id:pid,
|
||||||
|
lang:lang, title:document.title, website:hp }) }).catch(function(){});
|
||||||
|
box.querySelector('.fea-fb-btns').hidden = true;
|
||||||
|
box.querySelector('.fea-fb-q').hidden = true;
|
||||||
|
moreEl.hidden = true; thanks.hidden = false;
|
||||||
|
setTimeout(closeCard, 2200);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
}, 40);
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta — API carta_id
|
||||||
|
* Description: Endpoint REST para que Inma y su asistente asignen el meta interno _carta_id.
|
||||||
|
* Version: 1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
add_action('rest_api_init', function() {
|
||||||
|
register_rest_route('fea/v1', '/carta-id/(?P<post_id>\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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -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
|
// 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).
|
// entorno → funciona en local (home en .../fea) y en prod (home en la raíz).
|
||||||
// No depende de un prefijo /fea/ hardcodeado (issue #91).
|
// No depende de un prefijo /fea/ hardcodeado (issue #91).
|
||||||
|
//
|
||||||
|
// 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);
|
$host = wp_parse_url($url, PHP_URL_HOST);
|
||||||
$home_host = wp_parse_url(home_url(), PHP_URL_HOST);
|
if ($host && !in_array(strtolower($host), $own_hosts, true)) {
|
||||||
if ($host && $home_host && strcasecmp($host, $home_host) !== 0) {
|
|
||||||
return null; // host externo → no es un artículo nuestro
|
return null; // host externo → no es un artículo nuestro
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: FEA Cloudflare Real IP
|
||||||
|
* Description: Restaura la IP real del visitante en REMOTE_ADDR cuando la conexión
|
||||||
|
* proviene de un rango oficial de Cloudflare, leyendo CF-Connecting-IP.
|
||||||
|
* Necesario para que plugins de seguridad (LLAR), analytics y comentarios
|
||||||
|
* vean la IP del usuario y no la del proxy de Cloudflare.
|
||||||
|
* Auto-correctivo: si la conexión NO viene de Cloudflare, no toca nada.
|
||||||
|
* Version: 1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rangos oficiales de Cloudflare (https://www.cloudflare.com/ips/).
|
||||||
|
$fea_cf_ipv4 = array(
|
||||||
|
'173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22',
|
||||||
|
'141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20',
|
||||||
|
'197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13',
|
||||||
|
'104.24.0.0/14', '172.64.0.0/13', '131.0.72.0/22',
|
||||||
|
);
|
||||||
|
$fea_cf_ipv6 = array(
|
||||||
|
'2400:cb00::/32', '2606:4700::/32', '2803:f800::/32', '2405:b500::/32',
|
||||||
|
'2405:8100::/32', '2a06:98c0::/29', '2c0f:f248::/32',
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( empty( $_SERVER['REMOTE_ADDR'] ) || empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fea_remote = $_SERVER['REMOTE_ADDR'];
|
||||||
|
$fea_client = trim( $_SERVER['HTTP_CF_CONNECTING_IP'] );
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comprueba si una IP está dentro de un CIDR (IPv4 o IPv6).
|
||||||
|
*/
|
||||||
|
function fea_ip_in_cidr( $ip, $cidr ) {
|
||||||
|
list( $subnet, $bits ) = array_pad( explode( '/', $cidr, 2 ), 2, null );
|
||||||
|
if ( null === $bits ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$bits = (int) $bits;
|
||||||
|
$ip_bin = @inet_pton( $ip );
|
||||||
|
$subnet_bin = @inet_pton( $subnet );
|
||||||
|
if ( false === $ip_bin || false === $subnet_bin || strlen( $ip_bin ) !== strlen( $subnet_bin ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$bytes = intdiv( $bits, 8 );
|
||||||
|
$rem = $bits % 8;
|
||||||
|
if ( $bytes > 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta - compact entry spacing
|
||||||
|
* Description: Ajusta el aire vertical de la navegacion de entradas y la paginacion de archivos.
|
||||||
|
*/
|
||||||
|
|
||||||
|
add_action('wp_head', function() {
|
||||||
|
if (is_admin()) return;
|
||||||
|
if (!(is_single() || is_archive() || is_search() || is_home())) return;
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
/* Issue #67: compactar el cierre del single post sin tocar el template FSE. */
|
||||||
|
body.single-post .wp-block-group.alignwide:has(> nav[aria-label="Navegación de entradas"]) {
|
||||||
|
margin-top: 1rem !important;
|
||||||
|
margin-bottom: 0.75rem !important;
|
||||||
|
}
|
||||||
|
body.single-post nav[aria-label="Navegación de entradas"] {
|
||||||
|
padding-top: 0.75rem !important;
|
||||||
|
padding-bottom: 0.75rem !important;
|
||||||
|
gap: 1rem !important;
|
||||||
|
}
|
||||||
|
body.single-post .wp-block-post-navigation-link {
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
body.single-post .wp-block-post-navigation-link a {
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
body.single-post .wp-block-group.alignwide:has(> .wp-block-heading + .wp-block-query) {
|
||||||
|
padding-top: 1rem !important;
|
||||||
|
padding-bottom: 1.25rem !important;
|
||||||
|
}
|
||||||
|
body.single-post .wp-block-group.alignwide:has(> .wp-block-heading + .wp-block-query) > .wp-block-heading {
|
||||||
|
margin-bottom: 0.65rem !important;
|
||||||
|
}
|
||||||
|
body.single-post .wp-block-group.alignwide:has(> .wp-block-heading + .wp-block-query) .wp-block-post-template > .wp-block-post {
|
||||||
|
margin-block-start: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Issue #67: paginacion de archivos/categorias ("Mas entradas" / siguiente pagina). */
|
||||||
|
body.archive .wp-block-query.alignwide > .wp-block-spacer,
|
||||||
|
body.search .wp-block-query.alignwide > .wp-block-spacer,
|
||||||
|
body.blog .wp-block-query.alignwide > .wp-block-spacer {
|
||||||
|
height: 0.5rem !important;
|
||||||
|
}
|
||||||
|
body.archive .wp-block-query.alignwide > .wp-block-group.alignfull:has(> .wp-block-query-pagination),
|
||||||
|
body.search .wp-block-query.alignwide > .wp-block-group.alignfull:has(> .wp-block-query-pagination),
|
||||||
|
body.blog .wp-block-query.alignwide > .wp-block-group.alignfull:has(> .wp-block-query-pagination) {
|
||||||
|
margin-top: 0.5rem !important;
|
||||||
|
margin-bottom: 0.75rem !important;
|
||||||
|
}
|
||||||
|
body.archive .wp-block-query-pagination,
|
||||||
|
body.search .wp-block-query-pagination,
|
||||||
|
body.blog .wp-block-query-pagination {
|
||||||
|
gap: 0.75rem 1.25rem !important;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
body.archive .wp-block-query-pagination-numbers,
|
||||||
|
body.search .wp-block-query-pagination-numbers,
|
||||||
|
body.blog .wp-block-query-pagination-numbers {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<?php
|
||||||
|
}, 30);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* fea-disable-comments — feadulta no usa comentarios.
|
||||||
|
* Defensivo: aunque un post quede con comment_status=open por accidente,
|
||||||
|
* el render trata comentarios y pings como cerrados y no muestra UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Comentarios y pings siempre cerrados en el frontend.
|
||||||
|
add_filter('comments_open', '__return_false', 20, 2);
|
||||||
|
add_filter('pings_open', '__return_false', 20, 2);
|
||||||
|
|
||||||
|
// No devolver comentarios existentes al render.
|
||||||
|
add_filter('comments_array', '__return_empty_array', 10, 2);
|
||||||
|
|
||||||
|
// Quitar el soporte de comentarios de los tipos de contenido.
|
||||||
|
add_action('init', function () {
|
||||||
|
remove_post_type_support('post', 'comments');
|
||||||
|
remove_post_type_support('post', 'trackbacks');
|
||||||
|
remove_post_type_support('page', 'comments');
|
||||||
|
remove_post_type_support('page', 'trackbacks');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Quitar "Comentarios" de la barra de admin.
|
||||||
|
add_action('wp_before_admin_bar_render', function () {
|
||||||
|
if (is_admin_bar_showing()) {
|
||||||
|
global $wp_admin_bar;
|
||||||
|
$wp_admin_bar->remove_menu('comments');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fea Search Console Verification
|
||||||
|
* Description: Mantiene la verificación de Google Search Console para
|
||||||
|
* www.feadulta.com tras el cutover Joomla -> 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 '<meta name="google-site-verification" content="wZ_edk-78QW3w8aXtiYpzTPEJ8_dh8MkhaIDSTq8U-8" />' . "\n";
|
||||||
|
}, 1);
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta - hide imported artifacts
|
||||||
|
* Description: Oculta en frontend artefactos importados hasta decidir una limpieza definitiva.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function fea_current_request_path() {
|
||||||
|
$path = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH);
|
||||||
|
return is_string($path) ? trim($path, '/') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_is_bad_imported_request_path() {
|
||||||
|
$path = fea_current_request_path();
|
||||||
|
return (bool)preg_match('~(^|/)tag/1/?$~', $path)
|
||||||
|
|| (bool)preg_match('~(^|/)[0-9]{2}-[0-9]{2}-[0-9]{4}/?$~', $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_is_bad_imported_tag($term) {
|
||||||
|
return $term
|
||||||
|
&& isset($term->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);
|
||||||
@@ -790,9 +790,40 @@ function fea_title(string $title): string {
|
|||||||
$out = preg_replace_callback('/([\/:¿¡] *)(\p{Ll})/u', function ($m) {
|
$out = preg_replace_callback('/([\/:¿¡] *)(\p{Ll})/u', function ($m) {
|
||||||
return $m[1] . mb_strtoupper($m[2], 'UTF-8');
|
return $m[1] . mb_strtoupper($m[2], 'UTF-8');
|
||||||
}, $out);
|
}, $out);
|
||||||
|
$out = fea_recapitalizar_nombres_propios($out);
|
||||||
return $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 = '/(?<![\p{L}])(' . $alt . ')(?![\p{L}])/iu';
|
||||||
|
$map = [];
|
||||||
|
foreach ($nombres as $n) $map[mb_strtolower($n, 'UTF-8')] = $n;
|
||||||
|
}
|
||||||
|
return preg_replace_callback($regex, function ($m) use ($map) {
|
||||||
|
$k = mb_strtolower($m[1], 'UTF-8');
|
||||||
|
return $map[$k] ?? $m[1];
|
||||||
|
}, $texto);
|
||||||
|
}
|
||||||
|
|
||||||
/** Lista de libros bíblicos (para avatar genérico de lecturas/eucaristías). #61 */
|
/** Lista de libros bíblicos (para avatar genérico de lecturas/eucaristías). #61 */
|
||||||
function fea_libros_biblicos(): array {
|
function fea_libros_biblicos(): array {
|
||||||
return [
|
return [
|
||||||
@@ -818,6 +849,14 @@ function fea_evangelista_de_texto(string $txt): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Devuelve 'antiguo-testamento' / 'nuevo-testamento' si el texto es esa firma, o ''. #66 */
|
||||||
|
function fea_testamento_de_texto(string $txt): string {
|
||||||
|
$t = mb_strtolower(trim($txt), 'UTF-8');
|
||||||
|
if ($t === 'antiguo testamento') return 'antiguo-testamento';
|
||||||
|
if ($t === 'nuevo testamento') return 'nuevo-testamento';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
/** True si el texto empieza por un libro bíblico (cita) o es el nombre de un libro. #61 */
|
/** True si el texto empieza por un libro bíblico (cita) o es el nombre de un libro. #61 */
|
||||||
function fea_es_libro_biblico(string $txt): bool {
|
function fea_es_libro_biblico(string $txt): bool {
|
||||||
$txt = trim($txt);
|
$txt = trim($txt);
|
||||||
@@ -850,6 +889,8 @@ function fea_avatar_url(object $post, int $size, int $author_id, string $author_
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($ev = fea_evangelista_de_texto($title)) return $base . $ev . '.svg';
|
if ($ev = fea_evangelista_de_texto($title)) return $base . $ev . '.svg';
|
||||||
|
// "Antiguo Testamento" / "Nuevo Testamento" llevan símbolo propio (no el genérico). #66
|
||||||
|
if ($t = fea_testamento_de_texto($title) ?: fea_testamento_de_texto($author_name)) return $base . $t . '.svg';
|
||||||
if (fea_es_libro_biblico($title) || fea_es_libro_biblico($author_name)) return $base . 'biblia.svg';
|
if (fea_es_libro_biblico($title) || fea_es_libro_biblico($author_name)) return $base . 'biblia.svg';
|
||||||
return get_avatar_url($author_id, ['size' => $size, 'default' => 'identicon']);
|
return get_avatar_url($author_id, ['size' => $size, 'default' => 'identicon']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* URLs de Joomla que no tengan equivalente en WordPress (404 genuino) se
|
||||||
|
* redirigen a antiguo.feadulta.com, donde Joomla se sigue sirviendo tras el
|
||||||
|
* cutover, en vez de mostrar el 404 de WordPress.
|
||||||
|
*/
|
||||||
|
|
||||||
|
add_action('template_redirect', function () {
|
||||||
|
if (!is_404()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = untrailingslashit(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
|
||||||
|
|
||||||
|
// La antigua home de Joomla (con prefijo de idioma /es/) debe llevar a
|
||||||
|
// la home nueva, no a la web antigua.
|
||||||
|
if ($path === '/es') {
|
||||||
|
wp_redirect(home_url('/'), 301);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_redirect('https://antiguo.feadulta.com' . $_SERVER['REQUEST_URI'], 301);
|
||||||
|
exit;
|
||||||
|
}, 0);
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* fea-menu-i18n — Traducción de los menús FSE (issue #120).
|
||||||
|
*
|
||||||
|
* Polylang FREE no traduce los bloques `wp_navigation` (menús del header y pie).
|
||||||
|
* Este plugin engancha `render_block` sobre cada `core/navigation-link` /
|
||||||
|
* `core/navigation-submenu` y, cuando el idioma actual ≠ es:
|
||||||
|
* 1) sustituye la ETIQUETA por su traducción (mapa de abajo, hecho a mano),
|
||||||
|
* 2) remapea la URL al destino traducido si existe (post/página/categoría via
|
||||||
|
* Polylang); si no hay traducción del destino, deja la URL ES (fallback).
|
||||||
|
*
|
||||||
|
* Las etiquetas son cortas y de contexto religioso → traducidas a mano para
|
||||||
|
* máxima calidad (el contenido largo de #120 va por MiniMax + glosario).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// NOTA: los mu-plugins cargan antes que Polylang → NO comprobar pll_* a nivel de
|
||||||
|
// fichero (abortaría). Se comprueba dentro del filtro, en render (ya cargado).
|
||||||
|
|
||||||
|
/** Mapa etiqueta ES => [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/<slug>. 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);
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* fea-pensamientos — Galerías (Pensamientos, jornadas) y pausa aleatoria en artículos.
|
||||||
|
*
|
||||||
|
* Las imágenes viven en wp-content/uploads/joomla-galleries/ (copiadas de Joomla
|
||||||
|
* el 2026-07-08 tras el cutover) — WordPress ya no depende del filesystem de
|
||||||
|
* Joomla/antiguo.feadulta.com para servirlas.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('FEA_JOOMLA_IMAGES_DIR')) {
|
||||||
|
define('FEA_JOOMLA_IMAGES_DIR', WP_CONTENT_DIR . '/uploads/joomla-galleries');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('FEA_JOOMLA_IMAGES_URL')) {
|
||||||
|
define('FEA_JOOMLA_IMAGES_URL', content_url('uploads/joomla-galleries'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('FEA_PENS_DIR')) {
|
||||||
|
define('FEA_PENS_DIR', rtrim(FEA_JOOMLA_IMAGES_DIR, '/') . '/Pensamientos');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('FEA_PENS_URL')) {
|
||||||
|
define('FEA_PENS_URL', rtrim(FEA_JOOMLA_IMAGES_URL, '/') . '/Pensamientos');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('FEA_GALLERY_PER_PAGE')) {
|
||||||
|
define('FEA_GALLERY_PER_PAGE', 72);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('FEA_RANDOM_THOUGHT_EXCLUDED_CATS')) {
|
||||||
|
// Categorías que NO muestran pensamiento aleatorio:
|
||||||
|
// 1645 Lecturas bíblicas · 28 Evangelios y comentarios (textos del evangelio)
|
||||||
|
// 20 Presentación colaboradores (fichas de colaboradores)
|
||||||
|
// 1647 Comentarios al evangelio SÍ muestra pensamiento (decisión Rafa 2026-06-19).
|
||||||
|
define('FEA_RANDOM_THOUGHT_EXCLUDED_CATS', '1645,28,20');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('FEA_RANDOM_THOUGHT_EXCLUDED_IDS')) {
|
||||||
|
// Posts estructurales (páginas disfrazadas de post) que no deben llevar pensamiento.
|
||||||
|
// 17563 = índice /colaboradores/ (está en «Sin categoría», no lo cubre la cat 20).
|
||||||
|
define('FEA_RANDOM_THOUGHT_EXCLUDED_IDS', '17563');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('FEA_GALLERY_MANIFEST')) {
|
||||||
|
define('FEA_GALLERY_MANIFEST', WP_CONTENT_DIR . '/uploads/fea-gallery-manifest.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_gallery_safe_dir(string $dir): string {
|
||||||
|
$dir = trim($dir);
|
||||||
|
$dir = trim($dir, "/\\ \t\n\r\0\x0B");
|
||||||
|
return preg_replace('/[^A-Za-z0-9._-]/', '', $dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_gallery_base_dir(): string {
|
||||||
|
return rtrim(FEA_JOOMLA_IMAGES_DIR, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_gallery_base_url(): string {
|
||||||
|
return rtrim(FEA_JOOMLA_IMAGES_URL, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_gallery_manifest_files(string $dir, string $order = 'desc'): array {
|
||||||
|
static $manifest = null;
|
||||||
|
|
||||||
|
if ($manifest === null) {
|
||||||
|
$manifest = [];
|
||||||
|
$path = (string) FEA_GALLERY_MANIFEST;
|
||||||
|
if (is_readable($path)) {
|
||||||
|
$decoded = json_decode((string) file_get_contents($path), true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$manifest = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($manifest[$dir]) || !is_array($manifest[$dir])) return [];
|
||||||
|
|
||||||
|
$files = array_values(array_filter(array_map('strval', $manifest[$dir]), function ($file) {
|
||||||
|
return preg_match('/\.(jpe?g|png|gif|webp)$/i', $file);
|
||||||
|
}));
|
||||||
|
natsort($files);
|
||||||
|
$files = array_values($files);
|
||||||
|
if ($order === 'desc') {
|
||||||
|
$files = array_reverse($files);
|
||||||
|
}
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_gallery_files(string $dir, string $order = 'desc'): array {
|
||||||
|
$dir = fea_gallery_safe_dir($dir);
|
||||||
|
if ($dir === '') return [];
|
||||||
|
|
||||||
|
$path = fea_gallery_base_dir() . '/' . $dir;
|
||||||
|
if (!is_dir($path) || !is_readable($path)) {
|
||||||
|
return fea_gallery_manifest_files($dir, $order);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mtime = (int) @filemtime($path);
|
||||||
|
$cache_key = 'fea_gallery_' . md5($path . '|' . $mtime . '|' . $order);
|
||||||
|
$cached = get_transient($cache_key);
|
||||||
|
if (is_array($cached)) return $cached;
|
||||||
|
|
||||||
|
$files = [];
|
||||||
|
$entries = @scandir($path);
|
||||||
|
if (!is_array($entries)) return [];
|
||||||
|
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
if (!preg_match('/\.(jpe?g|png|gif|webp)$/i', $entry)) continue;
|
||||||
|
if (!is_file($path . '/' . $entry)) continue;
|
||||||
|
$files[] = $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
natsort($files);
|
||||||
|
$files = array_values($files);
|
||||||
|
if ($order === 'desc') {
|
||||||
|
$files = array_reverse($files);
|
||||||
|
}
|
||||||
|
|
||||||
|
set_transient($cache_key, $files, 10 * MINUTE_IN_SECONDS);
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_gallery_url(string $dir, string $file): string {
|
||||||
|
return fea_gallery_base_url() . '/' . rawurlencode(fea_gallery_safe_dir($dir)) . '/' . rawurlencode($file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_gallery_page_param(string $dir): string {
|
||||||
|
return 'fea_gallery_' . substr(md5($dir), 0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_gallery_render(array $atts = []): string {
|
||||||
|
$atts = shortcode_atts([
|
||||||
|
'dir' => '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 '<p class="fea-gallery-empty">Galería no disponible.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 = '<div class="fea-gallery" data-fea-gallery="' . esc_attr($dir) . '">';
|
||||||
|
$html .= '<div class="fea-gallery-grid">';
|
||||||
|
foreach ($visible as $file) {
|
||||||
|
$url = fea_gallery_url($dir, $file);
|
||||||
|
$alt = preg_replace('/\.[^.]+$/', '', $file);
|
||||||
|
$html .= '<a class="fea-gallery-item" href="' . esc_url($url) . '" data-fea-lightbox="1">';
|
||||||
|
$html .= '<img src="' . esc_url($url) . '" alt="' . esc_attr($alt) . '" loading="lazy" decoding="async">';
|
||||||
|
$html .= '</a>';
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
if ($pages > 1) {
|
||||||
|
$html .= '<nav class="fea-gallery-pages" aria-label="Paginación de galería">';
|
||||||
|
if ($page > 1) {
|
||||||
|
$html .= '<a href="' . esc_url(add_query_arg($param, $page - 1)) . '">Anterior</a>';
|
||||||
|
}
|
||||||
|
$html .= '<span>Página ' . esc_html((string) $page) . ' de ' . esc_html((string) $pages) . '</span>';
|
||||||
|
if ($page < $pages) {
|
||||||
|
$html .= '<a href="' . esc_url(add_query_arg($param, $page + 1)) . '">Siguiente</a>';
|
||||||
|
}
|
||||||
|
$html .= '</nav>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
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 '<aside class="fea-random-thought" aria-label="Una pausa">'
|
||||||
|
. '<div class="fea-random-thought-title"><span></span><strong>Una pausa para el alma</strong><span></span></div>'
|
||||||
|
. '<a href="' . esc_url($url) . '" data-fea-lightbox="1">'
|
||||||
|
. '<img src="' . esc_url($url) . '" alt="Pensamiento aleatorio" loading="lazy" decoding="async">'
|
||||||
|
. '</a></aside>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
.fea-gallery {
|
||||||
|
width: min(1180px, 100%);
|
||||||
|
max-width: none;
|
||||||
|
margin: 1.5rem auto 2rem;
|
||||||
|
}
|
||||||
|
.fea-gallery .fea-gallery-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
|
||||||
|
gap: 0.65rem !important;
|
||||||
|
}
|
||||||
|
.fea-gallery-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
aspect-ratio: 275 / 160;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.07);
|
||||||
|
}
|
||||||
|
.fea-gallery-item img {
|
||||||
|
width: 98.5%;
|
||||||
|
height: 96.5%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
transition: transform 0.16s ease;
|
||||||
|
}
|
||||||
|
.fea-gallery-item:hover img { transform: scale(1.02); }
|
||||||
|
.fea-gallery-pages {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.fea-gallery-pages a {
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.fea-random-thought {
|
||||||
|
margin: 2.4rem auto 1.4rem;
|
||||||
|
max-width: 720px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.fea-random-thought-title {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #7f1d1d;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.fea-random-thought-title span {
|
||||||
|
height: 1px;
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.28;
|
||||||
|
}
|
||||||
|
.fea-random-thought a { display: inline-block; max-width: min(100%, 560px); }
|
||||||
|
.fea-random-thought img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.14);
|
||||||
|
}
|
||||||
|
.fea-lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 99999;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4rem 5rem;
|
||||||
|
background: rgba(0,0,0,0.86);
|
||||||
|
}
|
||||||
|
.fea-lightbox.is-open { display: flex; }
|
||||||
|
.fea-lightbox-frame {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.fea-lightbox-frame img {
|
||||||
|
max-width: min(100%, 1100px);
|
||||||
|
max-height: calc(100vh - 8rem);
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 18px 60px rgba(0,0,0,0.45);
|
||||||
|
}
|
||||||
|
.fea-lightbox button {
|
||||||
|
position: absolute;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.92);
|
||||||
|
color: #111;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.fea-lightbox-close {
|
||||||
|
top: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
.fea-lightbox-prev,
|
||||||
|
.fea-lightbox-next {
|
||||||
|
top: 50%;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
.fea-lightbox-prev { left: 1rem; }
|
||||||
|
.fea-lightbox-next { right: 1rem; }
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.fea-gallery { width: min(100%, calc(100vw - 1rem)); }
|
||||||
|
.fea-gallery .fea-gallery-grid { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
|
||||||
|
.fea-lightbox { padding: 3.5rem 1rem; }
|
||||||
|
.fea-lightbox-prev,
|
||||||
|
.fea-lightbox-next {
|
||||||
|
top: auto;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var links = Array.prototype.slice.call(document.querySelectorAll('[data-fea-lightbox="1"]'));
|
||||||
|
if (!links.length) return;
|
||||||
|
|
||||||
|
var box = document.createElement('div');
|
||||||
|
box.className = 'fea-lightbox';
|
||||||
|
box.innerHTML = '<button type="button" class="fea-lightbox-close" aria-label="Cerrar">×</button><button type="button" class="fea-lightbox-prev" aria-label="Anterior">‹</button><div class="fea-lightbox-frame"><img alt=""></div><button type="button" class="fea-lightbox-next" aria-label="Siguiente">›</button>';
|
||||||
|
document.body.appendChild(box);
|
||||||
|
|
||||||
|
var img = box.querySelector('img');
|
||||||
|
var closeButton = box.querySelector('.fea-lightbox-close');
|
||||||
|
var prevButton = box.querySelector('.fea-lightbox-prev');
|
||||||
|
var nextButton = box.querySelector('.fea-lightbox-next');
|
||||||
|
var index = 0;
|
||||||
|
|
||||||
|
var show = function (nextIndex) {
|
||||||
|
index = (nextIndex + links.length) % links.length;
|
||||||
|
img.src = links[index].href;
|
||||||
|
img.alt = links[index].querySelector('img') ? links[index].querySelector('img').alt : '';
|
||||||
|
box.classList.add('is-open');
|
||||||
|
};
|
||||||
|
var close = function () {
|
||||||
|
box.classList.remove('is-open');
|
||||||
|
img.removeAttribute('src');
|
||||||
|
};
|
||||||
|
|
||||||
|
box.addEventListener('click', function (event) {
|
||||||
|
if (event.target === box) close();
|
||||||
|
});
|
||||||
|
closeButton.addEventListener('click', close);
|
||||||
|
prevButton.addEventListener('click', function () { show(index - 1); });
|
||||||
|
nextButton.addEventListener('click', function () { show(index + 1); });
|
||||||
|
document.addEventListener('keydown', function (event) {
|
||||||
|
if (!box.classList.contains('is-open')) return;
|
||||||
|
if (event.key === 'Escape') close();
|
||||||
|
if (event.key === 'ArrowLeft') show(index - 1);
|
||||||
|
if (event.key === 'ArrowRight') show(index + 1);
|
||||||
|
});
|
||||||
|
links.forEach(function (link, linkIndex) {
|
||||||
|
link.addEventListener('click', function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
show(linkIndex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
}, 30);
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* fea-recopilatorios — Listados dinámicos auto-actualizables (issues #96-#118).
|
||||||
|
*
|
||||||
|
* Sustituye las páginas-recopilatorio MANUALES de Joomla (tablas/listas de
|
||||||
|
* enlaces mantenidas a mano) por un listado generado desde una categoría.
|
||||||
|
* Así, cada carta/post nuevo que entre en la categoría aparece solo, sin
|
||||||
|
* copiar y pegar.
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* [fea_recopilatorio cat="1648"] (por term_id)
|
||||||
|
* [fea_recopilatorio cat="eucaristia"] (por slug)
|
||||||
|
* [fea_recopilatorio cat="1648" per_page="150" group="year" order="desc"]
|
||||||
|
*
|
||||||
|
* - group="year" (def.): separadores por año. group="none": lista plana.
|
||||||
|
* - Paginación propia (?recop=N) para no chocar con la paginación del tema.
|
||||||
|
* - Títulos normalizados con fea_title() si existe (legacy en MAYÚSCULAS).
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('FEA_RECOP_DEFAULT_PER_PAGE')) {
|
||||||
|
define('FEA_RECOP_DEFAULT_PER_PAGE', 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_recop_resolve_term($cat): int {
|
||||||
|
$cat = trim((string) $cat);
|
||||||
|
if ($cat === '') return 0;
|
||||||
|
if (ctype_digit($cat)) return (int) $cat;
|
||||||
|
$t = get_term_by('slug', $cat, 'category');
|
||||||
|
return $t ? (int) $t->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 '<p class="fea-recop-empty">Recopilatorio no disponible.</p>';
|
||||||
|
|
||||||
|
$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 '<p class="fea-recop-empty">Todavía no hay entradas en esta sección.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$by_year = ($atts['group'] === 'year');
|
||||||
|
$html = '<div class="fea-recop">';
|
||||||
|
$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 .= '</ul>';
|
||||||
|
$html .= '<h3 class="fea-recop-year">' . esc_html($y) . '</h3><ul class="fea-recop-list">';
|
||||||
|
$cur_year = $y; $open = true;
|
||||||
|
}
|
||||||
|
} elseif (!$open) {
|
||||||
|
$html .= '<ul class="fea-recop-list">'; $open = true;
|
||||||
|
}
|
||||||
|
$title = fea_recop_title(get_the_title());
|
||||||
|
$html .= '<li><a href="' . esc_url(get_permalink()) . '">' . esc_html($title) . '</a>'
|
||||||
|
. ' <span class="fea-recop-date">' . esc_html(get_the_date('j M Y')) . '</span></li>';
|
||||||
|
}
|
||||||
|
if ($open) $html .= '</ul>';
|
||||||
|
wp_reset_postdata();
|
||||||
|
|
||||||
|
// Paginación propia
|
||||||
|
$total_pages = (int) $q->max_num_pages;
|
||||||
|
if ($total_pages > 1) {
|
||||||
|
$html .= '<nav class="fea-recop-pages" aria-label="Paginación del recopilatorio">';
|
||||||
|
if ($paged > 1) {
|
||||||
|
$html .= '<a href="' . esc_url(add_query_arg('recop', $paged - 1)) . '">Anterior</a>';
|
||||||
|
}
|
||||||
|
$html .= '<span>Página ' . $paged . ' de ' . $total_pages . '</span>';
|
||||||
|
if ($paged < $total_pages) {
|
||||||
|
$html .= '<a href="' . esc_url(add_query_arg('recop', $paged + 1)) . '">Siguiente</a>';
|
||||||
|
}
|
||||||
|
$html .= '</nav>';
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
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('~<img[^>]+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 '<p class="fea-recop-empty">Todavía no hay multimedia disponible.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = '<div class="fea-mm-wrap"><div class="fea-mm-grid">';
|
||||||
|
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 = '<img class="fea-mm-img" src="' . esc_url($prev['src']) . '" alt="" loading="lazy" '
|
||||||
|
. 'onerror="this.style.display=\'none\';this.parentNode.classList.add(\'fea-mm-noimg\');">';
|
||||||
|
}
|
||||||
|
$cls = 'fea-mm-thumb' . ($prev['src'] === '' ? ' fea-mm-noimg' : '');
|
||||||
|
$play = $prev['type'] === 'video'
|
||||||
|
? '<span class="fea-mm-play" aria-hidden="true"></span>' : '';
|
||||||
|
|
||||||
|
$html .= '<a class="fea-mm-card" href="' . esc_url($url) . '">'
|
||||||
|
. '<span class="' . $cls . '">' . $thumb . $play . '</span>'
|
||||||
|
. '<span class="fea-mm-body">'
|
||||||
|
. '<span class="fea-mm-title">' . esc_html($title) . '</span>'
|
||||||
|
. '<span class="fea-mm-date">' . esc_html(get_the_date('j M Y')) . '</span>'
|
||||||
|
. '<span class="fea-mm-excerpt">' . esc_html($excerpt) . '</span>'
|
||||||
|
. '</span></a>';
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
$total_pages = (int) $q->max_num_pages;
|
||||||
|
wp_reset_postdata();
|
||||||
|
if ($total_pages > 1) {
|
||||||
|
$html .= '<nav class="fea-recop-pages" aria-label="Paginación de multimedia">';
|
||||||
|
if ($paged > 1) $html .= '<a href="' . esc_url(add_query_arg('mmpag', $paged - 1)) . '">Anterior</a>';
|
||||||
|
$html .= '<span>Página ' . $paged . ' de ' . $total_pages . '</span>';
|
||||||
|
if ($paged < $total_pages) $html .= '<a href="' . esc_url(add_query_arg('mmpag', $paged + 1)) . '">Siguiente</a>';
|
||||||
|
$html .= '</nav>';
|
||||||
|
}
|
||||||
|
$html .= '</div>'; // .fea-mm-wrap
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
add_shortcode('fea_multimedia_indice', 'fea_mm_indice_render');
|
||||||
|
|
||||||
|
add_action('wp_head', function () {
|
||||||
|
if (is_admin()) return;
|
||||||
|
?>
|
||||||
|
<style id="fea-recop-css">
|
||||||
|
.fea-recop { margin: 1.5rem 0; }
|
||||||
|
.fea-recop-year {
|
||||||
|
font-family: 'Fraunces', Georgia, serif; font-weight: 600;
|
||||||
|
color: #8b1a2e; margin: 1.6rem 0 0.6rem; font-size: 1.3rem;
|
||||||
|
border-bottom: 1px solid #efe7d8; padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.fea-recop-list { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.fea-recop-list li {
|
||||||
|
padding: 0.35rem 0; border-bottom: 1px solid #f4eee2;
|
||||||
|
display: flex; justify-content: space-between; gap: 1rem; align-items: baseline;
|
||||||
|
}
|
||||||
|
.fea-recop-list a { text-decoration: none; color: #2a2320; }
|
||||||
|
.fea-recop-list a:hover { color: #8b1a2e; text-decoration: underline; }
|
||||||
|
.fea-recop-date { color: #998; font-size: 0.82rem; white-space: nowrap; }
|
||||||
|
.fea-recop-pages {
|
||||||
|
display: flex; gap: 1rem; align-items: center; justify-content: center;
|
||||||
|
margin-top: 1.4rem; font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.fea-recop-pages a {
|
||||||
|
padding: 0.4rem 0.8rem; border: 1px solid #8b1a2e; border-radius: 6px;
|
||||||
|
text-decoration: none; color: #8b1a2e;
|
||||||
|
}
|
||||||
|
/* Galería multimedia (#110) */
|
||||||
|
/* wrapper: el padre es entry-content alignfull (ancho completo), así que basta
|
||||||
|
centrar con margin:auto; max-width:none vence el cap de is-layout-constrained */
|
||||||
|
.fea-mm-wrap {
|
||||||
|
width: min(1180px, 100%);
|
||||||
|
max-width: none;
|
||||||
|
margin: 1.5rem auto 2rem;
|
||||||
|
}
|
||||||
|
.fea-mm-grid {
|
||||||
|
display: grid; gap: 1.3rem;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
@media (min-width: 520px) { .fea-mm-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
|
@media (min-width: 760px) { .fea-mm-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||||
|
@media (max-width: 700px) { .fea-mm-wrap { width: min(100%, calc(100vw - 1rem)); } }
|
||||||
|
.fea-mm-card {
|
||||||
|
display: flex; flex-direction: column; text-decoration: none;
|
||||||
|
background: #fff; border: 1px solid #efe7d8; border-radius: 10px;
|
||||||
|
overflow: hidden; transition: box-shadow .15s, transform .15s;
|
||||||
|
}
|
||||||
|
.fea-mm-card:hover { box-shadow: 0 6px 18px rgba(139,26,46,.13); transform: translateY(-2px); }
|
||||||
|
.fea-mm-thumb {
|
||||||
|
position: relative; display: block; aspect-ratio: 16/9; background: #f4eee2;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.fea-mm-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.fea-mm-noimg {
|
||||||
|
background: linear-gradient(135deg, #8b1a2e 0%, #b34255 100%);
|
||||||
|
}
|
||||||
|
.fea-mm-play {
|
||||||
|
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||||
|
width: 54px; height: 54px; border-radius: 50%;
|
||||||
|
background: rgba(0,0,0,.55); pointer-events: none;
|
||||||
|
}
|
||||||
|
.fea-mm-play::after {
|
||||||
|
content: ''; position: absolute; top: 50%; left: 54%; transform: translate(-50%, -50%);
|
||||||
|
border-style: solid; border-width: 11px 0 11px 18px;
|
||||||
|
border-color: transparent transparent transparent #fff;
|
||||||
|
}
|
||||||
|
.fea-mm-card:hover .fea-mm-play { background: rgba(139,26,46,.85); }
|
||||||
|
.fea-mm-body { padding: 0.8rem 0.9rem 1rem; display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
|
.fea-mm-title {
|
||||||
|
font-family: 'Fraunces', Georgia, serif; font-weight: 600; color: #2a2320;
|
||||||
|
font-size: 1.02rem; line-height: 1.25;
|
||||||
|
}
|
||||||
|
.fea-mm-card:hover .fea-mm-title { color: #8b1a2e; }
|
||||||
|
.fea-mm-date { color: #998; font-size: 0.78rem; }
|
||||||
|
.fea-mm-excerpt { color: #5a534e; font-size: 0.85rem; line-height: 1.4; margin-top: 0.15rem; }
|
||||||
|
</style>
|
||||||
|
<?php
|
||||||
|
}, 26);
|
||||||
+336
@@ -0,0 +1,336 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta — Support Campaign
|
||||||
|
* Description: Landing y banner discreto de apoyo económico para la migración de Fe Adulta.
|
||||||
|
* Version: 1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEA_SUPPORT_TEMPLATE = 'fea-support-campaign.php';
|
||||||
|
const FEA_SUPPORT_TEMPLATE_LABEL = 'Fe Adulta — Campaña de apoyo';
|
||||||
|
|
||||||
|
function fea_support_template_path(): string {
|
||||||
|
return __DIR__ . '/fea-support-campaign/template.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_support_is_spanish_context(): bool {
|
||||||
|
if (!function_exists('pll_current_language')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pll_current_language() === 'es';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_support_campaign_page(): ?WP_Post {
|
||||||
|
static $page = 'unset';
|
||||||
|
|
||||||
|
if ($page !== 'unset') {
|
||||||
|
return $page instanceof WP_Post ? $page : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pages = get_posts([
|
||||||
|
'post_type' => '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 = '<div class="fea-support-actions fea-support-actions--' . esc_attr($context) . '">';
|
||||||
|
foreach ($buttons as $key => $config) {
|
||||||
|
if (empty($data[$key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '<a class="fea-support-button ' . esc_attr($config['class']) . '" href="'
|
||||||
|
. esc_url($data[$key]) . '" target="_blank" rel="noopener">'
|
||||||
|
. esc_html($config['label']) . '</a>';
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
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 = '<div class="fea-support-progress fea-support-progress--' . esc_attr($context) . '">';
|
||||||
|
$html .= '<div class="fea-support-progress__numbers">';
|
||||||
|
$html .= '<strong>' . esc_html($summary) . '</strong>';
|
||||||
|
$html .= '<span>' . esc_html(number_format_i18n($percent, 0)) . '%</span>';
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '<div class="fea-support-progress__track" aria-hidden="true">';
|
||||||
|
$html .= '<span class="fea-support-progress__fill" style="width:' . esc_attr(number_format($percent, 2, '.', '')) . '%"></span>';
|
||||||
|
$html .= '</div>';
|
||||||
|
if (!empty($data['progress_note'])) {
|
||||||
|
$html .= '<p class="fea-support-progress__note">' . esc_html($data['progress_note']) . '</p>';
|
||||||
|
}
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
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 = '<section class="fea-support-banner" aria-label="Campaña de apoyo económico">';
|
||||||
|
$html .= '<div class="fea-support-banner__copy">';
|
||||||
|
$html .= '<span class="fea-support-banner__eyebrow">' . esc_html($data['eyebrow']) . '</span>';
|
||||||
|
$html .= '<h2 class="fea-support-banner__title">' . esc_html($data['banner_title']) . '</h2>';
|
||||||
|
$html .= '<p class="fea-support-banner__text">' . esc_html($data['banner_text']) . '</p>';
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '<div class="fea-support-banner__side">';
|
||||||
|
$html .= fea_support_progress_html($data, 'banner');
|
||||||
|
$html .= '<div class="fea-support-banner__links">';
|
||||||
|
$html .= '<a class="fea-support-button is-primary" href="' . esc_url($url) . '">Ver la campaña</a>';
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= '</section>';
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
<?php
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
get_header();
|
||||||
|
the_post();
|
||||||
|
|
||||||
|
$data = fea_support_campaign_data(get_the_ID());
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
.fea-support-page {
|
||||||
|
--fea-support-burgundy: #8b1a2e;
|
||||||
|
--fea-support-ink: #2a2320;
|
||||||
|
--fea-support-warm: #f5efe7;
|
||||||
|
--fea-support-line: #e6d8c5;
|
||||||
|
--fea-support-card: #fffdf9;
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2.5rem 1.25rem 4.5rem;
|
||||||
|
color: var(--fea-support-ink);
|
||||||
|
}
|
||||||
|
.fea-support-hero {
|
||||||
|
background: linear-gradient(180deg, #f3ece3 0%, #fbf7f1 100%);
|
||||||
|
border: 1px solid var(--fea-support-line);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 2rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.8fr);
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
.fea-support-hero__eyebrow,
|
||||||
|
.fea-support-banner__eyebrow {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
color: var(--fea-support-burgundy);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.fea-support-hero__title {
|
||||||
|
font-family: "Fraunces", Georgia, serif;
|
||||||
|
font-size: clamp(2rem, 4vw, 3.35rem);
|
||||||
|
line-height: 1.05;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
.fea-support-hero__intro {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.03rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
max-width: 58ch;
|
||||||
|
}
|
||||||
|
.fea-support-card {
|
||||||
|
background: var(--fea-support-card);
|
||||||
|
border: 1px solid var(--fea-support-line);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: 0 18px 40px -34px rgba(42, 35, 32, 0.55);
|
||||||
|
}
|
||||||
|
.fea-support-card__title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
color: #6f655c;
|
||||||
|
}
|
||||||
|
.fea-support-progress {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.fea-support-progress__numbers {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.55rem;
|
||||||
|
}
|
||||||
|
.fea-support-progress__numbers strong {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.fea-support-progress__numbers span {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: #6f655c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.fea-support-progress__track {
|
||||||
|
width: 100%;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eadfce;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.fea-support-progress__fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, #8b1a2e, #c4884b);
|
||||||
|
}
|
||||||
|
.fea-support-progress__note {
|
||||||
|
margin: 0.65rem 0 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #6f655c;
|
||||||
|
}
|
||||||
|
.fea-support-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
.fea-support-button {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.94rem;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
.fea-support-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 14px 24px -20px rgba(42, 35, 32, 0.55);
|
||||||
|
}
|
||||||
|
.fea-support-button.is-primary {
|
||||||
|
background: var(--fea-support-burgundy);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.fea-support-button.is-secondary {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--fea-support-ink);
|
||||||
|
border: 1px solid var(--fea-support-line);
|
||||||
|
}
|
||||||
|
.fea-support-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 300px;
|
||||||
|
gap: 2rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.fea-support-content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.fea-support-content > *:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.fea-support-content h2,
|
||||||
|
.fea-support-content h3 {
|
||||||
|
font-family: "Fraunces", Georgia, serif;
|
||||||
|
color: var(--fea-support-ink);
|
||||||
|
}
|
||||||
|
.fea-support-content h2 {
|
||||||
|
margin-top: 2rem;
|
||||||
|
font-size: clamp(1.5rem, 2.4vw, 2rem);
|
||||||
|
}
|
||||||
|
.fea-support-content h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
.fea-support-content p,
|
||||||
|
.fea-support-content li {
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
.fea-support-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 2rem;
|
||||||
|
}
|
||||||
|
.fea-support-sidebar__small {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: #6f655c;
|
||||||
|
}
|
||||||
|
.fea-support-banner {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 2rem auto 0;
|
||||||
|
padding: 1.4rem 1.5rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--fea-support-line);
|
||||||
|
background: linear-gradient(180deg, #f8f3eb 0%, #fffdf9 100%);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.85fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.fea-support-banner__title {
|
||||||
|
font-family: "Fraunces", Georgia, serif;
|
||||||
|
font-size: clamp(1.4rem, 2.6vw, 2rem);
|
||||||
|
margin: 0 0 0.45rem;
|
||||||
|
}
|
||||||
|
.fea-support-banner__text {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.fea-support-banner__links {
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.fea-support-hero,
|
||||||
|
.fea-support-layout,
|
||||||
|
.fea-support-banner {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fea-support-sidebar {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<main id="wp--skip-link--target" class="fea-support-page">
|
||||||
|
<section class="fea-support-hero">
|
||||||
|
<div class="fea-support-hero__copy">
|
||||||
|
<span class="fea-support-hero__eyebrow"><?php echo esc_html($data['eyebrow'] ?? 'Apoya Fe Adulta'); ?></span>
|
||||||
|
<h1 class="fea-support-hero__title"><?php echo esc_html($data['hero_title'] ?? get_the_title()); ?></h1>
|
||||||
|
<p class="fea-support-hero__intro"><?php echo esc_html($data['hero_intro'] ?? ''); ?></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="fea-support-card" aria-label="Estado de la campaña">
|
||||||
|
<p class="fea-support-card__title">Objetivo de la campaña</p>
|
||||||
|
<?php echo fea_support_progress_html($data, 'page'); ?>
|
||||||
|
<?php echo fea_support_buttons_html($data, 'page'); ?>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="fea-support-layout">
|
||||||
|
<div class="fea-support-content">
|
||||||
|
<?php the_content(); ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="fea-support-sidebar">
|
||||||
|
<div class="fea-support-card">
|
||||||
|
<p class="fea-support-card__title">Colaborar ahora</p>
|
||||||
|
<?php echo fea_support_progress_html($data, 'sidebar'); ?>
|
||||||
|
<?php echo fea_support_buttons_html($data, 'sidebar'); ?>
|
||||||
|
<p class="fea-support-sidebar__small">
|
||||||
|
Si compartes esta página con otras personas de la comunidad, también nos ayudas a acercarnos al objetivo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
get_footer();
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* fea-ui — Ajustes estéticos globales del front (issue #95).
|
||||||
|
*
|
||||||
|
* 1. Foco/click: elimina el recuadro gris/negro por defecto al hacer click,
|
||||||
|
* mantiene foco visible y de marca SOLO con teclado (:focus-visible).
|
||||||
|
* 2. Menú: tipografía editorial Fraunces (coherente con los títulos),
|
||||||
|
* línea carmesí en hover/ítem activo, sin subrayado.
|
||||||
|
*
|
||||||
|
* CSS global (el menú y los enlaces están en todo el sitio), no solo en single.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('FEA_UI_CRIMSON')) {
|
||||||
|
define('FEA_UI_CRIMSON', '#8b1a2e'); // carmesí de marca
|
||||||
|
}
|
||||||
|
if (!defined('FEA_UI_INK')) {
|
||||||
|
define('FEA_UI_INK', '#2a2320'); // texto de marca
|
||||||
|
}
|
||||||
|
|
||||||
|
add_action('wp_head', function () {
|
||||||
|
if (is_admin()) return;
|
||||||
|
$crimson = FEA_UI_CRIMSON;
|
||||||
|
$ink = FEA_UI_INK;
|
||||||
|
?>
|
||||||
|
<style id="fea-ui">
|
||||||
|
/* ── 1. Foco / click ───────────────────────────────────────────── */
|
||||||
|
a, button, summary, [role="button"] { -webkit-tap-highlight-color: transparent; }
|
||||||
|
|
||||||
|
/* Sin outline al hacer click con ratón/dedo… */
|
||||||
|
a:focus:not(:focus-visible),
|
||||||
|
button:focus:not(:focus-visible) { outline: none; }
|
||||||
|
|
||||||
|
/* …pero foco visible y de marca al navegar con teclado (accesibilidad) */
|
||||||
|
a:focus-visible,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 2px solid <?php echo $crimson; ?>;
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* El core agranda el outline del menú (outline-offset:4px) → lo reducimos */
|
||||||
|
.wp-block-navigation .wp-block-navigation-item .wp-block-navigation-item__content {
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 2. Menú — Opción C · Sans limpia minimalista (issue #95) ──── */
|
||||||
|
.wp-block-navigation .wp-block-navigation-item__content {
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
color: <?php echo $ink; ?>;
|
||||||
|
text-decoration: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nunca subrayado (el core lo pone en hover) */
|
||||||
|
.wp-block-navigation a.wp-block-navigation-item__content:hover,
|
||||||
|
.wp-block-navigation a.wp-block-navigation-item__content:focus {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Línea carmesí inferior, animada, en hover y en el ítem activo */
|
||||||
|
.wp-block-navigation > .wp-block-navigation__container > .wp-block-navigation-item
|
||||||
|
> .wp-block-navigation-item__content::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0; right: 0; bottom: -3px;
|
||||||
|
height: 2px;
|
||||||
|
background: <?php echo $crimson; ?>;
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left center;
|
||||||
|
transition: transform 0.18s ease;
|
||||||
|
}
|
||||||
|
.wp-block-navigation > .wp-block-navigation__container > .wp-block-navigation-item
|
||||||
|
> .wp-block-navigation-item__content:hover::after,
|
||||||
|
.wp-block-navigation > .wp-block-navigation__container > .wp-block-navigation-item
|
||||||
|
> .wp-block-navigation-item__content[aria-current]::after {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
.wp-block-navigation > .wp-block-navigation__container > .wp-block-navigation-item
|
||||||
|
> .wp-block-navigation-item__content[aria-current] {
|
||||||
|
color: <?php echo $crimson; ?>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submenús: sin línea inferior; hover marca el texto en carmesí */
|
||||||
|
.wp-block-navigation__submenu-container .wp-block-navigation-item__content::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.wp-block-navigation__submenu-container .wp-block-navigation-item__content:hover,
|
||||||
|
.wp-block-navigation__submenu-container .wp-block-navigation-item__content[aria-current] {
|
||||||
|
color: <?php echo $crimson; ?>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 3. Fondo cálido en artículos (issue #78 feedback "demasiado blanca") ── */
|
||||||
|
body.single-post {
|
||||||
|
background-color: #f5f0eb !important;
|
||||||
|
--wp--preset--color--base: #f5f0eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
<?php
|
||||||
|
}, 25);
|
||||||
Reference in New Issue
Block a user