Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06d7057e76 | |||
| ef2978fc21 | |||
| 587b10c2b3 | |||
| 6b966a3b5f | |||
| 754915b91f | |||
| 75c0f07eab | |||
| f0946be1fc | |||
| d836297c89 | |||
| d9ff5e891c | |||
| 5cf588ffe3 | |||
| a734ffc933 | |||
| b3a2ae682b | |||
| 825434a0b7 | |||
| 908188ddf2 | |||
| 68b95aae89 | |||
| 82c352aa15 | |||
| 51ae723c5a | |||
| 7a623e8bb9 | |||
| 5055e92143 | |||
| 638f5b70c7 | |||
| 15d3d72c70 | |||
| 809d4d9b81 | |||
| 6b61250b0b | |||
| d044e229dc |
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# Cache de Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Windows zone identifiers
|
||||||
|
*.Zone.Identifier
|
||||||
|
*:Zone.Identifier
|
||||||
@@ -17,6 +17,7 @@ scripts/ — scripts Python/PHP de migración, traducción, TTS y mantenimi
|
|||||||
| `fea-homepage.php` | Tarjetas de portada |
|
| `fea-homepage.php` | Tarjetas de portada |
|
||||||
| `fea-share.php` | Sección "Comparte FeAdulta" (Facebook, Instagram, Imprimir) |
|
| `fea-share.php` | Sección "Comparte FeAdulta" (Facebook, Instagram, Imprimir) |
|
||||||
| `fea-ui.php` | Estilos globales, menú, fondo cálido en artículos |
|
| `fea-ui.php` | Estilos globales, menú, fondo cálido en artículos |
|
||||||
|
| `fea-accesibilidad.php` | Tipografía Atkinson Hyperlegible (self-hosted) + toggle claro/oscuro persistente |
|
||||||
| `fea-beta-feedback.php` | Barra de feedback beta + endpoint `/fea/v1/lang/{id}` |
|
| `fea-beta-feedback.php` | Barra de feedback beta + endpoint `/fea/v1/lang/{id}` |
|
||||||
| `fea-search.php` | Buscador móvil nativo |
|
| `fea-search.php` | Buscador móvil nativo |
|
||||||
| `fea-pensamientos.php` | Widget de pensamientos |
|
| `fea-pensamientos.php` | Widget de pensamientos |
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
# Actualizar WordPress local desde Joomla produccion
|
||||||
|
|
||||||
|
Runbook para refrescar la copia local migrada a WordPress con los ultimos
|
||||||
|
articulos visibles en Joomla produccion.
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
- Repo local: `/home/rafa/joomla-migration`
|
||||||
|
- WordPress local: `https://farmer.taild3aaf6.ts.net/fea/`
|
||||||
|
- WordPress DB local: contenedor `wordpress-mysql`, base `wordpress_db`
|
||||||
|
- WordPress web local: contenedor `wordpress-web`
|
||||||
|
- Joomla produccion esta detras de Cloudflare. Para leer el origen se uso:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --resolve www.feadulta.com:443:134.0.10.170 -k -L \
|
||||||
|
-A "Mozilla/5.0 Codex Feadulta" https://www.feadulta.com/es/
|
||||||
|
```
|
||||||
|
|
||||||
|
El acceso SSH/MySQL de produccion documentado en scripts antiguos puede estar
|
||||||
|
rotado. Si falla, no asumir que los scripts historicos funcionan contra DB real.
|
||||||
|
|
||||||
|
## Flujo preferente
|
||||||
|
|
||||||
|
Si hay credenciales actuales o dump de produccion, usar una sincronizacion desde
|
||||||
|
base de datos. Los scripts historicos que documentan el modelo son:
|
||||||
|
|
||||||
|
- `scripts/import_new_k2_items.py`
|
||||||
|
- `scripts/import_new_cartas.py`
|
||||||
|
- `scripts/import_new_content.py`
|
||||||
|
- `scripts/fix_imported_k2_metas.py`
|
||||||
|
- `scripts/regenerar_clasificacion_csv.py`
|
||||||
|
- `scripts/aplicar_clasificacion_a_bd.py`
|
||||||
|
|
||||||
|
Ojo con `scripts/import_new_k2_items.py`: la version historica lee
|
||||||
|
`LAST_INSERT_ID()` en otra conexion, asi que puede devolver `0`. En el delta de
|
||||||
|
2026 se corrigieron metadatos con el offset `wp_id = k2_id + 26040`.
|
||||||
|
|
||||||
|
## Flujo de contingencia: HTML publico
|
||||||
|
|
||||||
|
Cuando no hay acceso a la DB de Joomla produccion, usar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/import_public_joomla_delta.py
|
||||||
|
python3 scripts/import_public_joomla_delta.py --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
El primer comando es dry-run. El segundo escribe en WordPress local.
|
||||||
|
|
||||||
|
Este importador:
|
||||||
|
|
||||||
|
- lee las cartas visibles configuradas en `CARTAS`;
|
||||||
|
- recorre los enlaces de esas cartas;
|
||||||
|
- importa solo IDs superiores al maximo ya presente en WordPress;
|
||||||
|
- conserva `_fgj2wp_old_k2_id` y `_fgj2wp_old_content_id`;
|
||||||
|
- asigna `Idioma=1`;
|
||||||
|
- asigna `_carta_id` a los K2 importados;
|
||||||
|
- clasifica por seccion de la carta:
|
||||||
|
`lecturas-biblicas`, `comentario-editorial`,
|
||||||
|
`comentarios-al-evangelio`, `eucaristia`, `multimedia`, `articulos`.
|
||||||
|
|
||||||
|
Limitacion importante: este flujo solo ve contenido enlazado desde las cartas
|
||||||
|
publicas actuales. No detecta articulos ocultos, no publicados o no enlazados.
|
||||||
|
|
||||||
|
## Preparar una semana nueva
|
||||||
|
|
||||||
|
1. Localizar las cartas visibles en produccion:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --resolve www.feadulta.com:443:134.0.10.170 -k -L \
|
||||||
|
-A "Mozilla/5.0 Codex Feadulta" \
|
||||||
|
https://www.feadulta.com/es/ayuda/esta-semana.html
|
||||||
|
|
||||||
|
curl --resolve www.feadulta.com:443:134.0.10.170 -k -L \
|
||||||
|
-A "Mozilla/5.0 Codex Feadulta" \
|
||||||
|
https://www.feadulta.com/es/ayuda/semana-pasada.html
|
||||||
|
|
||||||
|
curl --resolve www.feadulta.com:443:134.0.10.170 -k -L \
|
||||||
|
-A "Mozilla/5.0 Codex Feadulta" \
|
||||||
|
https://www.feadulta.com/es/ayuda/otras-semanas.html
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Actualizar `CARTAS` en `scripts/import_public_joomla_delta.py` con:
|
||||||
|
|
||||||
|
- `content_id`
|
||||||
|
- URL relativa
|
||||||
|
- fecha de publicacion
|
||||||
|
- categorias de carta:
|
||||||
|
- actual: `[TERM_CARTA_SEMANA, TERM_CARTAS_OTRAS, TERM_FEADULTA]`
|
||||||
|
- semana pasada: `[TERM_CARTAS_OTRAS, TERM_CARTA_PASADA, TERM_FEADULTA]`
|
||||||
|
- otras semanas: `[TERM_CARTAS_OTRAS, TERM_FEADULTA]`
|
||||||
|
|
||||||
|
3. Ejecutar dry-run y comprobar conteos:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/import_public_joomla_delta.py
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Aplicar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/import_public_joomla_delta.py --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verificaciones
|
||||||
|
|
||||||
|
Maximos importados:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec wordpress-mysql mysql \
|
||||||
|
-u wordpress_user -pwordpress_pass wordpress_db \
|
||||||
|
--default-character-set=utf8mb4 -B -e "
|
||||||
|
SELECT meta_key,
|
||||||
|
MIN(CAST(meta_value AS UNSIGNED)) min_id,
|
||||||
|
MAX(CAST(meta_value AS UNSIGNED)) max_id,
|
||||||
|
COUNT(*) n
|
||||||
|
FROM wp_postmeta
|
||||||
|
WHERE meta_key IN ('_fgj2wp_old_k2_id','_fgj2wp_old_content_id')
|
||||||
|
GROUP BY meta_key;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Delta exacto para un corte dado:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec wordpress-mysql mysql \
|
||||||
|
-u wordpress_user -pwordpress_pass wordpress_db \
|
||||||
|
--default-character-set=utf8mb4 -B -e "
|
||||||
|
SELECT pm.meta_key, COUNT(DISTINCT pm.post_id) n
|
||||||
|
FROM wp_postmeta pm
|
||||||
|
WHERE (pm.meta_key='_fgj2wp_old_k2_id'
|
||||||
|
AND CAST(pm.meta_value AS UNSIGNED) > 18102)
|
||||||
|
OR (pm.meta_key='_fgj2wp_old_content_id'
|
||||||
|
AND CAST(pm.meta_value AS UNSIGNED) > 9133)
|
||||||
|
GROUP BY pm.meta_key;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Portada:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -L --max-time 20 -sS \
|
||||||
|
https://farmer.taild3aaf6.ts.net/fea/ \
|
||||||
|
| rg -n "La puerta pequeña|Carta de la semana"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Carrusel de portada
|
||||||
|
|
||||||
|
El carrusel no depende de imagen destacada de posts. Se sincroniza desde:
|
||||||
|
|
||||||
|
```text
|
||||||
|
wordpress/wp-content/uploads/home/
|
||||||
|
```
|
||||||
|
|
||||||
|
El plugin responsable es:
|
||||||
|
|
||||||
|
```text
|
||||||
|
wordpress/wp-content/mu-plugins/fea-slider-sync.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Ese plugin replica el modelo Joomla `images/home/` y sincroniza Smart Slider 3
|
||||||
|
slider `2`.
|
||||||
|
|
||||||
|
Detectar imagenes actuales en produccion:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --resolve www.feadulta.com:443:134.0.10.170 -k -L \
|
||||||
|
--max-time 20 -sS -A "Mozilla/5.0 Codex Feadulta" \
|
||||||
|
https://www.feadulta.com/es/ \
|
||||||
|
| rg -o 'images/home/[^"'"'"') ]+'
|
||||||
|
```
|
||||||
|
|
||||||
|
Procedimiento:
|
||||||
|
|
||||||
|
1. Mover las imagenes antiguas fuera de `uploads/home/`.
|
||||||
|
2. Descargar las nuevas desde `https://www.feadulta.com/images/home/...`.
|
||||||
|
3. Copiarlas al contenedor si el host no tiene permisos de escritura:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker cp /tmp/pausa_999999000944.jpg \
|
||||||
|
wordpress-web:/var/www/html/wp-content/uploads/home/pausa_999999000944.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Ajustar propietario:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec wordpress-web chown www-data:www-data \
|
||||||
|
/var/www/html/wp-content/uploads/home/pausa_999999000944.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Forzar resync:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec wordpress-web \
|
||||||
|
wp eval "var_export(fea_slider_home_sync_now(true));" --allow-root
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Verificar Smart Slider:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec wordpress-mysql mysql \
|
||||||
|
-u wordpress_user -pwordpress_pass wordpress_db \
|
||||||
|
--default-character-set=utf8mb4 -B -e "
|
||||||
|
SELECT id,title,thumbnail,params
|
||||||
|
FROM wp_nextend2_smartslider3_slides
|
||||||
|
WHERE slider=2
|
||||||
|
ORDER BY ordering;"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceso wp-admin local
|
||||||
|
|
||||||
|
URL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://farmer.taild3aaf6.ts.net/fea/wp-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Administradores vistos en la copia local:
|
||||||
|
|
||||||
|
- `calvo`
|
||||||
|
- `eqpyk`
|
||||||
|
- `icalvotorre`
|
||||||
|
- `josek`
|
||||||
|
- `pabloarias`
|
||||||
|
- `andrey`
|
||||||
|
|
||||||
|
Si no se conoce la clave, resetear una cuenta local:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec wordpress-web \
|
||||||
|
wp user update calvo --user_pass='FeAdulta2024!' --allow-root
|
||||||
|
```
|
||||||
|
|
||||||
|
Comprobar rol:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec wordpress-web \
|
||||||
|
wp user get calvo --fields=ID,user_login,user_email,roles,display_name \
|
||||||
|
--allow-root
|
||||||
|
```
|
||||||
|
|
||||||
|
Si el login sigue fallando despues del reset, probar ventana privada o borrar
|
||||||
|
cookies de `farmer.taild3aaf6.ts.net`.
|
||||||
|
|
||||||
|
## Estado del delta 2026-06-14
|
||||||
|
|
||||||
|
En la actualizacion del 14 de junio de 2026 se importaron:
|
||||||
|
|
||||||
|
- `46` items K2, de `18103` a `18161`;
|
||||||
|
- `14` articulos `content`, de `9134` a `9150`;
|
||||||
|
- cartas:
|
||||||
|
- `9136` / `Uno y Trino`
|
||||||
|
- `9143` / `20 años de fe adulta`
|
||||||
|
- `9150` / `La puerta pequeña`
|
||||||
|
|
||||||
|
Carrusel actualizado de:
|
||||||
|
|
||||||
|
- `pausa_999999000941.jpg`
|
||||||
|
- `pausa_999999000942.jpg`
|
||||||
|
- `pausa_999999000943.jpg`
|
||||||
|
|
||||||
|
a:
|
||||||
|
|
||||||
|
- `pausa_999999000944.jpg`
|
||||||
|
- `pausa_999999000945.jpg`
|
||||||
|
- `pausa_999999000946.jpg`
|
||||||
|
|
||||||
|
Las imagenes antiguas quedaron en:
|
||||||
|
|
||||||
|
```text
|
||||||
|
wordpress/wp-content/uploads/home-old-20260614/
|
||||||
|
```
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Benchmark Gemma 4 12B vs Gemma 4 4B
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Comparar si Gemma 4 12B mejora de forma material el flujo real de Feadulta frente a Gemma 4 4B sin ejecutar todavía una batería grande.
|
||||||
|
|
||||||
|
Este benchmark propone una sola prueba pequeña pero representativa: traducción + preparación para TTS.
|
||||||
|
|
||||||
|
## Condiciones fijas
|
||||||
|
Usar exactamente la misma configuración para ambos modelos:
|
||||||
|
|
||||||
|
- mismo prompt
|
||||||
|
- misma temperatura (`0.2` recomendada)
|
||||||
|
- mismo max tokens
|
||||||
|
- misma context window
|
||||||
|
- sin herramientas externas
|
||||||
|
- misma máquina / mismo backend de LM Studio
|
||||||
|
|
||||||
|
## Caso de uso elegido
|
||||||
|
Feadulta suele necesitar:
|
||||||
|
|
||||||
|
- traducciones ES ↔ EN
|
||||||
|
- preservar tono espiritual/catequético
|
||||||
|
- mantener nombres propios y citas bíblicas correctas
|
||||||
|
- dejar el texto listo para TTS sin frases raras
|
||||||
|
|
||||||
|
## Prueba
|
||||||
|
### Input sugerido
|
||||||
|
Tomar un fragmento real de Feadulta de 600 a 900 palabras en español con:
|
||||||
|
|
||||||
|
- tono espiritual o doctrinal
|
||||||
|
- al menos 2 citas bíblicas
|
||||||
|
- nombres propios o referencias litúrgicas
|
||||||
|
- frases largas y algún matiz teológico
|
||||||
|
|
||||||
|
### Prompt
|
||||||
|
```text
|
||||||
|
Actúa como editor bilingüe para Feadulta.
|
||||||
|
|
||||||
|
Tarea:
|
||||||
|
1. Traduce al inglés el texto español adjunto.
|
||||||
|
2. Mantén el tono espiritual, cercano y claro; no lo vuelvas académico ni robótico.
|
||||||
|
3. Conserva correctamente citas bíblicas, nombres propios, referencias litúrgicas y términos doctrinales.
|
||||||
|
4. Después de la traducción, genera una segunda versión "TTS-ready" del mismo texto en inglés:
|
||||||
|
- frases algo más cortas
|
||||||
|
- puntuación natural para locución
|
||||||
|
- sin enumeraciones artificiales
|
||||||
|
- sin emojis
|
||||||
|
- sin notas del traductor
|
||||||
|
5. No inventes contenido ni expliques decisiones.
|
||||||
|
|
||||||
|
Devuelve exactamente este formato:
|
||||||
|
|
||||||
|
[EN_TRANSLATION]
|
||||||
|
...
|
||||||
|
|
||||||
|
[TTS_READY_EN]
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
[PEGAR TEXTO FUENTE AQUÍ]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Qué evaluar
|
||||||
|
Puntuar 1–5 en cada eje:
|
||||||
|
|
||||||
|
1. Fidelidad al original
|
||||||
|
- no omite matices importantes
|
||||||
|
- no simplifica demasiado
|
||||||
|
- no añade ideas
|
||||||
|
|
||||||
|
2. Naturalidad del inglés
|
||||||
|
- suena humano
|
||||||
|
- no parece traducción literal
|
||||||
|
- mantiene buen ritmo
|
||||||
|
|
||||||
|
3. Precisión religiosa / terminológica
|
||||||
|
- citas bíblicas bien mantenidas
|
||||||
|
- términos doctrinales consistentes
|
||||||
|
- nombres y referencias correctos
|
||||||
|
|
||||||
|
4. Calidad TTS-ready
|
||||||
|
- frases respirables
|
||||||
|
- puntuación ayuda a narración
|
||||||
|
- no hay giros torpes para voz
|
||||||
|
|
||||||
|
5. Necesidad de edición manual
|
||||||
|
- 5 = casi publicar
|
||||||
|
- 1 = rehacer bastante
|
||||||
|
|
||||||
|
## Señales de que gana el 12B
|
||||||
|
- conserva mejor el matiz del original
|
||||||
|
- hace menos traducción literal rara
|
||||||
|
- entrega una versión TTS más fluida
|
||||||
|
- requiere menos retoques antes de publicar
|
||||||
|
|
||||||
|
## Regla práctica de decisión
|
||||||
|
Si el 12B gana claramente en 3 o más ejes y la latencia extra sigue siendo tolerable, merece ser el modelo por defecto para traducción/TTS de Feadulta.
|
||||||
|
|
||||||
|
Si la mejora es pequeña, el 4B puede seguir siendo suficiente para borradores rápidos.
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
# Guía para publicar la carta semanal en Fe Adulta (WordPress)
|
||||||
|
|
||||||
|
> **Para quién es este documento:** para el asistente (Claude Code / Cowork) que ayuda a
|
||||||
|
> **Inma** a publicar la carta semanal y los artículos en el WordPress nuevo de Fe Adulta.
|
||||||
|
>
|
||||||
|
> **Reparto de responsabilidades:**
|
||||||
|
> - **Inma** aporta el contenido editorial: cómo se redacta y compone la carta y los artículos.
|
||||||
|
> - **Este documento** aporta la infraestructura y la estructura del sitio: cómo conectarse,
|
||||||
|
> dónde va cada cosa, qué categorías usar, qué se publica automáticamente y qué hay que
|
||||||
|
> hacer a mano.
|
||||||
|
> - **Inma sube siempre en ESPAÑOL.** Las traducciones (EN/FR/IT/PT) y el audio (TTS) los
|
||||||
|
> genera un proceso automático en el servidor (ver §7). No hay que traducir a mano.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. El sitio y cómo conectarse
|
||||||
|
|
||||||
|
### 1.1 Entornos
|
||||||
|
|
||||||
|
| Entorno | URL | Estado |
|
||||||
|
|---------|-----|--------|
|
||||||
|
| **Producción** | `https://www.feadulta.com` | El sitio en vivo, WordPress. Cutover completado el 2026-07-08 |
|
||||||
|
| Legado Joomla | `https://antiguo.feadulta.com` | Solo contenido histórico no migrado. No se publica aquí |
|
||||||
|
|
||||||
|
**El cutover de dominio ya se hizo (2026-07-08):** `www.feadulta.com` es WordPress y es donde
|
||||||
|
se publica siempre. `wp-nuevo.feadulta.com` (el subdominio de Beta) ha dejado de servir nada
|
||||||
|
(el directorio que usaba se vació al mover los ficheros a la raíz del hosting) — si algún
|
||||||
|
enlace o script antiguo todavía lo menciona, hay que cambiarlo a `www.feadulta.com`.
|
||||||
|
|
||||||
|
### 1.2 Acceso recomendado: API REST de WordPress + contraseña de aplicación
|
||||||
|
|
||||||
|
No hace falta SSH ni tocar la base de datos. WordPress trae una API REST y un sistema de
|
||||||
|
**"Contraseñas de aplicación"** (Application Passwords) pensado exactamente para esto.
|
||||||
|
|
||||||
|
**Cómo obtener la credencial (lo hace Inma una sola vez):**
|
||||||
|
Inma **ya tiene usuario con rol Editor** en el sitio (suficiente para crear, editar,
|
||||||
|
publicar y programar entradas). Con ese usuario:
|
||||||
|
1. Entrar en `https://www.feadulta.com/wp-admin`.
|
||||||
|
2. Ir a **Usuarios → Perfil** → bajar hasta **"Contraseñas de aplicación"**.
|
||||||
|
3. Escribir un nombre (p. ej. `cowork-cartas`) y pulsar **Añadir**.
|
||||||
|
4. WordPress muestra una contraseña de 24 caracteres con espacios
|
||||||
|
(formato `xxxx xxxx xxxx xxxx xxxx xxxx`). **Se copia y se guarda**: solo se ve una vez.
|
||||||
|
|
||||||
|
**Cómo la usa el asistente:** autenticación HTTP Basic sobre HTTPS, con
|
||||||
|
`usuario:contraseña_de_aplicación`. Ejemplo de prueba (debe responder con los datos del
|
||||||
|
usuario):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -u "USUARIO:xxxx xxxx xxxx xxxx xxxx xxxx" \
|
||||||
|
https://www.feadulta.com/wp-json/wp/v2/users/me
|
||||||
|
```
|
||||||
|
|
||||||
|
Base de la API para todo lo demás: `https://www.feadulta.com/wp-json/wp/v2/`
|
||||||
|
|
||||||
|
> **⚠️ Pendiente de configurar en Cloudflare (bloqueante).** Comprobado el **27/06/2026**: el
|
||||||
|
> sitio está tras Cloudflare y devuelve **403 "Attention Required"** a **cualquier** petición
|
||||||
|
> que no sea un navegador real con JavaScript — afecta a la API REST, a la home y a
|
||||||
|
> `wp-login.php`, incluso enviando un User-Agent de navegador. Es el reto de navegador de
|
||||||
|
> Cloudflare, **no** un problema de la credencial.
|
||||||
|
>
|
||||||
|
> Para que el asistente pueda usar la API hay que crear una **excepción en el panel de
|
||||||
|
> Cloudflare** (lo hace Inma). Opciones, de más simple a más limpia:
|
||||||
|
> 1. **Allowlist por IP de origen:** WAF → Tools → permitir la IP pública desde la que se
|
||||||
|
> conecta el asistente. Simple, pero requiere IP fija/conocida.
|
||||||
|
> 2. **Regla WAF con token secreto:** una Custom Rule que haga *Skip / Bypass* cuando la
|
||||||
|
> petición vaya a `/wp-json/*` **y** lleve una cabecera secreta (p. ej.
|
||||||
|
> `X-FEA-Key: <secreto>`). El asistente añade esa cabecera en cada llamada.
|
||||||
|
> 3. **Subdominio "solo DNS"** (sin proxy naranja), p. ej. `api.feadulta.com`, apuntando al
|
||||||
|
> mismo origen para servir la API sin pasar por el reto de Cloudflare.
|
||||||
|
>
|
||||||
|
> Hasta que se aplique una de estas, las llamadas a la API fallarán con 403. Coordinar con
|
||||||
|
> Rafa/Inma cuál se elige.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. La idea clave: **la carta de la semana ES la portada**
|
||||||
|
|
||||||
|
Esto es lo más importante de entender. La portada de Fe Adulta **no** muestra "los últimos
|
||||||
|
artículos por categoría". Muestra **exactamente lo que enlaza la carta de la semana actual**,
|
||||||
|
agrupado por las secciones internas de esa carta.
|
||||||
|
|
||||||
|
Es decir: la carta semanal es un artículo largo en HTML, con secciones marcadas por
|
||||||
|
encabezados, y dentro de cada sección hay enlaces a otros artículos. La portada lee la carta
|
||||||
|
vigente, detecta esas secciones y coloca cada grupo de enlaces en su bloque correspondiente.
|
||||||
|
|
||||||
|
**Consecuencia práctica:** para que la portada se rellene bien, la carta tiene que estar
|
||||||
|
escrita con unos **encabezados de sección concretos** (§5) y enlazar a artículos que ya
|
||||||
|
existan en el sitio (§4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Categorías del sitio
|
||||||
|
|
||||||
|
Las categorías se asignan por su **ID numérico** vía API (campo `categories: [..]`).
|
||||||
|
|
||||||
|
### 3.1 Categorías de la carta (estado de cada carta)
|
||||||
|
|
||||||
|
| ID | Nombre | Slug | Significado |
|
||||||
|
|----|--------|------|-------------|
|
||||||
|
| **6** | Carta de la semana | `cartasemana` | La carta **vigente**. Debe estar solo la actual. |
|
||||||
|
| **22** | La semana pasada | `carta-semana-pasada` | La carta de la semana anterior. Debe estar solo una. |
|
||||||
|
| **21** | Cartas de otras semanas | `cartas-de-otras-semanas` | Histórico **acumulativo**: TODAS las cartas publicadas alguna vez, incluidas la vigente (6) y la anterior (22). No se quita nunca, solo se añade. Así el listado de "Otras Semanas" sirve para navegar por cualquier carta pasada sin tener que ir carta por carta. |
|
||||||
|
|
||||||
|
### 3.2 Categorías temáticas (para los artículos de dentro de la carta)
|
||||||
|
|
||||||
|
| ID | Nombre | Uso |
|
||||||
|
|----|--------|-----|
|
||||||
|
| 1645 | Lecturas bíblicas | Lecturas del domingo |
|
||||||
|
| 1646 | Comentario editorial | Comentario editorial de la semana |
|
||||||
|
| 1647 | Comentarios al evangelio | Artículos que comentan el evangelio |
|
||||||
|
| 1648 | Eucaristía | Material para la eucaristía |
|
||||||
|
| 1649 | Multimedia | Vídeos / audios / material multimedia |
|
||||||
|
| 1650 | Artículos | Artículos generales seleccionados |
|
||||||
|
|
||||||
|
> Cada artículo de la semana va en su categoría temática. **La carta** (el texto largo) va
|
||||||
|
> en la categoría **6**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Cómo se publica una carta nueva (flujo completo)
|
||||||
|
|
||||||
|
Una "carta" no es un solo artículo: es **un artículo-carta que enlaza a varios artículos**.
|
||||||
|
El orden correcto es: primero los artículos, luego la carta que los enlaza.
|
||||||
|
|
||||||
|
### Paso 1 — Crear los artículos de la semana
|
||||||
|
|
||||||
|
Cada pieza de contenido (cada comentario al evangelio, cada artículo seleccionado, etc.) es
|
||||||
|
un **post** propio. Se crean vía API y conviene crearlos primero **como borrador** y, cuando
|
||||||
|
estén listos, publicarlos.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -u "USUARIO:APP_PASSWORD" \
|
||||||
|
-X POST https://www.feadulta.com/wp-json/wp/v2/posts \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"title": "Título del artículo",
|
||||||
|
"content": "<p>Cuerpo en HTML…</p>",
|
||||||
|
"status": "draft",
|
||||||
|
"categories": [1647]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
De la respuesta JSON interesan dos campos:
|
||||||
|
- `id` → identificador del post.
|
||||||
|
- `link` → la **URL pública** del artículo. **Guardar esta URL**: es la que se usa para
|
||||||
|
enlazar desde la carta.
|
||||||
|
|
||||||
|
Repetir para cada artículo, usando la categoría temática que corresponda (§3.2).
|
||||||
|
|
||||||
|
> ⚠️ **No adivinéis la URL a partir del título — usad siempre el `link` real de la API,
|
||||||
|
> y solo DESPUÉS de publicar.** En la carta 734 los artículos se quedaron en `draft` con el
|
||||||
|
> título prefijado `[PRUEBA]`, y la carta se compuso enlazando slugs *adivinados* a mano
|
||||||
|
> (`.../prueba-silencio/`, etc.) a partir de ese título provisional. Al publicar de verdad
|
||||||
|
> con el título limpio, WordPress genera el slug desde el título final y **le añade un
|
||||||
|
> sufijo (`-2`, `-4`…) si ya existe otro post con ese mismo slug** — algo frecuente en
|
||||||
|
> temas recurrentes ("Silencio", "La palabra", "Te encontré"…). Resultado: 21 de 22
|
||||||
|
> enlaces de la carta apuntaban a una URL que nunca existió. Regla: **publicad cada
|
||||||
|
> artículo (quitando `[PRUEBA]` del título) en cuanto esté listo**, y componed los
|
||||||
|
> enlaces de la carta con el `link` que devuelve la API **tras ese publish**, no antes.
|
||||||
|
|
||||||
|
### Paso 1.5 — Asignar `_carta_id` a cada artículo de la semana (IMPORTANTE)
|
||||||
|
|
||||||
|
Cada artículo de la semana debe llevar el meta `_carta_id` = ID del post de la carta a la
|
||||||
|
que pertenece. **Sin esto, el sistema no puede localizar "todos los artículos de esta
|
||||||
|
carta" para publicarlos/traducirlos/locutarlos en bloque.**
|
||||||
|
|
||||||
|
`_carta_id` es un meta interno y **no se puede asignar por el endpoint estándar**
|
||||||
|
`/wp/v2/posts/<id>` (WordPress lo bloquea por empezar por `_`). Para esto hay un
|
||||||
|
**endpoint REST propio**, desplegado el 2026-07-07:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Leer el _carta_id actual de un artículo
|
||||||
|
curl -s -u "USUARIO:APP_PASSWORD" \
|
||||||
|
https://www.feadulta.com/wp-json/fea/v1/carta-id/ID_DEL_ARTICULO
|
||||||
|
|
||||||
|
# Asignar (o corregir) el _carta_id de un artículo
|
||||||
|
curl -s -u "USUARIO:APP_PASSWORD" \
|
||||||
|
-X POST https://www.feadulta.com/wp-json/fea/v1/carta-id/ID_DEL_ARTICULO \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"carta_id": ID_DE_LA_CARTA}'
|
||||||
|
|
||||||
|
# Borrar el _carta_id (si os habéis equivocado de artículo)
|
||||||
|
curl -s -u "USUARIO:APP_PASSWORD" \
|
||||||
|
-X DELETE https://www.feadulta.com/wp-json/fea/v1/carta-id/ID_DEL_ARTICULO
|
||||||
|
```
|
||||||
|
|
||||||
|
Respuesta en los tres casos: `{"post_id": ID, "carta_id": ID|null}`.
|
||||||
|
|
||||||
|
Notas:
|
||||||
|
- Solo podéis asignar `_carta_id` a artículos **que ya podéis editar** (mismo criterio que
|
||||||
|
el resto de la API: vuestro usuario Editor).
|
||||||
|
- `carta_id` tiene que ser el ID de **un post que exista** (normalmente el de la carta,
|
||||||
|
aunque en el momento de llamar a este endpoint la carta puede que aún esté en borrador —
|
||||||
|
eso no importa, solo tiene que existir el post).
|
||||||
|
- Podéis llamarlo justo después de crear cada artículo (Paso 1) o al final, después de tener
|
||||||
|
el ID de la carta (Paso 3) — el orden entre pasos 1 y 1.5 y 3 no importa mientras al acabar
|
||||||
|
todos los artículos de la semana tengan el `_carta_id` correcto.
|
||||||
|
- Código fuente: `wp-content/mu-plugins/fea-carta-id-api.php` (mu-plugin, activo siempre).
|
||||||
|
|
||||||
|
### Paso 2 — Componer el artículo-carta
|
||||||
|
|
||||||
|
La carta es un post HTML con:
|
||||||
|
1. Texto editorial libre (introducción).
|
||||||
|
2. Una sección por cada bloque, **encabezada con una de las frases exactas de §5**.
|
||||||
|
3. Dentro de cada sección, **enlaces (`<a href>`) a las URLs** de los artículos del Paso 1.
|
||||||
|
|
||||||
|
Ver §5 para el formato exacto de los encabezados y un ejemplo completo.
|
||||||
|
|
||||||
|
### Paso 3 — Publicar (o programar) la carta
|
||||||
|
|
||||||
|
La carta va en la categoría **6** (`cartasemana`) **y también en la 21** (`cartas-de-otras-semanas`)
|
||||||
|
desde el primer momento — ver §3.1: la 21 es un histórico acumulativo que no se quita nunca, así
|
||||||
|
que toda carta la lleva ya desde que se crea, no solo cuando se "degrada" en el Paso 4.
|
||||||
|
|
||||||
|
- **Publicar ya:** `"status": "publish"`.
|
||||||
|
- **Programar:** `"status": "future"` + `"date"` con la fecha/hora local del sitio en
|
||||||
|
formato ISO 8601, p. ej. `"2026-06-29T08:00:00"`. WordPress la publicará sola a esa hora.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -u "USUARIO:APP_PASSWORD" \
|
||||||
|
-X POST https://www.feadulta.com/wp-json/wp/v2/posts \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"title": "Carta de la semana — 29 de junio",
|
||||||
|
"content": "<p>…cuerpo de la carta con sus secciones y enlaces…</p>",
|
||||||
|
"status": "future",
|
||||||
|
"date": "2026-06-29T08:00:00",
|
||||||
|
"categories": [6, 21]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Paso 4 — Rotar la carta anterior (IMPORTANTE, manual)
|
||||||
|
|
||||||
|
Al entrar una carta nueva en la categoría **6**, hay que **degradar la anterior** para que la
|
||||||
|
categoría "Carta de la semana" contenga **solo una** carta. Esto es solo mover el marcador de
|
||||||
|
**estado** (6 → 22 → ninguno): la categoría **21** ("Otras semanas") **no se toca en la
|
||||||
|
rotación**, porque es acumulativa y ya la lleva cada carta desde que se publicó (Paso 3). Nunca
|
||||||
|
se quita la 21 a una carta.
|
||||||
|
|
||||||
|
1. A la carta que **dejaba** de ser actual: quitarle la categoría **6** y ponerle la **22**
|
||||||
|
("La semana pasada"). Mantiene la **21** que ya tenía.
|
||||||
|
2. A la que estaba en **22**: quitarle la **22** sin más. Mantiene la **21** que ya tenía —
|
||||||
|
así queda solo en el histórico "Otras semanas", visible igual que todas las demás.
|
||||||
|
|
||||||
|
Vía API se actualiza el array `categories` del post (sustituye al anterior, así que hay que
|
||||||
|
**incluir siempre la 21** en el array nuevo o se perdería):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. La carta que deja de ser actual: 6 → 22 (conserva 21)
|
||||||
|
curl -s -u "USUARIO:APP_PASSWORD" \
|
||||||
|
-X POST https://www.feadulta.com/wp-json/wp/v2/posts/ID_DE_LA_CARTA_VIEJA \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"categories": [22, 21]}'
|
||||||
|
|
||||||
|
# 2. La que estaba en 22: se queda solo con 21 (y las que no sean de estado, p.ej. 71)
|
||||||
|
curl -s -u "USUARIO:APP_PASSWORD" \
|
||||||
|
-X POST https://www.feadulta.com/wp-json/wp/v2/posts/ID_DE_LA_CARTA_MAS_VIEJA \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"categories": [21]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ El endpoint `POST /wp/v2/posts/ID` con `categories` **sustituye** el array completo, no
|
||||||
|
> añade. Antes de rotar, comprobar con `GET /wp-json/wp/v2/posts/ID_DE_LA_CARTA?_fields=categories`
|
||||||
|
> qué categorías tiene ya el post (p. ej. si tiene además la 71 "Feadulta") e incluirlas todas
|
||||||
|
> en el array nuevo — si no, se pierden categorías sin querer (esto pasó en la carta 733/734,
|
||||||
|
> ver issue #159 y #161).
|
||||||
|
>
|
||||||
|
> Si se programa la carta nueva con `future`, esta rotación puede hacerse el mismo día en que
|
||||||
|
> se publique. Si surge duda sobre qué carta está en qué categoría, consultar:
|
||||||
|
> `GET /wp-json/wp/v2/posts?categories=6` (debe devolver solo una).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Formato de la carta para que la portada la "entienda"
|
||||||
|
|
||||||
|
La portada parsea el HTML de la carta buscando **encabezados con estas frases** (no distingue
|
||||||
|
mayúsculas/acentos, pero el texto debe coincidir). Cada encabezado abre una sección; todos los
|
||||||
|
enlaces que vayan **después** de ese encabezado (hasta el siguiente) se muestran en el bloque
|
||||||
|
de portada indicado:
|
||||||
|
|
||||||
|
| Frase del encabezado en la carta | Bloque de portada |
|
||||||
|
|----------------------------------|-------------------|
|
||||||
|
| **Evangelio y comentarios al Evangelio** | Evangelio |
|
||||||
|
| **Artículos seleccionados para la semana** | Artículos de la semana |
|
||||||
|
| **Para unas eucaristías más participativas y actuales** | Eucaristía |
|
||||||
|
| **Material multimedia** | Multimedia |
|
||||||
|
| **Escuela EFFA** | EFFA |
|
||||||
|
|
||||||
|
**Reglas:**
|
||||||
|
- Los encabezados se suelen marcar en rojo/negrita (es el estilo histórico), pero lo que
|
||||||
|
importa es que **el texto coincida** con la frase de la tabla.
|
||||||
|
- Los enlaces dentro de cada sección deben apuntar a **artículos que existan** en el sitio
|
||||||
|
(las URLs del Paso 1). Enlaces a páginas externas se ignoran.
|
||||||
|
- Si una sección no tiene enlaces, ese bloque de la portada queda vacío.
|
||||||
|
- **"Noticias de alcance" NO se enlaza a mano en el cuerpo de la carta.** Si la semana
|
||||||
|
incluye una noticia de este tipo, el artículo va categorizado en la categoría **41**
|
||||||
|
("Noticias de alcance") y **eso basta**: la portada tiene un bloque de footer aparte que
|
||||||
|
se alimenta solo de esa categoría. Si además se enlaza esa noticia dentro de "Artículos
|
||||||
|
seleccionados para la semana", **sale duplicada** en la portada (pasó en la carta 734,
|
||||||
|
ver issue #159).
|
||||||
|
|
||||||
|
**Ejemplo mínimo de cuerpo de carta:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<p>Texto editorial de introducción de la semana…</p>
|
||||||
|
|
||||||
|
<p><strong style="color:red">Evangelio y comentarios al Evangelio</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://www.feadulta.com/comentario-evangelio-domingo/">Comentario al evangelio</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p><strong style="color:red">Artículos seleccionados para la semana</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://www.feadulta.com/articulo-uno/">Primer artículo</a></li>
|
||||||
|
<li><a href="https://www.feadulta.com/articulo-dos/">Segundo artículo</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p><strong style="color:red">Para unas eucaristías más participativas y actuales</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://www.feadulta.com/eucaristia-domingo/">Material para la eucaristía</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p><strong style="color:red">Material multimedia</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://www.feadulta.com/video-semana/">Vídeo de la semana</a></li>
|
||||||
|
</ul>
|
||||||
|
```
|
||||||
|
|
||||||
|
> La portada cachea el resultado **15 minutos**. Al guardar/editar la carta se refresca sola,
|
||||||
|
> pero si un cambio no se ve al momento, esperar unos minutos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Qué es automático y qué hay que tocar a mano
|
||||||
|
|
||||||
|
### Automático — NO hay que hacer nada
|
||||||
|
|
||||||
|
- **Portada (home):** se construye sola a partir de la carta vigente (la más reciente en la
|
||||||
|
categoría 6) y sus secciones (§5).
|
||||||
|
- **Página "Carta de la semana"** (`/carta-de-la-semana/`): redirige automáticamente a la
|
||||||
|
carta más reciente de la categoría 6.
|
||||||
|
- **Página "La semana pasada"** (`/la-semana-pasada/`): redirige a la carta más reciente de la
|
||||||
|
categoría 22.
|
||||||
|
- **Menús, avatares de autores, multiidioma, etc.:** gestionados por el tema/plugins.
|
||||||
|
- **Traducciones (EN/FR/IT/PT) y audio (TTS):** las genera el proceso del servidor (§7).
|
||||||
|
|
||||||
|
### Manual — lo que hace Inma / su asistente
|
||||||
|
|
||||||
|
- Crear los **artículos** de la semana (§4, Paso 1).
|
||||||
|
- Componer y **publicar/programar la carta** (§4, Pasos 2-3).
|
||||||
|
- **Rotar** la carta anterior de categoría (§4, Paso 4).
|
||||||
|
- Subir **imagen destacada** del artículo si se quiere (campo `featured_media` con el ID de
|
||||||
|
un adjunto previamente subido a la biblioteca de medios).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Traducción y audio (lado servidor — ⚠️ hoy es MANUAL, no automático)
|
||||||
|
|
||||||
|
**Corregido 2026-07-12: esto todavía NO es automático.** El cron que traduciría y generaría
|
||||||
|
audio solo al publicar (issue [#23](https://gitea.feadulta.com/rafa/feadulta/issues/23)) sigue
|
||||||
|
sin implementar — es una propuesta abierta, no algo que ya corra. Versiones anteriores de esta
|
||||||
|
guía decían "hay (o habrá) un proceso programado" dando a entender que ya estaba activo o a
|
||||||
|
punto; no lo está. **Inma NO tiene que traducir ni generar audio ella misma**, pero sí tiene que
|
||||||
|
**pedirlo** — no llega solo.
|
||||||
|
|
||||||
|
**Cómo pedirlo hoy:** escribir al grupo de WhatsApp mencionando "Hermes" (o por Telegram),
|
||||||
|
indicando la carta/artículo. Hermes ejecuta los scripts correspondientes en el servidor de Rafa.
|
||||||
|
Ver el runbook completo (motores de traducción, voces TTS por autor, tiempos, qué hacer si
|
||||||
|
Hermes no responde) en `docs/guia-tts-traduccion-inma.md`.
|
||||||
|
|
||||||
|
**Por eso sigue siendo importante subir siempre el contenido en español** — la traducción parte
|
||||||
|
siempre del ES, pero hay que pedirla, no asumir que "ya llegará". Si una carta urgente necesita
|
||||||
|
traducción inmediata, avisar a Rafa directamente además de pedírselo a Hermes.
|
||||||
|
|
||||||
|
> *Este punto es responsabilidad de Rafa (infraestructura). Se incluye aquí para que el
|
||||||
|
> asistente de Inma sepa que el trabajo de traducir/locutar no lo tiene que hacer él mismo,
|
||||||
|
> pero sí que tiene que solicitarlo activamente.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Resumen rápido (checklist por carta)
|
||||||
|
|
||||||
|
- [ ] Crear cada artículo de la semana y **publicarlo** (no dejarlo en `draft`/`[PRUEBA]`).
|
||||||
|
- [ ] Guardar la URL (`link`) de cada artículo **después** de publicarlo, no antes (el slug
|
||||||
|
puede cambiar por colisión con otro post del mismo título).
|
||||||
|
- [ ] Asignar `_carta_id` a cada artículo con el endpoint de §1.5 (`fea/v1/carta-id/{id}`).
|
||||||
|
- [ ] Componer la carta en HTML con los **encabezados exactos** de §5 y los enlaces a esos artículos.
|
||||||
|
- [ ] Publicar o programar la carta en las categorías **6 y 21** (§3.1, §4 Paso 3).
|
||||||
|
- [ ] Rotar la carta anterior: quitar 6, poner 22 (conservando su 21). A la que estaba en 22,
|
||||||
|
quitarle solo la 22 (conservando su 21 — nunca se quita la 21 a nadie, §4 Paso 4).
|
||||||
|
- [ ] Comprobar que la portada muestra las secciones (esperar hasta 15 min si hace falta).
|
||||||
|
- [ ] No traducir ni generar audio a mano — pero SÍ pedirlo a Hermes (WhatsApp/Telegram, ver §7 y `docs/guia-tts-traduccion-inma.md`). No es automático todavía.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Referencia rápida de la API
|
||||||
|
|
||||||
|
| Acción | Método y endpoint |
|
||||||
|
|--------|-------------------|
|
||||||
|
| Probar credencial | `GET /wp-json/wp/v2/users/me` |
|
||||||
|
| Listar categorías | `GET /wp-json/wp/v2/categories?per_page=100` |
|
||||||
|
| Crear artículo/carta | `POST /wp-json/wp/v2/posts` |
|
||||||
|
| Editar artículo/carta | `POST /wp-json/wp/v2/posts/{id}` |
|
||||||
|
| Ver carta vigente | `GET /wp-json/wp/v2/posts?categories=6` |
|
||||||
|
| Subir imagen | `POST /wp-json/wp/v2/media` (cabecera `Content-Disposition`) |
|
||||||
|
| Leer `_carta_id` de un artículo | `GET /wp-json/fea/v1/carta-id/{id}` |
|
||||||
|
| Asignar `_carta_id` a un artículo | `POST /wp-json/fea/v1/carta-id/{id}` con body `{"carta_id": N}` |
|
||||||
|
| Borrar `_carta_id` de un artículo | `DELETE /wp-json/fea/v1/carta-id/{id}` |
|
||||||
|
|
||||||
|
## Regla para la sección Evangelio y comentarios al Evangelio
|
||||||
|
|
||||||
|
La sección empieza siempre con el enlace al post del evangelio de esa semana (por
|
||||||
|
ejemplo, `MATEO 14, 22-33`) y después van los comentarios. Si ese post no existe,
|
||||||
|
avisad antes de publicar la carta: no empecéis la sección por un comentario.
|
||||||
|
|
||||||
|
No hay que completar ningún campo nuevo. A partir de ese enlace, el sitio asigna la
|
||||||
|
cita bíblica automáticamente a los comentarios nuevos.
|
||||||
|
|
||||||
|
Campos útiles del post: `title`, `content` (HTML), `status` (`draft`/`publish`/`future`),
|
||||||
|
`date` (ISO 8601 para programar), `categories` (array de IDs), `featured_media` (ID de adjunto).
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Guía de traducción y audio (TTS) para Inma / Mixbot — feadulta.com
|
||||||
|
|
||||||
|
> **Para quién es este documento:** para Inma y su asistente (Mixbot / Cowork), y como
|
||||||
|
> referencia para Hermes cuando se le pide que traduzca o locute un artículo/carta.
|
||||||
|
>
|
||||||
|
> **Estado real a 2026-07-12 (importante, corrige la guía de publicación §7 de versiones
|
||||||
|
> anteriores): esto NO es automático.** No hay ningún cron corriendo hoy que traduzca o genere
|
||||||
|
> audio solo al publicar — esa automatización es la propuesta abierta
|
||||||
|
> [issue #23](https://gitea.feadulta.com/rafa/feadulta/issues/23), sin implementar. Todo lo de
|
||||||
|
> abajo es un proceso que **hay que pedir**, hoy solo ejecutable en el servidor/PC de Rafa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Quién puede hacer qué, hoy
|
||||||
|
|
||||||
|
| Tarea | ¿Quién puede hacerla sin Rafa presente? |
|
||||||
|
|---|---|
|
||||||
|
| Publicar carta/artículos en español | **Sí, Inma/Mixbot solos** — API REST ya funciona (ver `docs/guia-publicacion-carta-inma.md`). No depende del PC de Rafa. |
|
||||||
|
| Pedir traducción o TTS | **Solo indirectamente**: hay que pedírselo a **Hermes** (WhatsApp/Telegram). Los scripts que traducen y locutan viven únicamente en el PC/servidor de Rafa (Docker local + credenciales locales) — Mixbot no tiene acceso directo a ellos. |
|
||||||
|
| Traducir/locutar si Hermes tampoco está disponible | **Hoy, no.** Es la limitación real que hay que conocer: si el PC de Rafa está apagado o Hermes está caído, ni Inma ni Mixbot pueden disparar esto por su cuenta. Ver §5 (qué falta para que esto no dependa de Hermes). |
|
||||||
|
|
||||||
|
## 2. Cómo pedir una traducción o un audio (mientras Hermes esté disponible)
|
||||||
|
|
||||||
|
Escribir al grupo de WhatsApp `Feadulta_webmaster` mencionando "Hermes" (o por Telegram),
|
||||||
|
indicando qué carta/artículo (ID de WordPress o título+fecha si no se tiene el ID) y qué se
|
||||||
|
necesita: traducción, audio, o ambos. Ejemplos:
|
||||||
|
|
||||||
|
> "Hermes, tradúceme la carta 54XXX a los 4 idiomas"
|
||||||
|
> "Hermes, genera el audio de los artículos de la carta de esta semana"
|
||||||
|
|
||||||
|
Hermes ejecuta los scripts de abajo en el servidor de Rafa. No hace falta que Inma/Mixbot sepan
|
||||||
|
los nombres de los scripts ni los IDs internos — es información para cuando Hermes (o Rafa)
|
||||||
|
necesite el detalle técnico.
|
||||||
|
|
||||||
|
## 3. Traducción — motores disponibles
|
||||||
|
|
||||||
|
`scripts/translate_post.py` (repo `joomla-migration`) soporta tres motores via `FEA_ENGINE`:
|
||||||
|
|
||||||
|
| Motor | Coste | Cuándo usarlo |
|
||||||
|
|---|---|---|
|
||||||
|
| **`gemma`** (por defecto) | Gratis (modelo local, LM Studio en el PC de Rafa) | Opción por defecto. Requiere que el PC/GPU de Rafa esté encendido. |
|
||||||
|
| **`minimax`** | De pago, acotado (misma cuenta que el TTS) | Alternativa cuando Gemma no está disponible o la calidad no basta. |
|
||||||
|
| **`haiku`** | ⚠️ **De pago vía API directa de Anthropic** (no es cuota de sesión) | **No usar por defecto ni de forma autónoma.** Choca con la política de no gastar API de pago sin que Rafa confirme cada vez. Reservado para cuando Rafa lo ejecuta él mismo o da autorización puntual. |
|
||||||
|
|
||||||
|
Comando (lo ejecuta Hermes o Rafa, no Inma/Mixbot directamente):
|
||||||
|
```bash
|
||||||
|
cd /home/rafa/joomla-migration
|
||||||
|
python3 scripts/translate_post.py --carta <ID_CARTA> --langs en,fr,it,pt --status draft
|
||||||
|
# o para un solo artículo:
|
||||||
|
python3 scripts/translate_post.py --post-id <ID_POST> --langs en,fr,it,pt --status draft
|
||||||
|
```
|
||||||
|
`--status draft` dejar en borrador para revisión; `--status publish` publica directo. Tras
|
||||||
|
traducir, hace falta el paso de enlaces internos (`scripts/fix_carta_joomla_links.php`) y, si se
|
||||||
|
publica, degradar la carta anterior (`scripts/demote_old_cartasemana.php`) — Hermes ya conoce
|
||||||
|
este flujo (ver skill `feadulta-webmaster`, `references/procedures.md`).
|
||||||
|
|
||||||
|
## 4. Audio (TTS) — voces por autor
|
||||||
|
|
||||||
|
`scripts/minimax_tts.py` + `scripts/tts_produce.py` (genera y escribe en WP local) +
|
||||||
|
`scripts/sync_audio_to_prod.py` (sube a prod, soporta `--rollback` para deshacer). Modelo MiniMax
|
||||||
|
`speech-2.8-hd`.
|
||||||
|
|
||||||
|
**Voz por defecto:** `NicoFeadulta2026` (todos los autores sin voz clonada).
|
||||||
|
|
||||||
|
**Voces clonadas por autor** (issue #152 — solo estos 4 autores usan su propia voz, el resto cae
|
||||||
|
a Nico):
|
||||||
|
|
||||||
|
| Autor | WP user_id | voice_id |
|
||||||
|
|---|---|---|
|
||||||
|
| Fray Marcos | 382 | `FrayMarcosFeadulta2026` |
|
||||||
|
| José Antonio Pagola | 383 | `PagolaFeadulta2026` |
|
||||||
|
| José Luis Sicre | 774 | `SicreFeadulta2026` |
|
||||||
|
| José Arregi | 386 | `ArregiFeadulta2026` |
|
||||||
|
|
||||||
|
Añadir un autor nuevo a esta lista requiere clonar su voz primero (grabación limpia 2-5 min, sin
|
||||||
|
música/ruido de fondo — verificar con espectrograma antes de clonar, ver memoria
|
||||||
|
`feadulta-tts-voz-fraymarcos-202607` para el procedimiento y los descartes por música colada) y
|
||||||
|
añadirlo a `AUTHOR_VOICES` en `scripts/minimax_tts.py`. Esto sí requiere que Rafa (o alguien con
|
||||||
|
acceso al repo y a MiniMax) lo haga — no es autoservicio para Inma/Mixbot hoy.
|
||||||
|
|
||||||
|
**Generar audio de una carta concreta** (por defecto `tts_produce.py` procesa una cola larga de
|
||||||
|
cartas pendientes — para priorizar una carta concreta, sobreescribir la cola):
|
||||||
|
```bash
|
||||||
|
cd /home/rafa/joomla-migration
|
||||||
|
FEA_TTS_CARTAS="<ID_CARTA>" python3 scripts/tts_produce.py
|
||||||
|
```
|
||||||
|
Reanudable (no repite lo ya hecho, meta `fea_audio_done`) y con freno automático si la cuota de
|
||||||
|
MiniMax se agota (para tras fallos seguidos, no se queda colgado).
|
||||||
|
|
||||||
|
**Publicar el audio en prod** (el paso anterior solo escribe en el WordPress local):
|
||||||
|
```bash
|
||||||
|
python3 scripts/sync_audio_to_prod.py --carta <ID_CARTA>
|
||||||
|
# deshacer si algo suena mal:
|
||||||
|
python3 scripts/sync_audio_to_prod.py --rollback --carta <ID_CARTA>
|
||||||
|
```
|
||||||
|
Runbook de rollback ya documentado para que Hermes lo ejecute sin Rafa presente: issue #163.
|
||||||
|
|
||||||
|
## 5. La API key de MiniMax — decisión pendiente (de Rafa, no resuelta en este documento)
|
||||||
|
|
||||||
|
Hoy la key vive en un fichero local de Rafa, usada para TTS (y podría usarse para traducción
|
||||||
|
`FEA_ENGINE=minimax`). Para que Inma/Mixbot puedan disparar esto sin pasar por Hermes, harían
|
||||||
|
falta tanto acceso a esta key como acceso al entorno donde corren los scripts (Docker local del
|
||||||
|
PC de Rafa) — hoy ninguna de las dos cosas es cierta. Ver el issue maestro
|
||||||
|
[#172](https://gitea.feadulta.com/rafa/feadulta/issues/172) para las opciones que se están
|
||||||
|
valorando (key separada para Inma, gestor de secretos compartido, o mantener todo detrás de
|
||||||
|
Hermes). Mientras no se decida, la vía real es §2: pedírselo a Hermes.
|
||||||
|
|
||||||
|
## 6. Qué falta para que esto sea de verdad independiente de Hermes/Rafa
|
||||||
|
|
||||||
|
Siendo honestos: hoy, si el PC de Rafa está apagado (viaje, avería, lo que sea) y Hermes no
|
||||||
|
responde, **no hay forma de que Inma/Mixbot generen traducción o audio por su cuenta** — los
|
||||||
|
scripts y el WordPress local que usan como paso intermedio solo existen ahí. Para que esto
|
||||||
|
cambiara de verdad haría falta uno de:
|
||||||
|
- Mover el pipeline de traducción/TTS a un sitio alcanzable por Mixbot directamente (ej. correr
|
||||||
|
contra prod en vez de contra el WordPress local, y alojar los scripts en un servidor
|
||||||
|
accesible, no en el PC personal de Rafa).
|
||||||
|
- O implementar de una vez el cron automático (issue #23) para que ni siquiera haga falta
|
||||||
|
pedirlo — se dispara solo al publicar en español.
|
||||||
|
|
||||||
|
Ninguna de las dos está hecha. Documentado aquí para que la decisión de priorizarlo (o no) sea
|
||||||
|
consciente, no un descuido.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Handoff — Carta 46956 «Entre todos» (sesión 2026-06-17)
|
||||||
|
|
||||||
|
Estado para que **Codex** continúe. Se hizo TODO en **local** (Docker `wordpress-web`/`wordpress-mysql`). **Nada está en prod todavía.** El despliegue a prod quedó **pendiente y SIN hacer** (Rafa quiere ir por partes).
|
||||||
|
|
||||||
|
Repo: `/home/rafa/joomla-migration` (= remoto Gitea `rafa/feadulta`). Ver también wiki «Ciclo carta nueva» y memoria del ciclo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Qué se hizo en LOCAL (todo verificado)
|
||||||
|
|
||||||
|
### Carta y artículos
|
||||||
|
- **Carta 46956 «Entre todos»** (ES). Estaba en estado `future` por desfase de zona horaria (server US detrás de Madrid) → **publicada** (`post_status=publish`, fecha 2026-06-18). **Issue #87 (cerrado).**
|
||||||
|
- **19 artículos** con `_carta_id=46956` (IDs 46937–46955). De ellos **15 son ES** (46937–46951) y 4 ya venían en otros idiomas desde Joomla.
|
||||||
|
- **Cluster multiidioma** 46951–46955 = el MISMO artículo en 5 idiomas; **enlazado** en Polylang: `{es:46951, fr:46952, en:46953, it:46954, pt:46955}` (no se tradujo, ya estaba).
|
||||||
|
|
||||||
|
### Traducciones (motor Haiku)
|
||||||
|
- **Carta + 14 artículos ES → EN/FR/IT/PT = 60 posts** (IDs **46959–47018**), `publish`.
|
||||||
|
- Motor: **`FEA_ENGINE=haiku`** (nuevo flag en `scripts/translate_post.py`) → usa Claude Haiku 4.5 vía `translate_haiku.py`. API key en `/home/rafa/portfolio-tracker/.env`. Coste ~0.6–0.8 €/carta.
|
||||||
|
- Comando: `FEA_ENGINE=haiku python3 scripts/translate_post.py --carta 46956 --langs en,fr,it,pt --status draft` (luego publicado).
|
||||||
|
|
||||||
|
### Evangelio / lecturas bíblicas (**issue #88**)
|
||||||
|
- El pasaje **ES `MATEO 10, 26-33` (post 2682)** solo existía en ES. Política: **DESCARGAR la Biblia oficial, NO traducir con LLM**.
|
||||||
|
- Descargado de **bolls.life** y creados 4 posts (IDs **47079–47082**), enlazados Polylang a 2682, cat "Lecturas bíblicas" mapeada, `publish`:
|
||||||
|
- EN=**Douay-Rheims** (católica), PT=**CNBB** (católica), IT=**Nuova Riveduta 2006** (única moderna IT), FR=**Bible du Semeur** (no hay católica libre en bolls).
|
||||||
|
- Scripts: `scripts/fetch_lectura_bolls.py` (descarga) + `scripts/create_lecturas.php` (crea posts).
|
||||||
|
|
||||||
|
### Enlaces internos de las cartas
|
||||||
|
Las cartas traían enlaces Joomla legacy `es/buscadoravanzado/item/<k2_id>-<slug>.html` (rotos) y luego enlaces al pasaje ES. Arreglado en 2 pasos:
|
||||||
|
1. `scripts/fix_carta_joomla_links.php` — mapea `item/<id>` por meta `_fgj2wp_old_k2_id` → permalink WP del artículo en el idioma de cada carta.
|
||||||
|
2. `scripts/repoint_carta_links.php` — repunta enlaces ya-permalink que apuntaban al ES → a la traducción del idioma de la carta (p.ej. el evangelio).
|
||||||
|
- `APPLY=1 CARTA=46956 docker exec ... php /tmp/<script>.php`. Sin mapear (se dejan): `tablon-de-anuncios`, `noticias-de-alcance`, ítem `18151 el-buen-humor` (fuera del delta), multimedia `9155`.
|
||||||
|
|
||||||
|
### Categorías "carta de la semana"
|
||||||
|
- `scripts/demote_old_cartasemana.php` — deja en `cartasemana` (term 6) **solo la carta nueva** en CADA idioma (count=1 → el menú redirige directo al single). Movía las cartas anteriores a "cartas de otras semanas". Aplicado: count=1 en es/en/fr/it/pt.
|
||||||
|
|
||||||
|
### Verificación local (vía Caddy, NO localhost:8081)
|
||||||
|
- Cartas: `/fea/entre-todos/`, `/fea/en/among-all/`, `/fea/fr/entre-tous/`, `/fea/it/tra-tutti/`, `/fea/pt/entre-todos-2/` — todas publish.
|
||||||
|
- Menú "carta de la semana" por idioma → 302 al single de cada idioma.
|
||||||
|
- Evangelio enlaza al pasaje en su idioma (47079–47082), todos HTTP 200.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Estado de PROD (verificado read-only 2026-06-17)
|
||||||
|
|
||||||
|
SSH correcto: **`sshpass -p "$FEA_PROD_SSH_PASS" ssh feadulta@134.0.10.170`** (la pass de cPanel/FTP que dio Rafa NO sirve para SSH — son credenciales distintas; ambas en `.env`, no aquí). wp-cli en `/web/wp-nuevo`. NO scp (usar `ssh 'cat > ruta'`).
|
||||||
|
|
||||||
|
- **MAX post ID en prod = 46809.** Los IDs locales nuevos (46937–47082) están LIBRES en prod.
|
||||||
|
- **La carta de esta semana NO está en prod.** La "carta de la semana" actual en prod es la **anterior: 45018 «La puerta pequeña»** (2026-06-13).
|
||||||
|
- El pasaje ES **2682 `MATEO 10,26-33` SÍ está** en prod (post viejo). Faltan sus 4 traducciones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. PENDIENTE: desplegar la carta 46956 a prod (lo que paró Rafa)
|
||||||
|
|
||||||
|
Objetivo: que la carta de esta semana se vea bien en prod en los 5 idiomas. Implica el delta ES + traducciones + evangelios + estructura. **Rafa quiere ir por partes** (NO hacer todo de golpe sin su OK).
|
||||||
|
|
||||||
|
### ⚠️ Problema de coincidencia de IDs (clave)
|
||||||
|
La regla "IDs ES coinciden local↔prod" se ha ROTO: prod va por detrás (max 46809) mientras local hizo el delta cuando su max era 46936. Una reimportación fresca desde Joomla en prod asignaría IDs ~46810, **distintos de los locales**, y rompería el casado de traducciones/enlaces.
|
||||||
|
- **Enfoque recomendado:** replicar los posts locales a prod **preservando los IDs** (46937–47082 + nada de 2682 que ya está), ya que están libres en prod. Es decir, NO reimportar desde Joomla: volcar el contenido local (ES + traducciones + evangelios) a prod con `wp_insert_post`/inserción con ID explícito, reconstruir Polylang + categorías + metas (`_carta_id`, `_fgj2wp_old_k2_id`), y aplicar los mismos arreglos de enlaces y degradado de categoría.
|
||||||
|
- Revisar `scripts/sync_translations_to_prod.py` (opción A, ya validada para traducciones reusando texto local) y adaptarlo/extenderlo para incluir el delta ES y los pasajes del evangelio con ID preservado.
|
||||||
|
|
||||||
|
### Checklist prod (por partes, con OK de Rafa en cada salto)
|
||||||
|
1. [ ] Delta ES a prod: carta 46956 + artículos 46937–46955 con IDs preservados, metas (`_carta_id`, `_fgj2wp_old_k2_id`), categorías, idioma ES. Revisar.
|
||||||
|
2. [ ] Cluster multiidioma 46951–46955: enlazar Polylang en prod (los 5 ya existirían tras paso 1).
|
||||||
|
3. [ ] Traducciones EN/FR/IT/PT (carta + artículos): 46959–47018 con IDs preservados, Polylang, categorías.
|
||||||
|
4. [ ] Evangelio: crear en prod las 4 traducciones del 2682 (47079–47082) enlazadas a 2682 (que ya está en prod). Reusar `fetch_lectura_bolls.py` + `create_lecturas.php` adaptado a `FEA_WP_LOAD=/web/wp-nuevo/wp-load.php`.
|
||||||
|
5. [ ] Enlaces internos en prod: `fix_carta_joomla_links.php` + `repoint_carta_links.php` (con `FEA_WP_LOAD` de prod).
|
||||||
|
6. [ ] Degradar carta anterior en prod: `demote_old_cartasemana.php CARTA=46956` (mueve 45018 y traducciones a "otras semanas", deja count=1 por idioma).
|
||||||
|
7. [ ] Verificar vía Cloudflare/web: cartas y evangelio en los 5 idiomas (NB: en prod Cloudflare bloquea curl; verificar con `wp eval` o navegador).
|
||||||
|
8. [ ] (Después) TTS de la carta nueva — paso 5 del ciclo, prioritario sobre el gap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Scripts nuevos de esta sesión (en `scripts/`)
|
||||||
|
- `fix_carta_joomla_links.php` — enlaces Joomla `.html` → permalink WP por idioma.
|
||||||
|
- `repoint_carta_links.php` — repunta enlaces ya-permalink al idioma de la carta.
|
||||||
|
- `publish_carta.php` — draft→publish de carta+artículos+traducciones, corrige fechas futuras (timezone).
|
||||||
|
- `demote_old_cartasemana.php` — deja count=1 en `cartasemana` por idioma.
|
||||||
|
- `fetch_lectura_bolls.py` + `create_lecturas.php` — descarga e inserta pasajes bíblicos (issue #88).
|
||||||
|
- `translate_post.py` — añadido flag `FEA_ENGINE=haiku`.
|
||||||
|
|
||||||
|
## 5. Issues Gitea relacionados
|
||||||
|
- **#84** — Migrar delta carta nueva (prerequisito scripts; ya corregidos).
|
||||||
|
- **#87** — Carta no aparecía (estado `future` por timezone). **CERRADO.**
|
||||||
|
- **#88** — Lecturas bíblicas solo en ES → descargar EN/FR/IT/PT. **ABIERTO** (esta semana hecha en local; backfill histórico pendiente).
|
||||||
|
- Crear/usar el issue de **despliegue a prod** (este handoff) para el seguimiento.
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# Handoff técnico para Rafa — issues #174 y #175
|
||||||
|
|
||||||
|
## Alcance
|
||||||
|
Preparación técnica solamente. **No aplicar en producción desde aquí.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue #174 — cierre autónomo de la carta (publicar + rotar)
|
||||||
|
|
||||||
|
### Regla funcional confirmada por Inma/Mixbot
|
||||||
|
La rotación que ya funcionó hoy manualmente por WP REST API para la carta 735 es esta, y **solo para el post ES**:
|
||||||
|
|
||||||
|
1. **nueva carta** → añadir cat **6** (`carta actual`)
|
||||||
|
2. **la que estaba en cat 6** → quitar cat 6, añadir cat **22** (`semana pasada`), manteniendo cat **21** (`archivo acumulativo`)
|
||||||
|
3. **la que estaba en cat 22** → quitar cat 22, dejar solo cat **21**
|
||||||
|
4. **no tocar traducciones** en este paso
|
||||||
|
|
||||||
|
### Decisión pendiente para Rafa
|
||||||
|
Hay dos caminos válidos:
|
||||||
|
|
||||||
|
#### Opción A — adaptar `rotate_cartas.php` a ES-only
|
||||||
|
Pros:
|
||||||
|
- reutiliza lógica ya existente y conocida en el repo
|
||||||
|
- más coherente con el toolkit actual de scripts
|
||||||
|
- fácil dejar `DRY-RUN` + `APPLY=1`
|
||||||
|
|
||||||
|
Contras:
|
||||||
|
- hay que modificar el script actual porque hoy rota todos los idiomas
|
||||||
|
- si Mixbot publica por REST y la rotación la hace otro script aparte, el flujo queda repartido en dos piezas
|
||||||
|
|
||||||
|
#### Opción B — que Mixbot haga la rotación por WP REST API
|
||||||
|
Pros:
|
||||||
|
- ya está validado en vivo hoy
|
||||||
|
- mantiene toda la operación editorial en el mismo sitio donde ya se publica la carta
|
||||||
|
- evita acoplar la decisión editorial a un script server-side adicional
|
||||||
|
|
||||||
|
Contras:
|
||||||
|
- la lógica queda fuera del repo de scripts PHP si no se documenta bien
|
||||||
|
- conviene dejarla muy explícita para no divergirse del comportamiento esperado
|
||||||
|
|
||||||
|
### Recomendación técnica
|
||||||
|
Mi lectura: **cualquiera vale**, pero elegiría según quién vaya a mantenerlo:
|
||||||
|
|
||||||
|
- si Rafa quiere la lógica **versionada y centralizada en el repo**, mejor **A: `rotate_cartas.php` ES-only**
|
||||||
|
- si Inma/Mixbot ya tienen el flujo sólido y prefieren autonomía total desde su lado, mejor **B: REST API desde Mixbot**, con la lógica documentada en el issue/runbook
|
||||||
|
|
||||||
|
### Si Rafa elige A (script ES-only), propuesta concreta
|
||||||
|
Crear una variante mínima o adaptar `rotate_cartas.php` con estas reglas:
|
||||||
|
|
||||||
|
- entrada: `CARTA=<es_id>`
|
||||||
|
- lookup solo en idioma `es`
|
||||||
|
- localizar:
|
||||||
|
- `actual_es` = post con cat 6
|
||||||
|
- `pasada_es` = post con cat 22
|
||||||
|
- `nueva_es` = `CARTA`
|
||||||
|
- operaciones:
|
||||||
|
- `pasada_es` pierde 22, conserva 21
|
||||||
|
- `actual_es` pierde 6, gana 22, conserva 21
|
||||||
|
- `nueva_es` gana 6 y conserva/gana 21
|
||||||
|
- **nunca tocar** posts EN/FR/IT/PT
|
||||||
|
- mantener modo `DRY-RUN`
|
||||||
|
|
||||||
|
### Dry-run deseable para Rafa
|
||||||
|
Salida ideal del dry-run:
|
||||||
|
- `nueva_es=#54495 -> +6 (+21 si faltaba)`
|
||||||
|
- `actual_es=#54254 -> -6 +22 (mantiene 21)`
|
||||||
|
- `pasada_es=#53984 -> -22 (mantiene 21)`
|
||||||
|
- `translations untouched`
|
||||||
|
|
||||||
|
### Decisión funcional que conviene dejar escrita
|
||||||
|
Para evitar ambigüedad futura, dejar explícito en el issue o en el script:
|
||||||
|
- `21` es acumulativa y la conservan todas las cartas archivables
|
||||||
|
- `6` y `22` son mutuamente excluyentes
|
||||||
|
- la rotación editorial inicial afecta **solo al ES**
|
||||||
|
- las traducciones rotan/publican en un paso posterior independiente
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue #175 — endpoint `subir-avatar`
|
||||||
|
|
||||||
|
### Base ya existente
|
||||||
|
El sistema actual ya tiene casi todo:
|
||||||
|
- frontend lee `user_meta('foto_perfil')`
|
||||||
|
- `face_crop_avatar.py` ya resuelve recorte/centrado de cara
|
||||||
|
- `regen_avatars.php` ya existe para regeneración/ajustes
|
||||||
|
- caso real listo para probar: **Silvia Martínez Cano**, `user_id=1112`, `slug=silvia-mtnz`
|
||||||
|
|
||||||
|
### Contrato recomendado
|
||||||
|
Yo le propondría a Rafa este contrato por simplicidad operativa:
|
||||||
|
|
||||||
|
#### Endpoint
|
||||||
|
`POST /wp-json/fea/v1/subir-avatar`
|
||||||
|
|
||||||
|
#### Auth
|
||||||
|
La misma Application Password que ya usa `crear-autor` (`#166`), con el mismo criterio de permisos.
|
||||||
|
|
||||||
|
#### Identificador
|
||||||
|
**`user_id`** mejor que `slug`.
|
||||||
|
|
||||||
|
Motivo:
|
||||||
|
- Mixbot ya lo tiene en el roster
|
||||||
|
- evita ambigüedades por slug cambiado / transliteraciones / colisiones
|
||||||
|
- simplifica el lookup
|
||||||
|
|
||||||
|
#### Formato de entrada
|
||||||
|
**multipart/form-data** mejor que base64.
|
||||||
|
|
||||||
|
Motivo:
|
||||||
|
- más natural para subir imágenes
|
||||||
|
- menos overhead
|
||||||
|
- encaja mejor con `media_handle_sideload` / manejo típico WP
|
||||||
|
- más fácil de depurar que base64
|
||||||
|
|
||||||
|
#### Campo esperado
|
||||||
|
- `user_id` (obligatorio)
|
||||||
|
- `file` (obligatorio)
|
||||||
|
|
||||||
|
Opcionalmente:
|
||||||
|
- `crop=server|client` si Rafa quiere dejar ambas puertas abiertas, aunque probablemente es overkill de entrada
|
||||||
|
|
||||||
|
### Recomendación de procesamiento
|
||||||
|
Mi recomendación clara: **que el endpoint NO haga face-crop complejo** en la primera versión.
|
||||||
|
|
||||||
|
Mejor v1:
|
||||||
|
- Mixbot/Inma mandan la imagen **ya cuadrada**
|
||||||
|
- estándar objetivo: **1254x1254** (el que ya usáis)
|
||||||
|
- el endpoint solo:
|
||||||
|
1. valida usuario
|
||||||
|
2. guarda attachment
|
||||||
|
3. actualiza `foto_perfil`
|
||||||
|
4. devuelve `attachment_id`, `user_id`, `url`
|
||||||
|
|
||||||
|
Pros:
|
||||||
|
- mucho menos riesgo y menos dependencias server-side
|
||||||
|
- evita meter OpenCV/Pillow/lógica pesada dentro del endpoint
|
||||||
|
- más fácil de testear e idempotente
|
||||||
|
- si mañana queréis face-crop automático, se añade en v2
|
||||||
|
|
||||||
|
### Comportamiento recomendado del endpoint
|
||||||
|
- requiere login + permiso tipo `edit_others_posts` (igual filosofía que `crear-autor`)
|
||||||
|
- valida que `user_id` exista
|
||||||
|
- acepta solo imagen (`jpg`, `jpeg`, `png`, `webp` si WP lo admite ahí)
|
||||||
|
- crea attachment en Media Library
|
||||||
|
- hace `update_user_meta($user_id, 'foto_perfil', $attachment_id)`
|
||||||
|
- respuesta JSON:
|
||||||
|
- `user_id`
|
||||||
|
- `display_name`
|
||||||
|
- `attachment_id`
|
||||||
|
- `url`
|
||||||
|
- `updated: true`
|
||||||
|
|
||||||
|
### Idempotencia mínima útil
|
||||||
|
No hace falta obsesionarse en v1, pero sí conviene:
|
||||||
|
- si se vuelve a subir otra foto para el mismo `user_id`, simplemente reemplazar el `foto_perfil` al nuevo attachment
|
||||||
|
- opcional: si el hash del fichero coincide con el ya asignado, devolver `updated:false`
|
||||||
|
|
||||||
|
### Caso piloto sugerido
|
||||||
|
Primera prueba con:
|
||||||
|
- `user_id=1112`
|
||||||
|
- `slug=silvia-mtnz`
|
||||||
|
- imagen ya recortada por Inma/Mixbot a **1254x1254**
|
||||||
|
|
||||||
|
Así la prueba valida solo el endpoint, no el pipeline de crop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cierres administrativos
|
||||||
|
Por estado funcional, salvo sorpresa:
|
||||||
|
- `#166` → cerrable
|
||||||
|
- `#168` → cerrable
|
||||||
|
- `#170` → cerrable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recomendación final para Rafa
|
||||||
|
Si quiere minimizar trabajo y riesgo:
|
||||||
|
- **#174**: usar la lógica que ya validó Mixbot hoy, y solo portar al repo si él prefiere centralizar
|
||||||
|
- **#175**: endpoint mínimo con `user_id` + multipart + imagen ya cuadrada a 1254x1254
|
||||||
|
|
||||||
|
Eso deja la autonomía mucho más cerca sin meterse en una refactor rara.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Plan — Buscador avanzado NATIVO (feadulta #8, sin Typesense)
|
||||||
|
|
||||||
|
> **Para el agente ejecutor (Sonnet).** Replica el «Buscador avanzado» del Joomla viejo
|
||||||
|
> con WordPress nativo + MySQL FULLTEXT. Corre en el hosting actual (sin Docker/servicios).
|
||||||
|
> Typesense queda como mejora futura opcional (`docs/plan-buscador-typesense.md`).
|
||||||
|
|
||||||
|
## ⛔ Restricciones DURAS
|
||||||
|
- **Trabaja SOLO en LOCAL** (contenedor Docker `wordpress-web`, navegable en `http://localhost:8081/`;
|
||||||
|
equivalente Tailscale `https://farmer.taild3aaf6.ts.net/fea/`).
|
||||||
|
- **NO toques producción.** No abras SSH a `feadulta@134.0.10.170`. No despliegues nada.
|
||||||
|
- **NO commitees.** Deja el working tree listo; Rafa verifica antes de subir.
|
||||||
|
- wp-cli: usa `docker exec wordpress-web wp eval '<php>' --allow-root` y `wp eval-file`.
|
||||||
|
**`wp db query` NO funciona** en este contenedor (sin binario mysql) → para SQL usa
|
||||||
|
`global $wpdb; $wpdb->query(...)` dentro de `wp eval`.
|
||||||
|
- No metas bloques Gutenberg (`<!-- wp:... -->`) en `post_content` vía CLI (se guardan literales).
|
||||||
|
- Sigue el estilo de los mu-plugins existentes (`wordpress/wp-content/mu-plugins/fea-*.php`).
|
||||||
|
|
||||||
|
## Contexto ya hecho (fase 1, MVP nativo, en prod y local)
|
||||||
|
- `mu-plugins/fea-search.php`: barra de búsqueda visible en móvil; action a la raíz del
|
||||||
|
idioma (Polylang). En desktop se usa el buscador del menú.
|
||||||
|
- Template FSE `search` (creado con `scripts/set_search_template.php`) que muestra los
|
||||||
|
resultados en **rejilla de tarjetas** reutilizando las clases `fea-archive-grid` /
|
||||||
|
`fea-archive-card` (las del `archive`, #63). En local es el wp_template post 53826.
|
||||||
|
- La búsqueda nativa `/?s=` funciona.
|
||||||
|
|
||||||
|
## Qué tenía el «Buscador avanzado» K2 (a replicar)
|
||||||
|
Cinco modos: **palabra**, **autor**, **tema (categoría)**, **cita bíblica**, **fecha**.
|
||||||
|
|
||||||
|
## Implementación
|
||||||
|
|
||||||
|
### 1. Motor: MySQL FULLTEXT
|
||||||
|
- Verifica engine/versión: `SELECT VERSION()`, engine de `wp_posts` (esperado InnoDB, MySQL ≥5.6).
|
||||||
|
- Añade índice (idempotente; comprueba antes con `SHOW INDEX ... WHERE Index_type='FULLTEXT'`):
|
||||||
|
`ALTER TABLE wp_posts ADD FULLTEXT fea_ft (post_title, post_content);`
|
||||||
|
- mu-plugin **`fea-search-fulltext.php`**: en `is_search() && is_main_query() && !is_admin()`
|
||||||
|
y con términos, sustituye el `LIKE` por
|
||||||
|
`MATCH(wp_posts.post_title, wp_posts.post_content) AGAINST ('<term>*' IN BOOLEAN MODE)`
|
||||||
|
(filtros `posts_search` + `posts_search_orderby`/`posts_clauses`), ordenando por relevancia
|
||||||
|
cuando no se pida otro orden. Sanitiza el término. Si falla/no aplica, deja el comportamiento
|
||||||
|
nativo (degradación elegante). Debe **convivir** con los filtros del punto 3 (autor, cat,
|
||||||
|
date, meta) sin romper el WHERE.
|
||||||
|
|
||||||
|
### 2. Formulario de búsqueda avanzada
|
||||||
|
- mu-plugin **`fea-search-advanced.php`** que renderiza un formulario (method=get, action a la
|
||||||
|
raíz del idioma como en `fea-search.php`) con:
|
||||||
|
- texto `s` (palabra/frase),
|
||||||
|
- `<select name="fea_author">` (autores con ≥30 posts, excluyendo `FEA_AUTORES_EXCLUIR`
|
||||||
|
= [1,890,1049,1540], orden por `display_name`),
|
||||||
|
- `<select name="fea_cat">` (categorías TEMA: 1650 Artículos, 1647 Comentarios al evangelio,
|
||||||
|
1648 Eucaristía, 1649 Multimedia, 1645 Lecturas, 1646 Comentario editorial, 63 EFFA — usa
|
||||||
|
slugs/ids reales; excluye las de carta 6/21/22),
|
||||||
|
- texto `fea_cita` (cita bíblica, ej. «Jn 3» o «Mt»),
|
||||||
|
- fechas `fea_date_from` / `fea_date_to` (o año desde/hasta).
|
||||||
|
- Muéstralo en la página de resultados (template search) arriba, con los valores seleccionados
|
||||||
|
persistentes. Añade un enlace «Búsqueda avanzada» desde la barra `fea-search`.
|
||||||
|
- Considera una página dedicada `/buscar` (page slug `buscar`) que muestre el formulario aunque
|
||||||
|
no haya consulta aún (opcional pero recomendable como destino del enlace).
|
||||||
|
|
||||||
|
### 3. Aplicar filtros — `pre_get_posts`
|
||||||
|
En el mismo mu-plugin, registra las query vars (`query_vars` filter) y en `pre_get_posts`
|
||||||
|
(`is_search`, main query, !admin):
|
||||||
|
- `fea_author` → `$q->set('author', (int))`.
|
||||||
|
- `fea_cat` → `$q->set('cat', (int))`.
|
||||||
|
- `fea_cita` → `meta_query` `[['key'=>'_cita_evangelio','value'=>$cita,'compare'=>'LIKE']]`
|
||||||
|
(el sitio ya tiene 4.290 metas `_cita_evangelio`).
|
||||||
|
- `fea_date_from`/`fea_date_to` → `date_query`.
|
||||||
|
Combinables entre sí y con la palabra (FULLTEXT). Si solo se pasan filtros sin `s`, debe
|
||||||
|
funcionar igual (listado filtrado).
|
||||||
|
|
||||||
|
### 4. UI de resultados
|
||||||
|
- Reutiliza el template `search` (rejilla `fea-archive-card`). Añade el **autor** en cada
|
||||||
|
tarjeta (byline corto) y, arriba, el formulario del punto 2 + un «N resultados» + chips de
|
||||||
|
filtros activos. Mantén la estética del sitio (carmesí #8b1a2e).
|
||||||
|
|
||||||
|
### 5. Multiidioma
|
||||||
|
- Polylang filtra por idioma (no fuerces `lang`). Action del form a la raíz del idioma actual.
|
||||||
|
- Categorías: usa las traducidas vía Polylang cuando el idioma ≠ es. Autores: los mismos.
|
||||||
|
- Textos de la UI (labels, placeholder) por idioma (array es/en/fr/it/pt; al menos es/en).
|
||||||
|
|
||||||
|
## Verificación (en local, OBLIGATORIA antes de entregar)
|
||||||
|
Usa la suite Playwright (`tools/e2e`, scripts `shot_*.cjs`; ejecuta con
|
||||||
|
`NODE_PATH=tools/e2e/node_modules node tools/e2e/<script>.cjs`, apuntando a `http://localhost:8081`).
|
||||||
|
Comprueba y captura:
|
||||||
|
1. FULLTEXT activo: una consulta (`/?s=oración`) ordena por relevancia y responde rápido.
|
||||||
|
2. Filtro **autor**: `/?s=&fea_author=<ID>` devuelve solo de ese autor.
|
||||||
|
3. Filtro **tema**: `/?fea_cat=1650`.
|
||||||
|
4. Filtro **cita bíblica**: `/?fea_cita=Jn` devuelve posts con `_cita_evangelio` que empieza por «Jn».
|
||||||
|
5. Filtro **fecha**: rango acota por fechas.
|
||||||
|
6. **Combinado**: palabra + autor.
|
||||||
|
7. **Multiidioma**: en `/en/?s=love` la UI y resultados salen en inglés.
|
||||||
|
8. Formulario visible y usable en **desktop y móvil** (capturas).
|
||||||
|
|
||||||
|
## Entregable (reporta al terminar)
|
||||||
|
- Lista de ficheros creados/modificados (mu-plugins/ y scripts/).
|
||||||
|
- Capturas de la verificación (ruta).
|
||||||
|
- Resumen de qué funciona y limitaciones.
|
||||||
|
- **Checklist de despliegue a prod** (para que lo haga Rafa después): subir los mu-plugins a
|
||||||
|
`/web/wp-nuevo/wp-content/mu-plugins/`, aplicar el `ALTER TABLE ... ADD FULLTEXT` en la BD de
|
||||||
|
prod, recrear el template/página si aplica. NO lo ejecutes tú.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Plan de implementación — Buscador con Typesense (feadulta #8, fase 2)
|
||||||
|
|
||||||
|
> **Estado:** plan para ejecutar (pensado para que lo ejecute un agente Sonnet).
|
||||||
|
> **Fase 1 (MVP nativo) ya está en producción** — ver `feadulta-buscador-8` / issue #8:
|
||||||
|
> barra de búsqueda visible en móvil (`mu-plugins/fea-search.php`) + resultados en
|
||||||
|
> rejilla (`scripts/set_search_template.php`). Esta fase 2 sustituye el **motor** por
|
||||||
|
> Typesense manteniendo/мejorando esa UI, y añade **búsqueda por autor** (faceta).
|
||||||
|
>
|
||||||
|
> **DECISIÓN PENDIENTE (Rafa) — dónde corre Typesense en producción.** Todo lo demás
|
||||||
|
> se puede construir y validar en **local** sin esa decisión (Typesense en Docker local).
|
||||||
|
> Opciones de despliegue público (decidir luego): (a) PC + Cloudflare Tunnel
|
||||||
|
> `search.feadulta.com` (coste 0, atado al PC encendido, fallback al buscador nativo);
|
||||||
|
> (b) VPS pequeño dedicado ~4-6€/mes (fiable); (c) Typesense Cloud (gestionado).
|
||||||
|
> El hosting actual (cPanel compartido, sin Docker/systemd/root) **no sirve** para el motor.
|
||||||
|
|
||||||
|
## Decisiones ya tomadas
|
||||||
|
- **Integración:** Custom InstantSearch (indexador propio + UI InstantSearch.js), NO plugin.
|
||||||
|
- **Motor:** Typesense self-host en Docker.
|
||||||
|
- **Fallback:** si Typesense no responde, mantener el buscador nativo (`/?s=`) ya desplegado.
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
```
|
||||||
|
Navegador del usuario
|
||||||
|
└── InstantSearch.js (typesense-instantsearch-adapter) ── HTTPS ──► Typesense
|
||||||
|
(página /buscar en WP, search-only API key) (Docker)
|
||||||
|
▲
|
||||||
|
WP (wp-nuevo) ── indexador (wp-cli/PHP) admin API key ──────────────────────┘
|
||||||
|
└── hook publish_post / cron: reindexa incremental
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pasos (ejecutables por Sonnet)
|
||||||
|
|
||||||
|
### A. Typesense local de desarrollo (no depende de la decisión de infra)
|
||||||
|
1. Añadir servicio `typesense` a `docker-compose.yml` (imagen `typesense/typesense:27.x`),
|
||||||
|
volumen de datos, `--api-key` admin de desarrollo, puerto 8108 **solo en localhost**.
|
||||||
|
2. Levantar y verificar salud: `GET http://localhost:8108/health` → `{"ok":true}`.
|
||||||
|
|
||||||
|
### B. Schema de la colección `feadulta_posts`
|
||||||
|
Campos: `id`(post_id), `title`, `content` (texto plano, sin HTML), `excerpt`,
|
||||||
|
`author_id`(int32, facet), `author_name`(string, facet), `date`(int64, sort),
|
||||||
|
`url`, `lang`(string, facet: es/en/fr/it/pt), `categories`(string[], facet),
|
||||||
|
`thumbnail`(opcional). `default_sorting_field`: `date`.
|
||||||
|
|
||||||
|
### C. Indexador (`scripts/typesense_index.php`, wp-cli `wp eval-file`)
|
||||||
|
1. Recorre posts `publish` (`post`), excluye los template/feedback CPT.
|
||||||
|
2. Por cada post construye el documento (content = `wp_strip_all_tags`, author_name =
|
||||||
|
`display_name` del `post_author`, lang vía Polylang `pll_get_post_language`,
|
||||||
|
categories por slugs). Sube en **lotes** (import JSONL, action=upsert).
|
||||||
|
3. Idempotente, reanudable, con `--since` para incremental. ~24.778 posts.
|
||||||
|
4. Reindexado en caliente: hook `publish_post`/`save_post` → upsert de ese documento;
|
||||||
|
`before_delete_post` → delete. (Encolar para no penalizar el guardado.)
|
||||||
|
|
||||||
|
### D. UI InstantSearch (sustituye/mejora la página de resultados)
|
||||||
|
1. Página `/buscar` (o el propio template `search`) con InstantSearch.js +
|
||||||
|
`typesense-instantsearch-adapter`, apuntando a la URL pública de Typesense y la
|
||||||
|
**search-only** key (nunca la admin) — ambas configurables (constantes en un
|
||||||
|
mu-plugin `fea-search-typesense.php`, vacías hasta decidir la infra → fallback nativo).
|
||||||
|
2. Widgets: searchbox, hits (tarjetas con la estética actual `fea-archive-card`),
|
||||||
|
**refinement por autor** (faceta `author_name`), por idioma y por categoría,
|
||||||
|
paginación. Resaltado de coincidencias.
|
||||||
|
3. Multiidioma: filtrar `lang` = idioma actual por defecto; textos de UI por idioma.
|
||||||
|
4. Mantener la barra móvil de fase 1 como entrada; en desktop, integrar con el buscador
|
||||||
|
del menú.
|
||||||
|
|
||||||
|
### E. Seguridad de claves
|
||||||
|
- **Admin API key**: solo en el servidor (indexador), nunca en el front.
|
||||||
|
- **Search-only key**: restringida a la colección, embebida en el front (es segura por diseño).
|
||||||
|
- CORS de Typesense limitado al dominio del sitio.
|
||||||
|
|
||||||
|
### F. Exposición pública (BLOQUEADO por la decisión de infra)
|
||||||
|
- Opción PC+Tunnel: `cloudflared` → `search.feadulta.com` → `localhost:8108`.
|
||||||
|
- Opción VPS: Typesense en el VPS + Caddy/HTTPS en `search.feadulta.com`.
|
||||||
|
- Opción Cloud: usar el endpoint y keys que da Typesense Cloud.
|
||||||
|
- En los tres casos, la UI solo necesita la **URL pública + search-only key**.
|
||||||
|
|
||||||
|
## Verificación
|
||||||
|
- Local: `/health` ok; `typesense_index.php` indexa N docs; la página `/buscar` devuelve
|
||||||
|
resultados relevantes y la faceta de autor filtra (probar «González Amaro», «Pagola»).
|
||||||
|
- Comparar relevancia vs buscador nativo en consultas reales del feedback
|
||||||
|
(«reflexiones», búsqueda por autor).
|
||||||
|
- Prod: tras exponer el motor, repetir desde un navegador (Cloudflare bloquea headless).
|
||||||
|
|
||||||
|
## Riesgos / notas
|
||||||
|
- **Disponibilidad** si corre en el PC: caídas → debe degradar al buscador nativo, no a error.
|
||||||
|
- **RAM**: Typesense mantiene el índice en memoria; 24.778 posts de texto entran de sobra
|
||||||
|
en <1 GB, pero dimensionar el host en consecuencia.
|
||||||
|
- **Reindexado masivo inicial** puede tardar; hacerlo por lotes y fuera de hora punta.
|
||||||
|
- No exponer nunca la admin key; CORS restringido.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# API de subida de avatar (#175) — Plan de implementación
|
||||||
|
|
||||||
|
> **For Hermes:** implementar por pasos pequeños, con prueba de integración local antes de tocar el código de producción.
|
||||||
|
|
||||||
|
**Objetivo:** permitir que Mixbot/Inma asigne o reemplace de forma segura la foto de un autor mediante `POST /wp-json/fea/v1/subir-avatar`, sin depender del wp-admin ni de Rafa.
|
||||||
|
|
||||||
|
**Arquitectura:** un mu-plugin nuevo y autónomo en `mu-plugins/`, paralelo a `fea-crear-autor-api.php`. Reutiliza el mismo modelo de autenticación (Application Password autenticada + capacidad `edit_others_posts`). Recibe un `multipart/form-data` con `user_id` exacto e imagen en el campo `avatar`; valida, normaliza a un cuadrado de 512 px, crea el attachment de WordPress y actualiza únicamente el meta ACF `foto_perfil`.
|
||||||
|
|
||||||
|
**Decisiones deliberadas de contrato:**
|
||||||
|
- **Multipart**, no base64: evita codificación innecesaria y usa el manejo seguro nativo de uploads de WordPress.
|
||||||
|
- **`user_id` obligatorio**, no slug: evita ambigüedades al asignar una imagen a una persona.
|
||||||
|
- **JPEG, PNG y WebP; máximo 5 MB; mínimo 512×512 px; máximo 4096 px por lado y 16 megapíxeles.**
|
||||||
|
- Se conserva el attachment previo; la respuesta devuelve `previous_attachment_id` para rollback manual. No se borra ningún avatar anterior.
|
||||||
|
- La imagen se recorta centrada y se normaliza a **512×512 px** en el servidor.
|
||||||
|
|
||||||
|
**Alcance de seguridad:** el endpoint no permite elegir meta, ruta de filesystem, MIME arbitrario ni otro usuario distinto del `user_id` explícito. Requiere autenticación y permiso de editor o superior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Añadir prueba de integración local para el contrato vacío
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/integration/test_subir_avatar_api.sh`
|
||||||
|
|
||||||
|
**Step 1:** probar contra el WordPress Docker local que la ruta no está disponible antes de cargar el nuevo mu-plugin.
|
||||||
|
|
||||||
|
**Step 2:** el script debe cubrir, tras cargar el plugin: falta de autenticación (401), falta de `user_id` (400), usuario inexistente (404), falta de fichero (400) y una subida correcta a un usuario temporal.
|
||||||
|
|
||||||
|
**Step 3:** el caso correcto debe verificar respuesta, attachment creado, meta `foto_perfil` actualizado y que el attachment anterior no se elimina. Debe limpiar el usuario/attachment temporal al terminar.
|
||||||
|
|
||||||
|
### Task 2: Implementar el mu-plugin mínimo
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `mu-plugins/fea-subir-avatar-api.php`
|
||||||
|
|
||||||
|
**Step 1:** registrar `POST /fea/v1/subir-avatar` y reutilizar una callback de autorización equivalente a la de `crear-autor`.
|
||||||
|
|
||||||
|
**Step 2:** validar `user_id` y el fichero `avatar` antes de persistir nada.
|
||||||
|
|
||||||
|
**Step 3:** procesar la imagen con APIs nativas de WordPress, normalizarla a 512×512 y crear su attachment con metadata.
|
||||||
|
|
||||||
|
**Step 4:** actualizar exclusivamente `foto_perfil` y devolver ID del usuario, attachment nuevo, attachment anterior y URL.
|
||||||
|
|
||||||
|
### Task 3: Ejecutar integración local y revisión de seguridad
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify only if a test demuestra una necesidad real.
|
||||||
|
|
||||||
|
**Step 1:** copiar temporalmente el mu-plugin y el script de prueba a la instalación WordPress Docker local. No tocar producción.
|
||||||
|
|
||||||
|
**Step 2:** ejecutar la prueba y verificar que falla de forma esperada antes de implementar, y pasa después.
|
||||||
|
|
||||||
|
**Step 3:** ejecutar `php -l` sobre el mu-plugin y revisar `git diff --check` / `git diff`.
|
||||||
|
|
||||||
|
**Step 4:** dejar los cambios solo en la rama aislada `feat/subir-avatar-175`; no hacer push, PR ni despliegue sin aprobación explícita de Rafa.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Revisión rápida de issues mencionados por Inma/Rafa — 2026-07-15
|
||||||
|
|
||||||
|
## Alcance
|
||||||
|
Revisión en modo **diagnóstico + preparación**. **Sin cambios en producción**.
|
||||||
|
|
||||||
|
## Observación importante sobre numeración
|
||||||
|
En el repo actual de Gitea `rafa/feadulta` los issues llegan hoy hasta **#146**. Por tanto, las referencias del mensaje (`#166`, `#168`, `#170`, `#174`, `#175`) no existen en el tracker actual y probablemente pertenecen a una numeración anterior o a notas habladas.
|
||||||
|
|
||||||
|
Para no quedarnos bloqueados, revisé los issues **actuales y más cercanos al tema de autonomía / operación**:
|
||||||
|
- `#144` Cron servidor: auto-traducción (Haiku) + TTS (MiniMax)
|
||||||
|
- `#145` Habilitar acceso a la REST API para publicar cartas
|
||||||
|
- `#146` Propuesta: arquitectura multiagente con Ringer, Goose, OB1, Hermes y OpenClaw
|
||||||
|
- además `#126` y el histórico `#121` / `#125` por dependencia operativa
|
||||||
|
|
||||||
|
## Hallazgos verificados
|
||||||
|
|
||||||
|
### 1) Issue #126 — seguridad login post-cutover
|
||||||
|
Verificación server-side en prod (`/web`) hecha por SSH + `wp eval`:
|
||||||
|
- `llar_active=1`
|
||||||
|
- `mu_exists=1`
|
||||||
|
- mu-plugin presente: `fea-cloudflare-realip.php`
|
||||||
|
|
||||||
|
Conclusión:
|
||||||
|
- **La parte operativa del checklist parece ya presente en prod.**
|
||||||
|
- El issue probablemente necesita **actualización/cierre**, no trabajo técnico urgente.
|
||||||
|
|
||||||
|
### 2) Gap antiguo de repo sobre `fea-cloudflare-realip.php`
|
||||||
|
El comentario viejo de `#126` decía que el mu-plugin no estaba trackeado en git.
|
||||||
|
|
||||||
|
Estado actual en el repo local:
|
||||||
|
- Sí existe: `wordpress/wp-content/mu-plugins/fea-cloudflare-realip.php`
|
||||||
|
|
||||||
|
Conclusión:
|
||||||
|
- Ese comentario ya quedó **desactualizado**.
|
||||||
|
|
||||||
|
### 3) Issue #144 — base técnica para autonomía traducción/TTS
|
||||||
|
Comprobado en el repo: existen los bloques principales mencionados por el issue:
|
||||||
|
- `scripts/detect_untranslated.php`
|
||||||
|
- `scripts/translate_post.py`
|
||||||
|
- `scripts/fix_carta_joomla_links.php`
|
||||||
|
- `scripts/demote_old_cartasemana.php`
|
||||||
|
- `scripts/minimax_tts.py`
|
||||||
|
- `scripts/sync_audio_to_prod.py`
|
||||||
|
|
||||||
|
Además ejecuté el detector en el **WordPress local Docker** (no en prod):
|
||||||
|
|
||||||
|
#### Ejecución local
|
||||||
|
`docker exec wordpress-web php /tmp/detect_untranslated.php 0.12 draft`
|
||||||
|
- Resultado: **0 ofensores draft**
|
||||||
|
|
||||||
|
`docker exec wordpress-web php /tmp/detect_untranslated.php 0.12 any`
|
||||||
|
- Resultado: **12 ofensores sospechosos** sobre publicados/cualquier estado
|
||||||
|
- Resumen por idioma:
|
||||||
|
- `en: 4/1148`
|
||||||
|
- `fr: 3/1148`
|
||||||
|
- `it: 3/1147`
|
||||||
|
- `pt: 2/1147`
|
||||||
|
|
||||||
|
IDs señalados por el detector:
|
||||||
|
- `47978, 47981, 47980, 47979`
|
||||||
|
- `47756, 47153`
|
||||||
|
- `54304, 47285, 54307, 43278, 54306, 54305`
|
||||||
|
|
||||||
|
Notas útiles:
|
||||||
|
- El script **no corre bien desde host** porque exige `/var/www/html/wp-load.php`; hay que lanzarlo dentro del contenedor.
|
||||||
|
- Esto es buena pista para futura automatización en `#144`: ya hay piezas, pero conviene empaquetarlas en un wrapper reproducible y con contexto de ejecución claro.
|
||||||
|
|
||||||
|
### 4) Issue #145 — REST API para Inma
|
||||||
|
No hice verificación externa definitiva porque eso requiere:
|
||||||
|
- credencial real de aplicación, y
|
||||||
|
- prueba end-to-end frente a Cloudflare
|
||||||
|
|
||||||
|
Estado documental actual:
|
||||||
|
- la skill y la documentación operativa indican que la REST API **ya fue habilitada para Inma**, pero no he revalidado hoy ese punto desde fuera.
|
||||||
|
|
||||||
|
Conclusión:
|
||||||
|
- Antes del viaje a Madrid conviene hacer un **smoke test real** (`/wp-json/wp/v2/users/me`) desde fuera del server.
|
||||||
|
|
||||||
|
### 5) Issue #146 — autonomía / multiagente
|
||||||
|
Lo revisado aquí es principalmente discusión/arquitectura; no detecté una acción local obvia de código en este repo que mereciera tocar hoy sin alinear primero el objetivo.
|
||||||
|
|
||||||
|
## Recomendación práctica
|
||||||
|
1. **Aclarar la numeración** de `#166/#168/#170/#174/#175` para no revisar los issues equivocados.
|
||||||
|
2. Si los “nuevos de autonomía” eran realmente los del tracker actual, yo priorizaría así:
|
||||||
|
- `#145`: verificar de verdad que Inma puede publicar sin Rafa delante.
|
||||||
|
- `#144`: encapsular el flujo traducción/TTS en un wrapper/cron operativo.
|
||||||
|
- `#146`: dejarlo como diseño/roadmap, no como siguiente cambio técnico directo.
|
||||||
|
3. `#126` huele a **issue de limpieza/cierre** si nadie ve un fleco pendiente.
|
||||||
|
|
||||||
|
## Qué NO hice
|
||||||
|
- No apliqué cambios en producción.
|
||||||
|
- No toqué Joomla legacy.
|
||||||
|
- No abrí/cerré/edité issues en Gitea.
|
||||||
|
- No modifiqué código del repo en esta revisión.
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Segunda pasada — issues correctos en Gitea vivo (`gitea.feadulta.com`) — 2026-07-15
|
||||||
|
|
||||||
|
## Alcance
|
||||||
|
Revisión en modo **diagnóstico + preparación** sobre la instancia viva:
|
||||||
|
- Repo: `https://gitea.feadulta.com/rafa/feadulta`
|
||||||
|
- Issues revisados: `#166`, `#168`, `#170`, `#174`, `#175`
|
||||||
|
- **Sin cambios en producción**
|
||||||
|
- **Sin edición de issues**
|
||||||
|
|
||||||
|
## Corrección del desajuste anterior
|
||||||
|
La pasada previa consultó el Gitea local archivado (`localhost:3000`), que se queda en `#146`. La fuente correcta para el trabajo actual es **`gitea.feadulta.com`**, tal como ya reflejan:
|
||||||
|
- la skill `feadulta-editorial-workflows`
|
||||||
|
- `feadulta/webmaster/references/environment.md`
|
||||||
|
|
||||||
|
## Estado por issue
|
||||||
|
|
||||||
|
### `#166` — Alta de autores / `crear-autor`
|
||||||
|
**Estado funcional real:** resuelto e integrado.
|
||||||
|
|
||||||
|
Verificado en el issue vivo:
|
||||||
|
- Rafa comentó que decidió la **opción B** (endpoint acotado).
|
||||||
|
- Rafa comentó después: **"Desplegado en prod y verificado en vivo"**.
|
||||||
|
- Inma comentó el 14-jul que ya está **integrado en Mixbot**.
|
||||||
|
|
||||||
|
Verificado además en el repo local:
|
||||||
|
- existe `wordpress/wp-content/mu-plugins/fea-crear-autor-api.php`
|
||||||
|
- registra `POST /wp-json/fea/v1/crear-autor`
|
||||||
|
- fuerza rol fijo `author`
|
||||||
|
- es **idempotente** si el slug ya existe
|
||||||
|
|
||||||
|
Conclusión:
|
||||||
|
- **No hay trabajo técnico pendiente aquí**.
|
||||||
|
- Si queréis limpieza de tablero, este issue ya está para **cerrar** cuando Rafa quiera.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `#168` — Bots en Joomla viejo / Cloudflare
|
||||||
|
**Estado funcional real:** resuelto lado Cloudflare, aunque el issue sigue abierto.
|
||||||
|
|
||||||
|
Verificado en el issue vivo:
|
||||||
|
- el body ya documenta la parte técnica previa (cache Joomla activada y diagnóstico del fatal)
|
||||||
|
- Inma añadió comentario 14-jul: **"HECHO y verificado"** ajustando una regla existente de Cloudflare por el límite de 5 custom rules del plan free
|
||||||
|
|
||||||
|
Conclusión:
|
||||||
|
- Operativamente está **resuelto**.
|
||||||
|
- Si no queda ningún fleco de observabilidad, este issue también está para **cerrar**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `#170` — Crawlers sociales / preview Facebook
|
||||||
|
**Estado funcional real:** resuelto lado Cloudflare, aunque el issue sigue abierto.
|
||||||
|
|
||||||
|
Verificado en el issue vivo:
|
||||||
|
- Inma comentó 14-jul que ya existía una regla **"Permitir Facebook"** y que el crawler de Facebook ya pasa con **200**
|
||||||
|
|
||||||
|
Conclusión:
|
||||||
|
- Operativamente está **resuelto**.
|
||||||
|
- Igual que `#168`, parece issue de **cierre administrativo**, no técnico.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `#174` — Cierre autónomo de la carta (publicar + rotar)
|
||||||
|
**Estado funcional real:** abierto, esperando OK de Rafa. Aquí sí hay trabajo útil preparado, pero no conviene aplicar nada aún.
|
||||||
|
|
||||||
|
Lo importante que he verificado localmente:
|
||||||
|
|
||||||
|
#### 1) Ya existe lógica de rotación en scripts
|
||||||
|
En el repo local existe:
|
||||||
|
- `scripts/rotate_cartas.php`
|
||||||
|
- `scripts/demote_old_cartasemana.php`
|
||||||
|
|
||||||
|
`rotate_cartas.php` implementa esta cascada:
|
||||||
|
1. la que estaba en "semana pasada" → queda solo en "otras semanas"
|
||||||
|
2. la que estaba en "semana actual" → pasa a "semana pasada"
|
||||||
|
3. la nueva → pasa a "semana actual"
|
||||||
|
|
||||||
|
#### 2) Pero la implementación actual rota **todos los idiomas**, no solo ES
|
||||||
|
Esto es importante porque en `#174` justo se pregunta si:
|
||||||
|
- **solo se rota ES al cerrar la carta**, y
|
||||||
|
- las traducciones **no se rotan** hasta que estén publicadas
|
||||||
|
|
||||||
|
El script actual **no sigue ese criterio**: deriva y actúa sobre `es/en/fr/it/pt`.
|
||||||
|
|
||||||
|
#### 3) Dry-run local ejecutado
|
||||||
|
He ejecutado `rotate_cartas.php` en el WordPress local Docker en modo dry-run.
|
||||||
|
|
||||||
|
Resultado:
|
||||||
|
- el script **funciona** como dry-run
|
||||||
|
- pero el espejo local **no está alineado con el caso exacto de la carta 735 en ES** (el mirror local va por otro estado / otra numeración viva en esa parte)
|
||||||
|
- por tanto, **sirve para validar la mecánica del script**, pero **no para afirmar que ya resuelva `#174` tal cual**
|
||||||
|
|
||||||
|
Conclusión técnica:
|
||||||
|
- `#174` **no es greenfield**: ya hay base.
|
||||||
|
- Pero **hay que adaptar** la lógica si la decisión final es "rotar solo ES y dejar derivados/traducciones para después".
|
||||||
|
- No he preparado parche porque todavía falta el **OK funcional de Rafa**, y sin eso sería fácil codificar la lógica equivocada.
|
||||||
|
|
||||||
|
Recomendación:
|
||||||
|
- cuando Rafa confirme la regla exacta, el siguiente paso bueno es preparar un **wrapper ES-only + dry-run** en local, en vez de reaprovechar `rotate_cartas.php` tal cual.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `#175` — Endpoint `subir-avatar`
|
||||||
|
**Estado funcional real:** abierto, esperando OK de Rafa. También tiene muy buena base técnica ya hecha.
|
||||||
|
|
||||||
|
Lo verificado localmente:
|
||||||
|
|
||||||
|
#### 1) El frontend ya usa `foto_perfil`
|
||||||
|
En `wordpress/wp-content/mu-plugins/fea-homepage.php`:
|
||||||
|
- el avatar del autor usa el meta de usuario **`foto_perfil`**
|
||||||
|
- ese meta guarda un attachment ID
|
||||||
|
|
||||||
|
#### 2) Ya existen las piezas de procesamiento
|
||||||
|
En el repo hay:
|
||||||
|
- `scripts/face_crop_avatar.py`
|
||||||
|
- `scripts/regen_avatars.php`
|
||||||
|
|
||||||
|
O sea: el flujo de recorte / regeneración **ya existe**; faltaría encapsularlo detrás de un endpoint seguro.
|
||||||
|
|
||||||
|
#### 3) El caso concreto mencionado en el issue existe y está sin foto en local
|
||||||
|
Verificado en el WP local:
|
||||||
|
- usuario `1112` existe
|
||||||
|
- login: `silvia-mtnz`
|
||||||
|
- display: `Silvia Martínez Cano`
|
||||||
|
- `foto_perfil` está vacío
|
||||||
|
|
||||||
|
Conclusión técnica:
|
||||||
|
- `#175` tampoco es greenfield.
|
||||||
|
- El endpoint encaja bien como hermano de `fea/v1/crear-autor` (`#166`).
|
||||||
|
- La decisión pendiente no es "si se puede", sino **qué contrato exacto quiere Rafa**:
|
||||||
|
- `user_id` vs `slug`
|
||||||
|
- multipart vs base64
|
||||||
|
- si el endpoint recorta o exige imagen ya preparada
|
||||||
|
|
||||||
|
Recomendación:
|
||||||
|
- en cuanto Rafa dé el OK de contrato, el trabajo lógico es clonar el patrón de `fea-crear-autor-api.php` y dejar un endpoint **mínimo, acotado e idempotente**.
|
||||||
|
|
||||||
|
## Resumen ejecutivo
|
||||||
|
- `#166`: **hecho** ✅
|
||||||
|
- `#168`: **hecho** ✅ pero sigue abierto
|
||||||
|
- `#170`: **hecho** ✅ pero sigue abierto
|
||||||
|
- `#174`: **esperando OK funcional de Rafa**; hay base técnica, pero la actual rota todos los idiomas
|
||||||
|
- `#175`: **esperando OK funcional de Rafa**; hay base técnica muy clara y el caso Silvia está identificado
|
||||||
|
|
||||||
|
## Qué hice en esta pasada
|
||||||
|
- Reapunté la revisión a `gitea.feadulta.com`
|
||||||
|
- Leí los issues correctos por API pública
|
||||||
|
- Verifiqué localmente la existencia del endpoint `crear-autor`
|
||||||
|
- Ejecuté un **dry-run** de la lógica de rotación existente
|
||||||
|
- Verifiqué en local el caso `Silvia Martínez Cano / 1112 / foto_perfil vacío`
|
||||||
|
|
||||||
|
## Qué NO hice
|
||||||
|
- No toqué producción
|
||||||
|
- No cerré issues
|
||||||
|
- No comenté en Gitea
|
||||||
|
- No modifiqué código del repo
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta — Accesibilidad (tipografía + contraste)
|
||||||
|
* Description: Tipografía Atkinson Hyperlegible global (self-hosted) + toggle
|
||||||
|
* claro/oscuro persistente, pensado para el público mayor de
|
||||||
|
* Fe Adulta (issue rafa/feadulta#196, evidencia en comment #609).
|
||||||
|
* Version: 1.0
|
||||||
|
*
|
||||||
|
* Alcance decidido en el issue (2026-08-08): SOLO dos cosas, sin más superficie
|
||||||
|
* de UI que la necesaria —
|
||||||
|
* 1. Atkinson Hyperlegible sustituye la tipografía del cuerpo, sin selector.
|
||||||
|
* 2. Un único toggle claro/oscuro persistente (claro por defecto).
|
||||||
|
* Nada de panel de accesibilidad, A+/Aa ni cambio de tamaño de letra.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
if (!defined('FEA_A11Y_ACCENT_DARK')) {
|
||||||
|
// Variante clara del carmesí de marca (#8b1a2e) para que enlaces/foco
|
||||||
|
// cumplan contraste AA sobre fondo casi negro (el carmesí original da
|
||||||
|
// ~1.8:1 sobre #0d0d0d, muy por debajo del 4.5:1 que exige texto).
|
||||||
|
define('FEA_A11Y_ACCENT_DARK', '#e8899e');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 1) Anti-flash: fija el tema ANTES de que el navegador pinte nada ─────
|
||||||
|
* Se engancha con prioridad muy baja para salir de los primeros bytes de
|
||||||
|
* <head>, igual que el resto del sitio guarda preferencias en localStorage
|
||||||
|
* (ver fea-beta-feedback.php). Sin esto habría un parpadeo claro→oscuro. */
|
||||||
|
add_action('wp_head', function () {
|
||||||
|
if (is_admin()) return;
|
||||||
|
?>
|
||||||
|
<script>(function(){try{
|
||||||
|
var t = localStorage.getItem('fea_theme');
|
||||||
|
if (t === 'dark') document.documentElement.setAttribute('data-fea-theme', 'dark');
|
||||||
|
}catch(e){}})();</script>
|
||||||
|
<?php
|
||||||
|
}, 1);
|
||||||
|
|
||||||
|
/* ── 2) Fuente self-hosted + estilos de tema oscuro ───────────────────── */
|
||||||
|
add_action('wp_head', function () {
|
||||||
|
if (is_admin()) return;
|
||||||
|
$base = plugins_url('fea-accesibilidad/fonts', __FILE__);
|
||||||
|
$accent_dark = FEA_A11Y_ACCENT_DARK;
|
||||||
|
?>
|
||||||
|
<style id="fea-a11y">
|
||||||
|
/* ── Atkinson Hyperlegible (Braille Institute, OFL), subset latin ──── */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Atkinson Hyperlegible';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('<?php echo esc_url($base); ?>/atkinson-hyperlegible-regular.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Atkinson Hyperlegible';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 501 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('<?php echo esc_url($base); ?>/atkinson-hyperlegible-bold.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sustitución global del cuerpo de texto (no opcional, issue #196).
|
||||||
|
Especificidad baja a propósito: el menú (fea-ui.php, issue #95) usa
|
||||||
|
una sans de UI más específica y debe seguir ganando. */
|
||||||
|
body {
|
||||||
|
font-family: 'Atkinson Hyperlegible', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6,
|
||||||
|
.fea-hero-title,
|
||||||
|
.fea-section-title {
|
||||||
|
font-family: 'Atkinson Hyperlegible', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
input, textarea, select, button {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toggle claro/oscuro: botón inyectado junto al selector de idioma ── */
|
||||||
|
#fea-theme-btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 30px; height: 30px; border-radius: 50%;
|
||||||
|
border: 1px solid rgba(0,0,0,0.25); background: none; color: inherit;
|
||||||
|
cursor: pointer; font-size: 0.95rem; line-height: 1; padding: 0;
|
||||||
|
}
|
||||||
|
#fea-theme-btn:hover { background: rgba(0,0,0,0.06); }
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════════
|
||||||
|
MODO OSCURO — activado por [data-fea-theme="dark"] en <html>
|
||||||
|
══════════════════════════════════════════════════════════════════ */
|
||||||
|
html[data-fea-theme="dark"] {
|
||||||
|
/* Deja que el navegador oscurezca controles nativos gratis:
|
||||||
|
audio/video, scrollbars, checkboxes, selects sin estilo propio. */
|
||||||
|
color-scheme: dark;
|
||||||
|
--wp--preset--color--base: #0d0d0d;
|
||||||
|
--wp--preset--color--contrast: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] body {
|
||||||
|
background-color: #0d0d0d !important;
|
||||||
|
color: #ececec !important;
|
||||||
|
}
|
||||||
|
/* En artículos, fea-ui.php fija --wp--preset--color--base en el body
|
||||||
|
para el modo claro. El contenedor desplegable del menú hereda esa
|
||||||
|
variable, así que hay que restaurarla aquí en oscuro. */
|
||||||
|
html[data-fea-theme="dark"] body.single-post {
|
||||||
|
--wp--preset--color--base: #0d0d0d;
|
||||||
|
--wp--preset--color--contrast: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] a,
|
||||||
|
html[data-fea-theme="dark"] a:visited { color: <?php echo esc_html($accent_dark); ?>; }
|
||||||
|
html[data-fea-theme="dark"] a:focus-visible,
|
||||||
|
html[data-fea-theme="dark"] button:focus-visible {
|
||||||
|
outline-color: <?php echo esc_html($accent_dark); ?> !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cabecera/pie y bloques de grupo sin fondo explícito heredan el body;
|
||||||
|
solo hay que corregir los pocos sitios con color fijo en claro. */
|
||||||
|
html[data-fea-theme="dark"] input,
|
||||||
|
html[data-fea-theme="dark"] textarea,
|
||||||
|
html[data-fea-theme="dark"] select {
|
||||||
|
background-color: #1c1c1c;
|
||||||
|
color: #ececec;
|
||||||
|
border-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search block uses more-specific own color rules. */
|
||||||
|
html[data-fea-theme="dark"] .wp-block-search__inside-wrapper,
|
||||||
|
html[data-fea-theme="dark"] .wp-block-search__input {
|
||||||
|
background-color: #1c1c1c;
|
||||||
|
color: #ececec;
|
||||||
|
border-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .wp-block-search__button {
|
||||||
|
background-color: #ececec;
|
||||||
|
color: #0d0d0d;
|
||||||
|
border-color: #ececec;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-link-wrap { background: #161311; border-bottom-color: #2a2320; }
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-link { color: <?php echo esc_html($accent_dark); ?>; }
|
||||||
|
|
||||||
|
/* Crop the white edge embedded in slide images in dark mode only. */
|
||||||
|
html[data-fea-theme="dark"] .fea-hero-slider .n2-ss-slide-background-image {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-hero-slider .n2-ss-slide-background-image img {
|
||||||
|
transform: scale(1.015);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portada — franja hero (fea-homepage.php) */
|
||||||
|
html[data-fea-theme="dark"] .fea-hero-band {
|
||||||
|
background: linear-gradient(180deg, #1a1512, #0d0d0d);
|
||||||
|
border-bottom-color: #2a2320;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-hero-title,
|
||||||
|
html[data-fea-theme="dark"] .fea-section-title {
|
||||||
|
color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-section-label,
|
||||||
|
html[data-fea-theme="dark"] .fea-section-more,
|
||||||
|
html[data-fea-theme="dark"] .fea-card-author {
|
||||||
|
color: <?php echo esc_html($accent_dark); ?>;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-hero-meta { color: #b8afa8; }
|
||||||
|
|
||||||
|
/* Tarjetas de autor/artículo (fea-homepage.php) */
|
||||||
|
html[data-fea-theme="dark"] .fea-card {
|
||||||
|
background: #161311;
|
||||||
|
border-color: #2a2320;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-card-title a { color: #ececec; }
|
||||||
|
|
||||||
|
/* Archivos (categoría, búsqueda y autor) — fea-homepage.php fija aquí
|
||||||
|
fondos y texto claros que no heredan del tema. */
|
||||||
|
html[data-fea-theme="dark"] .wp-block-query-title {
|
||||||
|
color: #ececec !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-archive-card {
|
||||||
|
background: #161311;
|
||||||
|
border-color: #2a2320;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-archive-title a {
|
||||||
|
color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-archive-excerpt {
|
||||||
|
color: #b8afa8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comentarios al evangelio: el panel usa colores editoriales claros fijos. */
|
||||||
|
html[data-fea-theme="dark"] .fea-com-evangelio {
|
||||||
|
background: #1a1711;
|
||||||
|
border-left-color: #c9973e;
|
||||||
|
color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-com-evangelio-details summary {
|
||||||
|
color: #f0c674;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Elementos editoriales presentes en artículos y resultados. */
|
||||||
|
html[data-fea-theme="dark"] .fea-audio {
|
||||||
|
background: #161311;
|
||||||
|
border-color: #3a302b;
|
||||||
|
border-left-color: <?php echo esc_html($accent_dark); ?>;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-byline-name { color: #ececec; }
|
||||||
|
html[data-fea-theme="dark"] .fea-byline-cat,
|
||||||
|
html[data-fea-theme="dark"] .fea-eed-link .fecha { color: #b8afa8; }
|
||||||
|
html[data-fea-theme="dark"] .fea-share-rule { border-top-color: #3a302b; }
|
||||||
|
html[data-fea-theme="dark"] .fea-share-row { color: #d5cbc3; }
|
||||||
|
html[data-fea-theme="dark"] .fea-share-label,
|
||||||
|
html[data-fea-theme="dark"] .fea-share-sep { color: #b8afa8; }
|
||||||
|
|
||||||
|
/* Formulario y filtros de búsqueda avanzada. */
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-details {
|
||||||
|
background: #161311; border-color: #3a302b;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-summary {
|
||||||
|
background: #24181c; border-bottom-color: #3a302b;
|
||||||
|
color: <?php echo esc_html($accent_dark); ?>;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-label,
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-chips-label,
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-meta,
|
||||||
|
html[data-fea-theme="dark"] .fea-archive-card .fea-adv-byline { color: #b8afa8; }
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-chip {
|
||||||
|
background: #2a1b20; border-color: #56343d; color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-input,
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-select {
|
||||||
|
background: #1c1c1c; border-color: #3a3a3a; color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-count { color: #b8afa8; }
|
||||||
|
|
||||||
|
/* Recopilatorios y galería multimedia. */
|
||||||
|
html[data-fea-theme="dark"] .fea-recop-year { border-bottom-color: #3a302b; }
|
||||||
|
html[data-fea-theme="dark"] .fea-recop-list li { border-bottom-color: #2a2320; }
|
||||||
|
html[data-fea-theme="dark"] .fea-recop-list a,
|
||||||
|
html[data-fea-theme="dark"] .fea-mm-title { color: #ececec; }
|
||||||
|
html[data-fea-theme="dark"] .fea-recop-date,
|
||||||
|
html[data-fea-theme="dark"] .fea-mm-date,
|
||||||
|
html[data-fea-theme="dark"] .fea-mm-excerpt { color: #b8afa8; }
|
||||||
|
html[data-fea-theme="dark"] .fea-mm-card { background: #161311; border-color: #2a2320; }
|
||||||
|
html[data-fea-theme="dark"] .fea-mm-thumb { background: #241f1c; }
|
||||||
|
|
||||||
|
/* Galerías de pensamientos y navegación del tema base. */
|
||||||
|
html[data-fea-theme="dark"] .fea-gallery-item {
|
||||||
|
background: #161311; border-color: #3a302b;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-lightbox-frame img { background: #161311; }
|
||||||
|
html[data-fea-theme="dark"] .fea-lightbox button { background: #2a2320; color: #ececec; }
|
||||||
|
html[data-fea-theme="dark"] .wp-block-navigation .wp-block-navigation-item__content {
|
||||||
|
color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .wp-block-navigation__submenu-container {
|
||||||
|
background: #161311; border-color: #3a302b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Página de apoyo: plantilla propia con colores claros explícitos. */
|
||||||
|
html[data-fea-theme="dark"] .fea-support-page {
|
||||||
|
--fea-support-ink: #ececec;
|
||||||
|
--fea-support-warm: #161311;
|
||||||
|
--fea-support-line: #3a302b;
|
||||||
|
--fea-support-card: #161311;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-support-hero,
|
||||||
|
html[data-fea-theme="dark"] .fea-support-banner {
|
||||||
|
background: #1a1711 !important;
|
||||||
|
border-color: #3a302b !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-support-card,
|
||||||
|
html[data-fea-theme="dark"] .fea-support-button.is-secondary {
|
||||||
|
background: #161311 !important;
|
||||||
|
border-color: #3a302b !important;
|
||||||
|
color: #ececec !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-support-card__title,
|
||||||
|
html[data-fea-theme="dark"] .fea-support-progress__numbers span,
|
||||||
|
html[data-fea-theme="dark"] .fea-support-progress__note,
|
||||||
|
html[data-fea-theme="dark"] .fea-support-sidebar__small { color: #b8afa8; }
|
||||||
|
html[data-fea-theme="dark"] .fea-support-progress__track { background: #3a302b; }
|
||||||
|
|
||||||
|
/* Colaboradores: stylesheet propio del tema con tarjetas claras y !important. */
|
||||||
|
html[data-fea-theme="dark"] .entry-content .wrapper {
|
||||||
|
background: #161311 !important;
|
||||||
|
border-color: #3a302b !important;
|
||||||
|
color: #ececec !important;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,.45) !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .entry-content .wrapper strong {
|
||||||
|
color: #ececec !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .entry-content .wrapper a,
|
||||||
|
html[data-fea-theme="dark"] .entry-content .wrapper a:visited {
|
||||||
|
color: #ececec !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .entry-content .wrapper a:hover {
|
||||||
|
color: <?php echo esc_html($accent_dark); ?> !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selector de idioma (fea-homepage.php) */
|
||||||
|
html[data-fea-theme="dark"] #fea-lang-btn { border-color: rgba(255,255,255,0.3); }
|
||||||
|
html[data-fea-theme="dark"] #fea-lang-btn:hover,
|
||||||
|
html[data-fea-theme="dark"] #fea-theme-btn:hover { background: rgba(255,255,255,0.08); }
|
||||||
|
html[data-fea-theme="dark"] #fea-theme-btn { border-color: rgba(255,255,255,0.3); }
|
||||||
|
html[data-fea-theme="dark"] #fea-lang-dropdown {
|
||||||
|
background: #161311; border-color: #3a3a3a;
|
||||||
|
box-shadow: 0 6px 16px rgba(0,0,0,.5);
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] #fea-lang-dropdown a { color: #ececec; }
|
||||||
|
html[data-fea-theme="dark"] #fea-lang-dropdown a:hover { background: #241f1c; }
|
||||||
|
html[data-fea-theme="dark"] #fea-lang-dropdown a[aria-current] { background: #2a2320; color: #fff; }
|
||||||
|
|
||||||
|
/* Buscador móvil (fea-search.php) */
|
||||||
|
html[data-fea-theme="dark"] .fea-search-bar { background: #0d0d0d; border-bottom-color: #2a2320; }
|
||||||
|
html[data-fea-theme="dark"] .fea-search { background: #1c1c1c; border-color: #3a3a3a; }
|
||||||
|
html[data-fea-theme="dark"] .fea-search input[type=search] { color: #ececec; }
|
||||||
|
|
||||||
|
/* Reproductor de audio (fea-audio-player.php) — el <audio> nativo ya
|
||||||
|
cambia solo vía color-scheme; solo hace falta la etiqueta de texto. */
|
||||||
|
html[data-fea-theme="dark"] .fea-audio-label { color: #b8afa8; }
|
||||||
|
|
||||||
|
/* Barra y tarjeta de Beta Feedback (fea-beta-feedback.php) */
|
||||||
|
html[data-fea-theme="dark"] #fea-beta-bar {
|
||||||
|
background: #161311; border-top-color: #2a2320; color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] #fea-beta-bar .fea-beta-dismiss { color: #b8afa8; }
|
||||||
|
html[data-fea-theme="dark"] #fea-fb .fea-fb-card {
|
||||||
|
background: #161311; border-color: #3a3a3a; color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] #fea-fb button.fea-fb-vote {
|
||||||
|
background: #1c1c1c; border-color: #3a3a3a; color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] #fea-fb button.fea-fb-vote.sel {
|
||||||
|
border-color: <?php echo esc_html($accent_dark); ?>; background: #2a1418;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] #fea-fb textarea { background: #1c1c1c; border-color: #3a3a3a; color: #ececec; }
|
||||||
|
|
||||||
|
/* Barra de consentimiento de cookies (fea-cookie-consent.php) */
|
||||||
|
html[data-fea-theme="dark"] #fea-cc {
|
||||||
|
background: #161311 !important; color: #ececec !important; border-top-color: #2a2320 !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] #fea-cc a { color: #b8afa8 !important; }
|
||||||
|
html[data-fea-theme="dark"] #fea-cc .fea-cc-reject { color: #ececec !important; border-color: #3a3a3a !important; }
|
||||||
|
</style>
|
||||||
|
<?php
|
||||||
|
}, 30);
|
||||||
|
|
||||||
|
/* ── 3) Botón de toggle, inyectado en el header junto al selector de idioma ──
|
||||||
|
* Mismo patrón que el selector de idioma (fea-homepage.php): se crea por JS
|
||||||
|
* y se inserta en el nav del header, para no tocar el template FSE. */
|
||||||
|
add_action('wp_footer', function () {
|
||||||
|
if (is_admin()) return;
|
||||||
|
?>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
// El footer puede renderizarse más de una vez en algunos templates FSE.
|
||||||
|
// Evitamos crear un segundo toggle en ese caso.
|
||||||
|
if (document.getElementById('fea-theme-btn')) return;
|
||||||
|
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.id = 'fea-theme-btn';
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.setAttribute('aria-pressed', document.documentElement.getAttribute('data-fea-theme') === 'dark' ? 'true' : 'false');
|
||||||
|
btn.setAttribute('aria-label', 'Cambiar a modo oscuro');
|
||||||
|
btn.textContent = document.documentElement.getAttribute('data-fea-theme') === 'dark' ? '☀️' : '🌙';
|
||||||
|
|
||||||
|
function setLabel(isDark) {
|
||||||
|
btn.setAttribute('aria-pressed', isDark ? 'true' : 'false');
|
||||||
|
btn.setAttribute('aria-label', isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro');
|
||||||
|
btn.textContent = isDark ? '☀️' : '🌙';
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var isDark = document.documentElement.getAttribute('data-fea-theme') === 'dark';
|
||||||
|
if (isDark) {
|
||||||
|
document.documentElement.removeAttribute('data-fea-theme');
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-fea-theme', 'dark');
|
||||||
|
}
|
||||||
|
setLabel(!isDark);
|
||||||
|
try { localStorage.setItem('fea_theme', !isDark ? 'dark' : 'light'); } catch (e) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
function inject() {
|
||||||
|
var header = document.querySelector('header.wp-block-template-part') || document.querySelector('header');
|
||||||
|
if (!header) return;
|
||||||
|
var navGroup = header.querySelector('.wp-block-navigation__container') || header.querySelector('nav');
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.className = 'wp-block-navigation-item';
|
||||||
|
li.style.cssText = 'display:flex;align-items:center;margin-left:0.5rem;';
|
||||||
|
li.appendChild(btn);
|
||||||
|
if (!navGroup) {
|
||||||
|
header.style.position = 'relative';
|
||||||
|
li.style.cssText = 'position:absolute;top:50%;right:1.5rem;transform:translateY(-50%);';
|
||||||
|
header.appendChild(li);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Insertar después del selector de idioma si ya se inyectó, si no, al final del nav.
|
||||||
|
var langSwitcher = navGroup.querySelector('#fea-lang-switcher');
|
||||||
|
if (langSwitcher) {
|
||||||
|
var anchor = langSwitcher;
|
||||||
|
while (anchor.parentNode && anchor.parentNode !== navGroup) anchor = anchor.parentNode;
|
||||||
|
anchor.parentNode.insertBefore(li, anchor.nextSibling);
|
||||||
|
} else {
|
||||||
|
navGroup.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', inject);
|
||||||
|
} else {
|
||||||
|
inject();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
}, 21);
|
||||||
Binary file not shown.
Binary file not shown.
@@ -9,14 +9,17 @@ if (!defined('ABSPATH')) exit;
|
|||||||
|
|
||||||
/** Devuelve el HTML del reproductor para el post actual, o '' si no hay audio. */
|
/** Devuelve el HTML del reproductor para el post actual, o '' si no hay audio. */
|
||||||
function fea_audio_player_html(): string {
|
function fea_audio_player_html(): string {
|
||||||
$url = get_post_meta(get_the_ID(), 'fea_audio_url', true);
|
$id = get_the_ID();
|
||||||
|
$url = get_post_meta($id, 'fea_audio_url', true);
|
||||||
if (!$url) return '';
|
if (!$url) return '';
|
||||||
|
$voice = get_post_meta($id, 'fea_audio_voice', true) ?: 'NicoFeadulta2026';
|
||||||
return '<div class="fea-audio">'
|
return '<div class="fea-audio">'
|
||||||
. '<span class="fea-audio-label">'
|
. '<span class="fea-audio-label">'
|
||||||
. '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">'
|
. '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">'
|
||||||
. '<path fill="currentColor" d="M3 10v4h4l5 5V5L7 10H3zm13.5 2a4.5 4.5 0 0 0-2.5-4.03v8.06A4.5 4.5 0 0 0 16.5 12zM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54z"/>'
|
. '<path fill="currentColor" d="M3 10v4h4l5 5V5L7 10H3zm13.5 2a4.5 4.5 0 0 0-2.5-4.03v8.06A4.5 4.5 0 0 0 16.5 12zM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54z"/>'
|
||||||
. '</svg> Escucha</span>'
|
. '</svg> Escucha</span>'
|
||||||
. '<audio controls preload="none" src="' . esc_url($url) . '"></audio>'
|
. '<audio controls preload="none" src="' . esc_url($url) . '"'
|
||||||
|
. ' data-fea-audio-track data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '"></audio>'
|
||||||
. '</div>';
|
. '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,3 +63,27 @@ add_action('wp_head', function () {
|
|||||||
</style>
|
</style>
|
||||||
<?php
|
<?php
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Evento GA4 audio_play (issue tracking uso TTS). Un único evento por <audio>
|
||||||
|
// y carga de página, disparado en el primer 'play' (no en cada resume tras
|
||||||
|
// pausa/seek). gtag ya está definido por fea-analytics.php en wp_head prio 1,
|
||||||
|
// así que este script (footer) siempre lo encuentra disponible.
|
||||||
|
add_action('wp_footer', function () {
|
||||||
|
if (!is_singular('post')) return;
|
||||||
|
if (!get_post_meta(get_queried_object_id(), 'fea_audio_url', true)) return;
|
||||||
|
?>
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll('audio[data-fea-audio-track]').forEach(function (audio) {
|
||||||
|
var fired = false;
|
||||||
|
audio.addEventListener('play', function () {
|
||||||
|
if (fired || typeof gtag !== 'function') return;
|
||||||
|
fired = true;
|
||||||
|
gtag('event', 'audio_play', {
|
||||||
|
post_id: audio.dataset.postId,
|
||||||
|
voice: audio.dataset.voice
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
});
|
||||||
|
|||||||
@@ -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).
|
||||||
$host = wp_parse_url($url, PHP_URL_HOST);
|
//
|
||||||
$home_host = wp_parse_url(home_url(), PHP_URL_HOST);
|
// Durante el cutover de dominio (issue #158) el `home_url()` de WP sigue en
|
||||||
if ($host && $home_host && strcasecmp($host, $home_host) !== 0) {
|
// wp-nuevo.feadulta.com pero el contenido ya enlaza a www.feadulta.com. Para
|
||||||
|
// no romper la resolución mientras el cutover no esté cerrado, se acepta
|
||||||
|
// también una lista fija de hosts propios del sitio, no solo home_url().
|
||||||
|
// TODO: cuando #158 quede resuelto y home_url() sea www.feadulta.com, esta
|
||||||
|
// lista se puede simplificar a solo home_url().
|
||||||
|
$own_hosts = array_filter(array_unique([
|
||||||
|
strtolower((string) wp_parse_url(home_url(), PHP_URL_HOST)),
|
||||||
|
'www.feadulta.com',
|
||||||
|
'feadulta.com',
|
||||||
|
'wp-nuevo.feadulta.com',
|
||||||
|
]));
|
||||||
|
$host = wp_parse_url($url, PHP_URL_HOST);
|
||||||
|
if ($host && !in_array(strtolower($host), $own_hosts, true)) {
|
||||||
return null; // host externo → no es un artículo nuestro
|
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,109 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta — API crear-autor
|
||||||
|
* Description: Endpoint REST acotado para que Mixbot (Inma) dé de alta autores
|
||||||
|
* nuevos sin depender de que Rafa esté disponible, sin necesitar
|
||||||
|
* el permiso sensible create_users de WP. El rol siempre es
|
||||||
|
* 'author' (fijo en el código, no lo decide quien llama).
|
||||||
|
* Idempotente: si el slug ya existe, devuelve el usuario existente.
|
||||||
|
* Version: 1.0
|
||||||
|
*
|
||||||
|
* Ver issue gitea.feadulta.com/rafa/feadulta#166.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
add_action('rest_api_init', function () {
|
||||||
|
register_rest_route('fea/v1', '/crear-autor', [
|
||||||
|
'methods' => WP_REST_Server::CREATABLE,
|
||||||
|
'callback' => 'fea_crear_autor_handle',
|
||||||
|
'permission_callback' => 'fea_crear_autor_can_call',
|
||||||
|
'args' => [
|
||||||
|
'nombre' => ['required' => true],
|
||||||
|
'slug' => ['required' => false],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Editor o superior (mismo nivel que ya usan Inma/Mixbot para editar posts). */
|
||||||
|
function fea_crear_autor_can_call(WP_REST_Request $request) {
|
||||||
|
if (!is_user_logged_in()) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_crear_autor_not_authenticated',
|
||||||
|
'Debes autenticarte para crear un autor.',
|
||||||
|
['status' => 401]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!current_user_can('edit_others_posts')) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_crear_autor_forbidden',
|
||||||
|
'No tienes permiso para crear autores.',
|
||||||
|
['status' => 403]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_crear_autor_handle(WP_REST_Request $request) {
|
||||||
|
$nombre = trim((string) $request->get_param('nombre'));
|
||||||
|
if ($nombre === '') {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_crear_autor_invalid',
|
||||||
|
'nombre es obligatorio.',
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug_input = trim((string) $request->get_param('slug'));
|
||||||
|
$login = sanitize_user(sanitize_title($slug_input !== '' ? $slug_input : $nombre), true);
|
||||||
|
if ($login === '') {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_crear_autor_invalid',
|
||||||
|
'No se pudo derivar un slug válido de nombre/slug.',
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotencia: si el login/slug ya existe, se devuelve tal cual (created=false).
|
||||||
|
$existing = get_user_by('login', $login);
|
||||||
|
if ($existing) {
|
||||||
|
return fea_crear_autor_response($existing, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = $login . '@feadulta.com';
|
||||||
|
$i = 2;
|
||||||
|
while (email_exists($email)) {
|
||||||
|
$email = $login . $i . '@feadulta.com';
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user_id = wp_insert_user([
|
||||||
|
'user_login' => $login,
|
||||||
|
'user_pass' => wp_generate_password(20),
|
||||||
|
'user_email' => $email,
|
||||||
|
'display_name' => $nombre,
|
||||||
|
'nickname' => $nombre,
|
||||||
|
'role' => 'author', // fijo: este endpoint nunca crea otro rol.
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (is_wp_error($user_id)) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_crear_autor_failed',
|
||||||
|
'No se pudo crear el usuario: ' . $user_id->get_error_message(),
|
||||||
|
['status' => 500]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fea_crear_autor_response(get_userdata($user_id), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_crear_autor_response(WP_User $user, bool $created) {
|
||||||
|
return rest_ensure_response([
|
||||||
|
'id' => $user->ID,
|
||||||
|
'login' => $user->user_login,
|
||||||
|
'slug' => $user->user_nicename,
|
||||||
|
'display_name' => $user->display_name,
|
||||||
|
'role' => 'author',
|
||||||
|
'created' => $created,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta — Evangelio en comentarios
|
||||||
|
* Description: Deriva la cita de la carta semanal y muestra la lectura en los comentarios al evangelio.
|
||||||
|
* Version: 1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
function fea_evangelio_category($es_id) {
|
||||||
|
return function_exists('fea_cat') ? (int) fea_cat($es_id) : (int) $es_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_evangelio_comment_categories() {
|
||||||
|
return array_map('fea_evangelio_category', [1647, 1646]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_evangelio_is_comment($post_id) {
|
||||||
|
return has_category(fea_evangelio_comment_categories(), $post_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lee el meta del comentario; las traducciones heredan el dato editorial del original ES. */
|
||||||
|
function fea_evangelio_comment_meta($post_id, $key) {
|
||||||
|
$value = get_post_meta($post_id, $key, true);
|
||||||
|
if ($value !== '' && $value !== null) return $value;
|
||||||
|
if (!function_exists('pll_get_post')) return $value;
|
||||||
|
|
||||||
|
$source_id = (int) pll_get_post((int) $post_id, 'es');
|
||||||
|
return $source_id && $source_id !== (int) $post_id
|
||||||
|
? get_post_meta($source_id, $key, true)
|
||||||
|
: $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_evangelio_reading_title($cita) {
|
||||||
|
$cita = trim(wp_strip_all_tags((string) $cita));
|
||||||
|
if (!preg_match('/^(Mt|Mc|Lc|Jn)\s+(.+)$/u', $cita, $m)) return '';
|
||||||
|
$books = ['Mt' => 'MATEO', 'Mc' => 'MARCOS', 'Lc' => 'LUCAS', 'Jn' => 'JUAN'];
|
||||||
|
return $books[$m[1]] . ' ' . preg_replace('/\s+/u', ' ', trim($m[2]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Localiza la lectura española, sin depender del slug (que puede llevar sufijos). */
|
||||||
|
function fea_evangelio_reading_id($cita) {
|
||||||
|
global $wpdb;
|
||||||
|
static $cache = [];
|
||||||
|
$title = fea_evangelio_reading_title($cita);
|
||||||
|
if ($title === '') return 0;
|
||||||
|
if (isset($cache[$title])) return $cache[$title];
|
||||||
|
// La coincidencia se hace siempre contra la lectura original en español; después
|
||||||
|
// Polylang la sustituye por la traducción del comentario cuando exista.
|
||||||
|
$cat = 1645;
|
||||||
|
$sql = $wpdb->prepare(
|
||||||
|
"SELECT p.ID FROM {$wpdb->posts} p
|
||||||
|
INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
|
||||||
|
INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
|
||||||
|
WHERE p.post_type = 'post' AND p.post_status = 'publish' AND p.post_title = %s
|
||||||
|
AND tt.taxonomy = 'category' AND tt.term_id = %d
|
||||||
|
ORDER BY p.post_date DESC LIMIT 1",
|
||||||
|
$title, $cat
|
||||||
|
);
|
||||||
|
return $cache[$title] = (int) $wpdb->get_var($sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Devuelve los evangelios enlazados en la sección pertinente de una carta. */
|
||||||
|
function fea_evangelio_from_carta($carta_id) {
|
||||||
|
if (!function_exists('fea_parse_carta_sections')) return [];
|
||||||
|
if (function_exists('pll_get_post')) $carta_id = (int) (pll_get_post((int) $carta_id, 'es') ?: $carta_id);
|
||||||
|
$links = fea_parse_carta_sections((int) $carta_id)['evangelio'] ?? [];
|
||||||
|
$result = [];
|
||||||
|
foreach ($links as $id) {
|
||||||
|
$post = get_post($id);
|
||||||
|
if (!$post) continue;
|
||||||
|
$cita = get_post_meta($id, '* Cita biblica', true);
|
||||||
|
if (!$cita && preg_match('/^(MATEO|MARCOS|LUCAS|JUAN)\s+(.+)$/u', trim($post->post_title), $m)) {
|
||||||
|
$abbr = ['MATEO' => 'Mt', 'MARCOS' => 'Mc', 'LUCAS' => 'Lc', 'JUAN' => 'Jn'][$m[1]];
|
||||||
|
$cita = $abbr . ' ' . $m[2];
|
||||||
|
}
|
||||||
|
if (fea_evangelio_reading_title($cita)) $result[(string) $id] = trim($cita);
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Busca una carta en las tres categorías históricas, dentro de cuatro días. */
|
||||||
|
function fea_evangelio_nearest_carta_id(WP_Post $post) {
|
||||||
|
$date = mysql2date('Y-m-d', $post->post_date, false);
|
||||||
|
$cartas = get_posts([
|
||||||
|
'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => 10,
|
||||||
|
'category__in' => array_map('fea_evangelio_category', [6, 21, 22]),
|
||||||
|
'date_query' => [[
|
||||||
|
'after' => date('Y-m-d', strtotime($date . ' -4 days')),
|
||||||
|
'before' => date('Y-m-d', strtotime($date . ' +4 days')), 'inclusive' => true,
|
||||||
|
]],
|
||||||
|
'orderby' => 'date', 'order' => 'DESC',
|
||||||
|
]);
|
||||||
|
if (!$cartas) return 0;
|
||||||
|
usort($cartas, function($a, $b) use ($post) {
|
||||||
|
return abs(strtotime($a->post_date) - strtotime($post->post_date)) <=> abs(strtotime($b->post_date) - strtotime($post->post_date));
|
||||||
|
});
|
||||||
|
return (int) $cartas[0]->ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escribe solo metadatos ausentes cuando la carta identifica un único evangelio. */
|
||||||
|
function fea_evangelio_capture_meta($post_id) {
|
||||||
|
$post = get_post($post_id);
|
||||||
|
if (!$post || $post->post_type !== 'post' || $post->post_status !== 'publish' || !fea_evangelio_is_comment($post_id)) return false;
|
||||||
|
if (get_post_meta($post_id, '_cita_evangelio', true)) return false;
|
||||||
|
$carta_id = (int) get_post_meta($post_id, '_carta_id', true);
|
||||||
|
if (!$carta_id) $carta_id = fea_evangelio_nearest_carta_id($post);
|
||||||
|
if (!$carta_id) return false;
|
||||||
|
$evangelios = fea_evangelio_from_carta($carta_id);
|
||||||
|
if (count($evangelios) !== 1) {
|
||||||
|
error_log(sprintf('Fe Adulta: comentario %d sin cita; carta %d tiene %d evangelios.', $post_id, $carta_id, count($evangelios)));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
update_post_meta($post_id, '_cita_evangelio', reset($evangelios));
|
||||||
|
if (!get_post_meta($post_id, '_carta_id', true)) update_post_meta($post_id, '_carta_id', $carta_id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_evangelio_summary_label($lang) {
|
||||||
|
$labels = [
|
||||||
|
'es' => 'Leer el evangelio', 'en' => 'Read the Gospel', 'fr' => 'Lire l’Évangile',
|
||||||
|
'it' => 'Leggi il Vangelo', 'pt' => 'Ler o Evangelho',
|
||||||
|
];
|
||||||
|
return $labels[$lang] ?? $labels['es'];
|
||||||
|
}
|
||||||
|
|
||||||
|
add_action('transition_post_status', function($new_status, $old_status, $post) {
|
||||||
|
if ($new_status === 'publish' && $old_status !== 'publish') fea_evangelio_capture_meta($post->ID);
|
||||||
|
}, 20, 3);
|
||||||
|
|
||||||
|
/** Añade referencia y texto plegado, cerrado por defecto, antes del comentario. */
|
||||||
|
add_filter('the_content', function($content) {
|
||||||
|
if (!is_single() || is_admin()) return $content;
|
||||||
|
global $post;
|
||||||
|
if (!$post || !fea_evangelio_is_comment($post->ID)) return $content;
|
||||||
|
$cita = trim((string) fea_evangelio_comment_meta($post->ID, '_cita_evangelio'));
|
||||||
|
if (!$cita) return $content;
|
||||||
|
$reading_id = fea_evangelio_reading_id($cita);
|
||||||
|
$lang = function_exists('pll_current_language') ? pll_current_language() : 'es';
|
||||||
|
if ($reading_id && $lang && $lang !== 'es' && function_exists('pll_get_post')) $reading_id = (int) (pll_get_post($reading_id, $lang) ?: $reading_id);
|
||||||
|
$reading = $reading_id ? get_post($reading_id) : null;
|
||||||
|
$starts_with_cita = preg_match('/^\s*' . preg_quote($cita, '/') . '(?:[\s\.,:;\-–—]|$)/iu', wp_strip_all_tags($content));
|
||||||
|
$html = '<section class="fea-com-evangelio" aria-label="' . esc_attr(fea_evangelio_summary_label($lang)) . '">';
|
||||||
|
if (!$starts_with_cita) $html .= '<p class="fea-com-evangelio-cita">' . esc_html($cita) . '</p>';
|
||||||
|
if ($reading && trim(wp_strip_all_tags($reading->post_content)) !== '') {
|
||||||
|
$html .= '<details class="fea-com-evangelio-details"><summary>' . esc_html(fea_evangelio_summary_label($lang)) . '</summary>'
|
||||||
|
. wpautop(do_shortcode($reading->post_content)) . '</details>';
|
||||||
|
}
|
||||||
|
return $html . '</section>' . $content;
|
||||||
|
}, 8);
|
||||||
|
|
||||||
|
add_action('wp_head', function() {
|
||||||
|
if (!is_single()) return;
|
||||||
|
global $post;
|
||||||
|
if (!$post || !fea_evangelio_is_comment($post->ID)) return;
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
.fea-com-evangelio { margin: 0 0 1.75rem; padding: 1rem 1.15rem; background: #f7f4ed; border-left: 4px solid #a77b2d; }
|
||||||
|
.fea-com-evangelio-cita { margin: 0 0 .65rem; font-weight: 700; }
|
||||||
|
.fea-com-evangelio-details summary { cursor: pointer; font-weight: 700; color: #51401e; }
|
||||||
|
.fea-com-evangelio-details > :last-child { margin-bottom: 0; }
|
||||||
|
.fea-com-evangelio-details[open] summary { margin-bottom: .85rem; }
|
||||||
|
</style>
|
||||||
|
<?php
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
+37
-19
@@ -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 [
|
||||||
@@ -2013,8 +2044,12 @@ add_filter('the_content', function($content) {
|
|||||||
global $post;
|
global $post;
|
||||||
if (!in_category('comentarios-al-evangelio', $post)) return $content;
|
if (!in_category('comentarios-al-evangelio', $post)) return $content;
|
||||||
|
|
||||||
$carta_id = get_post_meta($post->ID, '_carta_id', true);
|
$carta_id = function_exists('fea_evangelio_comment_meta')
|
||||||
$cita = get_post_meta($post->ID, '_cita_evangelio', true);
|
? fea_evangelio_comment_meta($post->ID, '_carta_id')
|
||||||
|
: get_post_meta($post->ID, '_carta_id', true);
|
||||||
|
$cita = function_exists('fea_evangelio_comment_meta')
|
||||||
|
? fea_evangelio_comment_meta($post->ID, '_cita_evangelio')
|
||||||
|
: get_post_meta($post->ID, '_cita_evangelio', true);
|
||||||
|
|
||||||
if (!$carta_id && !$cita) return $content;
|
if (!$carta_id && !$cita) return $content;
|
||||||
|
|
||||||
@@ -2035,23 +2070,6 @@ add_filter('the_content', function($content) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Cita del evangelio — link directo al anchor en el índice ---
|
|
||||||
if ($cita) {
|
|
||||||
$book_post_id = ['Mt' => 43908, 'Mc' => 43909, 'Lc' => 43907, 'Jn' => 43906];
|
|
||||||
$abbr = substr($cita, 0, 2);
|
|
||||||
$anchor = fea_cita_to_anchor($cita);
|
|
||||||
$index_url = isset($book_post_id[$abbr])
|
|
||||||
? esc_url(get_permalink($book_post_id[$abbr]) . '#' . $anchor)
|
|
||||||
: '';
|
|
||||||
|
|
||||||
$html .= '<div class="fea-com-row fea-com-cita">'
|
|
||||||
. '<span class="fea-com-label">Evangelio</span>'
|
|
||||||
. ($index_url
|
|
||||||
? '<a href="' . $index_url . '">' . esc_html($cita) . '</a>'
|
|
||||||
: '<span>' . esc_html($cita) . '</span>')
|
|
||||||
. '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Otros comentarios de esta misma cita ---
|
// --- Otros comentarios de esta misma cita ---
|
||||||
if ($cita) {
|
if ($cita) {
|
||||||
$others = get_posts([
|
$others = get_posts([
|
||||||
|
|||||||
@@ -0,0 +1,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);
|
||||||
@@ -1,23 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* fea-pensamientos — Galerías Joomla y pausa aleatoria en artículos.
|
* fea-pensamientos — Galerías (Pensamientos, jornadas) y pausa aleatoria en artículos.
|
||||||
*
|
*
|
||||||
* Reutiliza /images de Joomla sin duplicar ficheros en WordPress.
|
* 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')) {
|
if (!defined('FEA_JOOMLA_IMAGES_DIR')) {
|
||||||
define('FEA_JOOMLA_IMAGES_DIR', file_exists('/web/images') ? '/web/images' : '/var/www/joomla-images');
|
define('FEA_JOOMLA_IMAGES_DIR', WP_CONTENT_DIR . '/uploads/joomla-galleries');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!defined('FEA_JOOMLA_IMAGES_URL')) {
|
if (!defined('FEA_JOOMLA_IMAGES_URL')) {
|
||||||
$fea_is_prod = (defined('ABSPATH') && strpos((string) ABSPATH, '/web/wp-nuevo/') === 0)
|
define('FEA_JOOMLA_IMAGES_URL', content_url('uploads/joomla-galleries'));
|
||||||
|| (isset($_SERVER['HTTP_HOST']) && preg_match('/(^|\.)feadulta\.com$/', (string) $_SERVER['HTTP_HOST']))
|
|
||||||
|| file_exists('/web/images');
|
|
||||||
|
|
||||||
define(
|
|
||||||
'FEA_JOOMLA_IMAGES_URL',
|
|
||||||
$fea_is_prod ? 'https://www.feadulta.com/images' : 'https://farmer.taild3aaf6.ts.net/joomla/images'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!defined('FEA_PENS_DIR')) {
|
if (!defined('FEA_PENS_DIR')) {
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ defined('FEA_AUTORES_EXCLUIR') or define('FEA_AUTORES_EXCLUIR', [
|
|||||||
*/
|
*/
|
||||||
defined('FEA_CATS_CARTA_EXCLUIR') or define('FEA_CATS_CARTA_EXCLUIR', [6, 21, 22]);
|
defined('FEA_CATS_CARTA_EXCLUIR') or define('FEA_CATS_CARTA_EXCLUIR', [6, 21, 22]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorías RESIDUALES de la migración K2: páginas-índice por autor (listado de enlaces
|
||||||
|
* a sus artículos), no son artículos. 37 = "Lista de autores habituales", 38 = "Lista
|
||||||
|
* completa de autores por orden alfabético" (~1.169 posts). Por su gran tamaño (varios KB
|
||||||
|
* de títulos enlazados) el ranking FULLTEXT las puntúa altísimo y tapan el resultado real
|
||||||
|
* (bug reportado en feedback: "Tu verdadero ser" de Fray Marcos sacaba la ficha-índice del
|
||||||
|
* autor antes que el artículo). Se excluyen de los resultados de búsqueda.
|
||||||
|
*/
|
||||||
|
defined('FEA_CATS_RESIDUAL_EXCLUIR') or define('FEA_CATS_RESIDUAL_EXCLUIR', [37, 38]);
|
||||||
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────
|
||||||
// i18n mínimo (es / en / fr / it / pt)
|
// i18n mínimo (es / en / fr / it / pt)
|
||||||
@@ -139,6 +149,12 @@ add_action('pre_get_posts', function (WP_Query $q): void {
|
|||||||
// Categoría (tema)
|
// Categoría (tema)
|
||||||
if ($fea_cat > 0) $q->set('cat', $fea_cat);
|
if ($fea_cat > 0) $q->set('cat', $fea_cat);
|
||||||
|
|
||||||
|
// Excluir páginas-índice residuales K2 (ver FEA_CATS_RESIDUAL_EXCLUIR) de los
|
||||||
|
// resultados de búsqueda. No aplica si el usuario ya filtró por una categoría concreta.
|
||||||
|
if ($is_search && $fea_cat <= 0) {
|
||||||
|
$q->set('category__not_in', FEA_CATS_RESIDUAL_EXCLUIR);
|
||||||
|
}
|
||||||
|
|
||||||
// Cita bíblica: coincidencia por PREFIJO (el valor empieza por el término, ej. "Jn").
|
// Cita bíblica: coincidencia por PREFIJO (el valor empieza por el término, ej. "Jn").
|
||||||
// Usamos REGEXP '^<term>' con el término escapado para evitar metacaracteres.
|
// Usamos REGEXP '^<term>' con el término escapado para evitar metacaracteres.
|
||||||
if ($fea_cita !== '') {
|
if ($fea_cita !== '') {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* relevancia FULLTEXT si no se pide otro criterio de orden. Degradación elegante:
|
* relevancia FULLTEXT si no se pide otro criterio de orden. Degradación elegante:
|
||||||
* si no hay término o el índice FULLTEXT no existe, usa el comportamiento nativo.
|
* si no hay término o el índice FULLTEXT no existe, usa el comportamiento nativo.
|
||||||
* Convive con fea-search-advanced.php (filtros pre_get_posts de autor/cat/cita/fecha).
|
* Convive con fea-search-advanced.php (filtros pre_get_posts de autor/cat/cita/fecha).
|
||||||
* Version: 1.1
|
* Version: 1.3
|
||||||
*/
|
*/
|
||||||
if (!defined('ABSPATH')) exit;
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
@@ -38,16 +38,46 @@ function fea_ft_index_exists(): bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calcula el término FULLTEXT en Boolean Mode (cada palabra con prefijo *).
|
* Calcula el término FULLTEXT en Boolean Mode.
|
||||||
|
*
|
||||||
|
* Caso normal (sin comillas): cada palabra requerida con prefijo + y sufijo *. El '+'
|
||||||
|
* fuerza AND entre palabras — sin él, Boolean Mode las une con OR implícito y cualquier
|
||||||
|
* post que contenga UNA sola palabra común (p.ej. "ser") entra en el resultado, lo que en
|
||||||
|
* la práctica devolvía casi todo el sitio (~24.700 de ~24.780 posts, bug #8 reportado en
|
||||||
|
* feedback: "Tu verdadero ser" devolvía cientos de artículos irrelevantes).
|
||||||
|
*
|
||||||
|
* Caso frase exacta (entrecomillado, ej. `"tu verdadero ser"`): Boolean Mode soporta
|
||||||
|
* nativamente búsqueda de frase entre comillas dobles (coincidencia de adyacencia, no
|
||||||
|
* solo de palabras sueltas). Reenviamos el contenido saneado tal cual entre comillas, sin
|
||||||
|
* partirlo en palabras con + ni *, para que el usuario que escribe entre comillas obtenga
|
||||||
|
* de verdad una búsqueda de cadena exacta.
|
||||||
|
*
|
||||||
* Devuelve '' si el término sanitizado queda vacío.
|
* Devuelve '' si el término sanitizado queda vacío.
|
||||||
*/
|
*/
|
||||||
function fea_ft_boolean_term(string $raw): string {
|
function fea_ft_boolean_term(string $raw): string {
|
||||||
|
// Frase exacta: todo el término entre comillas dobles.
|
||||||
|
if (preg_match('/^"(.*)"$/us', $raw, $m)) {
|
||||||
|
$phrase = trim(substr(preg_replace('/[^\p{L}\p{N}\s\'\-]/u', '', $m[1]), 0, 200));
|
||||||
|
if ($phrase === '') return '';
|
||||||
|
return '"' . $phrase . '"';
|
||||||
|
}
|
||||||
|
|
||||||
$term = trim(substr(preg_replace('/[^\p{L}\p{N}\s\'\-]/u', '', $raw), 0, 200));
|
$term = trim(substr(preg_replace('/[^\p{L}\p{N}\s\'\-]/u', '', $raw), 0, 200));
|
||||||
if ($term === '') return '';
|
if ($term === '') return '';
|
||||||
|
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$words = preg_split('/\s+/', $term);
|
$words = preg_split('/\s+/', $term);
|
||||||
return implode('* ', array_map(fn($w) => $wpdb->esc_like($w), $words)) . '*';
|
|
||||||
|
// Descartamos palabras por debajo de innodb_ft_min_token_size (3 en este servidor):
|
||||||
|
// MySQL las excluye del índice, y forzarlas igualmente como término obligatorio ('+')
|
||||||
|
// no aporta nada al filtrado (WP igual las ignora) pero SÍ rompe el cálculo de
|
||||||
|
// relevancia: si un término '+' no existe en el índice, MATCH()...AGAINST() en modo
|
||||||
|
// boolean puede devolver 0 aunque la fila cumpla el resto de términos, dejando el
|
||||||
|
// ORDER BY sin poder distinguir resultados relevantes de irrelevantes.
|
||||||
|
$words = array_values(array_filter($words, fn($w) => mb_strlen($w) >= 3));
|
||||||
|
if (empty($words)) return '';
|
||||||
|
|
||||||
|
return implode(' ', array_map(fn($w) => '+' . $wpdb->esc_like($w) . '*', $words));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
/**
|
/**
|
||||||
* fea-share — Sección "Comparte Fe Adulta" en single posts + Open Graph tags.
|
* fea-share — Sección "Comparte Fe Adulta" en single posts + Open Graph tags.
|
||||||
* Botones: Facebook, Instagram (Web Share API + fallback copiar), Imprimir.
|
* Botones: Facebook, Instagram (Web Share API + fallback copiar), Imprimir.
|
||||||
* Sin plugins externos, sin JS de terceros, sin tracking.
|
* Sin plugins externos, sin JS de terceros. El botón de Facebook dispara un
|
||||||
|
* evento GA4 share_click (mismo patrón que fea-audio-player.php: data-attribute
|
||||||
|
* + listener en wp_footer que llama a gtag, ya inicializado por fea-analytics.php).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Solo artículos reales: single post_type=post, excluyendo institucionales. */
|
/** Solo artículos reales: single post_type=post, excluyendo institucionales. */
|
||||||
@@ -44,6 +46,7 @@ function fea_share_block_html(): string {
|
|||||||
$svg_pr = '<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true" focusable="false" fill="currentColor"><path d="M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3zm-3 11H8v-5h8v5zm3-7a1 1 0 0 1 0-2 1 1 0 0 1 0 2zm-1-9H6v4h12V3z"/></svg>';
|
$svg_pr = '<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true" focusable="false" fill="currentColor"><path d="M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3zm-3 11H8v-5h8v5zm3-7a1 1 0 0 1 0-2 1 1 0 0 1 0 2zm-1-9H6v4h12V3z"/></svg>';
|
||||||
|
|
||||||
$copied_msg = esc_js($t['copied']);
|
$copied_msg = esc_js($t['copied']);
|
||||||
|
$post_id = get_the_ID();
|
||||||
|
|
||||||
return '
|
return '
|
||||||
<div class="fea-share">
|
<div class="fea-share">
|
||||||
@@ -52,7 +55,8 @@ function fea_share_block_html(): string {
|
|||||||
<span class="fea-share-label">' . esc_html($t['section']) . '</span>
|
<span class="fea-share-label">' . esc_html($t['section']) . '</span>
|
||||||
<a href="https://www.facebook.com/sharer/sharer.php?u=' . $url . '"
|
<a href="https://www.facebook.com/sharer/sharer.php?u=' . $url . '"
|
||||||
target="_blank" rel="noopener noreferrer nofollow"
|
target="_blank" rel="noopener noreferrer nofollow"
|
||||||
class="fea-share-item fea-share-fb">
|
class="fea-share-item fea-share-fb"
|
||||||
|
data-fea-share-track="facebook" data-post-id="' . esc_attr($post_id) . '">
|
||||||
' . $svg_fb . '<span>' . esc_html($t['fb']) . '</span>
|
' . $svg_fb . '<span>' . esc_html($t['fb']) . '</span>
|
||||||
</a>
|
</a>
|
||||||
<span class="fea-share-sep" aria-hidden="true">·</span>
|
<span class="fea-share-sep" aria-hidden="true">·</span>
|
||||||
@@ -198,6 +202,15 @@ add_action('wp_footer', function () {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
document.querySelectorAll('[data-fea-share-track]').forEach(function (el) {
|
||||||
|
el.addEventListener('click', function () {
|
||||||
|
if (typeof gtag !== 'function') return;
|
||||||
|
gtag('event', 'share_click', {
|
||||||
|
method: el.dataset.feaShareTrack,
|
||||||
|
post_id: el.dataset.postId
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<?php
|
<?php
|
||||||
}, 20);
|
}, 20);
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta — API subir avatar
|
||||||
|
* Description: Endpoint REST acotado para asignar la foto de perfil de un autor.
|
||||||
|
* Version: 1.0
|
||||||
|
*
|
||||||
|
* POST /wp-json/fea/v1/subir-avatar
|
||||||
|
* multipart/form-data: user_id=<id>, avatar=<imagen JPEG|PNG|WebP>
|
||||||
|
*
|
||||||
|
* Ver issue gitea.feadulta.com/rafa/feadulta#175.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
const FEA_AVATAR_MAX_BYTES = 5242880; // 5 MiB.
|
||||||
|
const FEA_AVATAR_SIZE = 512;
|
||||||
|
const FEA_AVATAR_MIN_SIZE = 512;
|
||||||
|
const FEA_AVATAR_MAX_DIMENSION = 4096;
|
||||||
|
const FEA_AVATAR_MAX_PIXELS = 16777216; // 16 megapíxeles.
|
||||||
|
|
||||||
|
add_action('rest_api_init', function () {
|
||||||
|
register_rest_route('fea/v1', '/subir-avatar', [
|
||||||
|
'methods' => WP_REST_Server::CREATABLE,
|
||||||
|
'callback' => 'fea_subir_avatar_handle',
|
||||||
|
'permission_callback' => 'fea_subir_avatar_can_call',
|
||||||
|
// Se valida dentro del handler, después del permission_callback: una
|
||||||
|
// llamada sin autenticar siempre recibe 401, incluso si omite user_id.
|
||||||
|
'args' => [
|
||||||
|
'user_id' => [
|
||||||
|
'sanitize_callback' => 'absint',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Editor o superior: mismo nivel que el endpoint /crear-autor. */
|
||||||
|
function fea_subir_avatar_can_call(WP_REST_Request $request) {
|
||||||
|
if (!is_user_logged_in()) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_avatar_not_authenticated',
|
||||||
|
'Debes autenticarte para asignar un avatar.',
|
||||||
|
['status' => 401]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!current_user_can('edit_others_posts')) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_avatar_forbidden',
|
||||||
|
'No tienes permiso para asignar avatares.',
|
||||||
|
['status' => 403]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_subir_avatar_handle(WP_REST_Request $request) {
|
||||||
|
$user_id = absint($request->get_param('user_id'));
|
||||||
|
if (!$user_id) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_avatar_invalid_user',
|
||||||
|
'user_id es obligatorio.',
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = get_userdata($user_id);
|
||||||
|
if (!$user) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_avatar_user_not_found',
|
||||||
|
'No existe el usuario indicado.',
|
||||||
|
['status' => 404]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = $request->get_file_params();
|
||||||
|
$file = $files['avatar'] ?? null;
|
||||||
|
$validation = fea_subir_avatar_validate_file($file);
|
||||||
|
if (is_wp_error($validation)) return $validation;
|
||||||
|
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/image.php';
|
||||||
|
|
||||||
|
$uploaded = wp_handle_upload($file, [
|
||||||
|
'test_form' => false,
|
||||||
|
'mimes' => [
|
||||||
|
'jpg|jpeg|jpe' => 'image/jpeg',
|
||||||
|
'png' => 'image/png',
|
||||||
|
'webp' => 'image/webp',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
if (!empty($uploaded['error'])) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_avatar_upload_failed',
|
||||||
|
'No se pudo guardar la imagen: ' . $uploaded['error'],
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = fea_subir_avatar_normalize($uploaded['file']);
|
||||||
|
if (is_wp_error($normalized)) {
|
||||||
|
wp_delete_file($uploaded['file']);
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachment_id = wp_insert_attachment([
|
||||||
|
'post_mime_type' => $normalized['mime-type'],
|
||||||
|
'post_title' => 'Avatar — ' . $user->display_name,
|
||||||
|
'post_status' => 'inherit',
|
||||||
|
], $normalized['path'], 0, true);
|
||||||
|
if (is_wp_error($attachment_id)) {
|
||||||
|
wp_delete_file($normalized['path']);
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_avatar_attachment_failed',
|
||||||
|
'No se pudo crear el attachment del avatar.',
|
||||||
|
['status' => 500]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$metadata = wp_generate_attachment_metadata($attachment_id, $normalized['path']);
|
||||||
|
if (is_wp_error($metadata) || !is_array($metadata) || !$metadata) {
|
||||||
|
wp_delete_attachment($attachment_id, true);
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_avatar_metadata_failed',
|
||||||
|
'No se pudo generar la metadata del avatar.',
|
||||||
|
['status' => 500]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
wp_update_attachment_metadata($attachment_id, $metadata);
|
||||||
|
if (!wp_get_attachment_metadata($attachment_id)) {
|
||||||
|
wp_delete_attachment($attachment_id, true);
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_avatar_metadata_failed',
|
||||||
|
'No se pudo guardar la metadata del avatar.',
|
||||||
|
['status' => 500]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$previous_attachment_id = (int) get_user_meta($user_id, 'foto_perfil', true);
|
||||||
|
if (!update_user_meta($user_id, 'foto_perfil', (string) $attachment_id)) {
|
||||||
|
wp_delete_attachment($attachment_id, true);
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_avatar_assignment_failed',
|
||||||
|
'No se pudo asignar el avatar al usuario.',
|
||||||
|
['status' => 500]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = wp_getimagesize($normalized['path']);
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'user_id' => $user_id,
|
||||||
|
'attachment_id' => (int) $attachment_id,
|
||||||
|
'previous_attachment_id' => $previous_attachment_id ?: null,
|
||||||
|
'avatar_url' => wp_get_attachment_image_url($attachment_id, 'full'),
|
||||||
|
'width' => (int) ($size[0] ?? 0),
|
||||||
|
'height' => (int) ($size[1] ?? 0),
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_subir_avatar_validate_file($file) {
|
||||||
|
if (!is_array($file) || empty($file['tmp_name']) || !isset($file['error'])) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_avatar_missing_file',
|
||||||
|
'avatar es obligatorio.',
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ((int) $file['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_avatar_file_error',
|
||||||
|
'La subida de avatar falló.',
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ((int) $file['size'] > FEA_AVATAR_MAX_BYTES) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_avatar_file_too_large',
|
||||||
|
'La imagen no puede superar 5 MB.',
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$image = wp_getimagesize($file['tmp_name']);
|
||||||
|
if (!$image) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_avatar_invalid_image',
|
||||||
|
'avatar debe ser una imagen válida.',
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$width = (int) $image[0];
|
||||||
|
$height = (int) $image[1];
|
||||||
|
if ($width < FEA_AVATAR_MIN_SIZE || $height < FEA_AVATAR_MIN_SIZE) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_avatar_invalid_image',
|
||||||
|
'avatar debe ser una imagen de al menos 512×512 píxeles.',
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ($width > FEA_AVATAR_MAX_DIMENSION || $height > FEA_AVATAR_MAX_DIMENSION || ($width * $height) > FEA_AVATAR_MAX_PIXELS) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_avatar_image_too_large',
|
||||||
|
'avatar no puede superar 4096 píxeles por lado ni 16 megapíxeles.',
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowed_mimes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
if (!in_array($image['mime'], $allowed_mimes, true)) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_avatar_unsupported_type',
|
||||||
|
'Solo se admiten imágenes JPEG, PNG o WebP.',
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recorta al centro y normaliza la imagen al tamaño estándar del avatar. */
|
||||||
|
function fea_subir_avatar_normalize(string $path) {
|
||||||
|
$editor = wp_get_image_editor($path);
|
||||||
|
if (is_wp_error($editor)) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_avatar_editor_unavailable',
|
||||||
|
'No se pudo procesar la imagen subida.',
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resized = $editor->resize(FEA_AVATAR_SIZE, FEA_AVATAR_SIZE, true);
|
||||||
|
if (is_wp_error($resized)) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_avatar_resize_failed',
|
||||||
|
'No se pudo normalizar el avatar.',
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$saved = $editor->save($path);
|
||||||
|
if (is_wp_error($saved) || empty($saved['path'])) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_avatar_save_failed',
|
||||||
|
'No se pudo guardar el avatar normalizado.',
|
||||||
|
['status' => 500]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return $saved;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
/** Propuesta/aplicación segura del backfill de _cita_evangelio. */
|
||||||
|
/** Uso: wp eval-file scripts/backfill_evangelio_meta.php [--apply] --path=/var/www/html */
|
||||||
|
|
||||||
|
// WP-CLI no reenvía de forma consistente argumentos desconocidos a eval-file.
|
||||||
|
// El modo escritura exige por tanto una intención explícita y auditable:
|
||||||
|
// FEA_EVANGELIO_APPLY=1 wp eval-file scripts/backfill_evangelio_meta.php
|
||||||
|
$apply = getenv('FEA_EVANGELIO_APPLY') === '1'
|
||||||
|
|| in_array('--apply', $_SERVER['argv'] ?? [], true);
|
||||||
|
$posts = get_posts([
|
||||||
|
'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => -1,
|
||||||
|
'category__in' => fea_evangelio_comment_categories(),
|
||||||
|
'meta_query' => [['key' => '_cita_evangelio', 'compare' => 'NOT EXISTS']],
|
||||||
|
'orderby' => 'date', 'order' => 'ASC',
|
||||||
|
]);
|
||||||
|
$out = fopen('php://output', 'w');
|
||||||
|
fputcsv($out, ['post_id', 'fecha', 'carta_id', 'cita_propuesta', 'resultado']);
|
||||||
|
foreach ($posts as $post) {
|
||||||
|
$before_carta = (int) get_post_meta($post->ID, '_carta_id', true);
|
||||||
|
// Las cartas deducidas solo se proponen: la cola histórica requiere revisión humana.
|
||||||
|
$carta_id = $before_carta ?: fea_evangelio_nearest_carta_id($post);
|
||||||
|
$evangelios = $carta_id ? fea_evangelio_from_carta($carta_id) : [];
|
||||||
|
$cita = $before_carta && count($evangelios) === 1 ? reset($evangelios) : '';
|
||||||
|
$result = $cita ? ($apply ? 'aplicado' : 'propuesta') : (!$before_carta ? 'revision_manual_carta_por_fecha' : (count($evangelios) > 1 ? 'revision_manual_multi_evangelio' : 'sin_evangelio'));
|
||||||
|
if ($apply && $cita) {
|
||||||
|
update_post_meta($post->ID, '_cita_evangelio', $cita);
|
||||||
|
if (!$before_carta) update_post_meta($post->ID, '_carta_id', $carta_id);
|
||||||
|
}
|
||||||
|
fputcsv($out, [$post->ID, $post->post_date, $carta_id ?: '', $cita, $result]);
|
||||||
|
}
|
||||||
+15
-3
@@ -19,6 +19,7 @@ if ($action === 'get') {
|
|||||||
'title' => $p->post_title,
|
'title' => $p->post_title,
|
||||||
'content' => $p->post_content,
|
'content' => $p->post_content,
|
||||||
'status' => $p->post_status,
|
'status' => $p->post_status,
|
||||||
|
'author' => (int)$p->post_author,
|
||||||
], JSON_UNESCAPED_UNICODE));
|
], JSON_UNESCAPED_UNICODE));
|
||||||
exit(0);
|
exit(0);
|
||||||
}
|
}
|
||||||
@@ -43,10 +44,11 @@ if ($action === 'getmeta') {
|
|||||||
exit(0);
|
exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($action === 'setaudio') { // setaudio <id> <relpath>
|
if ($action === 'setaudio') { // setaudio <id> <relpath> [voice_id]
|
||||||
$id = (int)$argv[2];
|
$id = (int)$argv[2];
|
||||||
|
$voice = $argv[4] ?? 'NicoFeadulta2026';
|
||||||
update_post_meta($id, 'fea_audio_url', home_url($argv[3]));
|
update_post_meta($id, 'fea_audio_url', home_url($argv[3]));
|
||||||
update_post_meta($id, 'fea_audio_voice', 'NicoFeadulta2026');
|
update_post_meta($id, 'fea_audio_voice', $voice);
|
||||||
update_post_meta($id, 'fea_audio_done', '1');
|
update_post_meta($id, 'fea_audio_done', '1');
|
||||||
delete_post_meta($id, 'fea_audio_error');
|
delete_post_meta($id, 'fea_audio_error');
|
||||||
fwrite(STDOUT, "ok " . home_url($argv[3]) . "\n");
|
fwrite(STDOUT, "ok " . home_url($argv[3]) . "\n");
|
||||||
@@ -58,5 +60,15 @@ if ($action === 'setflag') { // setflag <id> <key> <value>
|
|||||||
exit(0);
|
exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
fwrite(STDERR, "uso: get|update|getmeta|setaudio|setflag\n");
|
if ($action === 'unsetaudio') { // unsetaudio <id> (rollback: despublica el audio)
|
||||||
|
$id = (int)$argv[2];
|
||||||
|
delete_post_meta($id, 'fea_audio_url');
|
||||||
|
delete_post_meta($id, 'fea_audio_voice');
|
||||||
|
delete_post_meta($id, 'fea_audio_done');
|
||||||
|
delete_post_meta($id, 'fea_audio_error');
|
||||||
|
fwrite(STDOUT, "ok despublicado $id\n");
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "uso: get|update|getmeta|setaudio|setflag|unsetaudio\n");
|
||||||
exit(2);
|
exit(2);
|
||||||
|
|||||||
@@ -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,59 @@
|
|||||||
|
# Homenajes Mardones / Galarreta + fixes varios (sesión 2026-06-23)
|
||||||
|
|
||||||
|
Scripts de la sesión 2026-06-23 (issues Gitea **#130, #131, #133, #137, #138**, todas cerradas).
|
||||||
|
Todo aplicado en **local** (Docker `wordpress-web`) y **prod** (wp-nuevo, vía `ssh feadulta@… && wp eval-file`).
|
||||||
|
|
||||||
|
## Convenciones del despliegue a prod (wp-nuevo)
|
||||||
|
- Prod corre en la **raíz** del dominio (`https://wp-nuevo.feadulta.com`), local bajo `/fea`.
|
||||||
|
→ al llevar contenido con rutas, transformar `/fea/wp-content` → `/wp-content`.
|
||||||
|
- `proc_open` deshabilitado en prod → usar `wp eval-file` (PHP puro). `scp` no va → subir con
|
||||||
|
`ssh '… cat > /entrada/FICHERO'`; imágenes en bloque con `tar cf -` → `ssh 'tar x'`.
|
||||||
|
- **IDs de contenido migrado coinciden local↔prod** (posts, template parts, users, cats).
|
||||||
|
- **Lección clave:** para arreglar posts ya migrados con texto malo (idioma/contenido),
|
||||||
|
**sobrescribir EN SITIO en prod** (mismos IDs) en vez de borrar+recrear → preserva los grupos
|
||||||
|
Polylang. Para contenido NUEVO (noticia, 23 posts Mardones) crear con **meta idempotente**.
|
||||||
|
|
||||||
|
## #138 — Mardones: 23 páginas `anterior/*.htm` → 23 posts individuales
|
||||||
|
Origen: el homenaje WP **17990** (`mardones-2`, com_content Joomla 501) enlazaba 23 páginas
|
||||||
|
estáticas de la web V1 FrontPage (`/web/anterior/*.htm`, windows-1252). 8 «Aprendiendo a orar»
|
||||||
|
(serie de Mardones) + 15 homenaje (obituarios, álbum de fotos, audio→conferencia, currículo…).
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
1. `mardones_build_manifest.py` — descarga local de los 23 `.htm`, decodifica **win-1252→UTF-8**,
|
||||||
|
limpia basura FrontPage, extrae párrafos `<p>` (descarta cabecera «cristianos siglo veintiuno»),
|
||||||
|
preserva enlaces externos, inserta imágenes. Genera `mardones_manifest.json`.
|
||||||
|
Títulos = texto del enlace en 17990; autor: serie orar = **710 (Mardones)**, homenajes = **1**.
|
||||||
|
2. `mardones_create_posts.php` (local) / `mardones_prod_create_and_repoint.php` (prod) — crea los 23
|
||||||
|
posts (lang es, cat 38, publish), idempotente por meta **`_mardones_src`**=nombre de fichero.
|
||||||
|
3. `mardones_repoint_homenaje.php` — repunta los 23 `href="anterior/X.htm"` del 17990 a los nuevos
|
||||||
|
permalinks (prod lo hace el script combinado).
|
||||||
|
4. Imágenes (8: `homenaje-album-1..7.jpg`, `homenaje-charla-mejico.jpg`) → `uploads/anterior-mardones/`.
|
||||||
|
|
||||||
|
IDs creados en local: **53577–53599**. En prod: IDs propios (se resuelven por meta).
|
||||||
|
|
||||||
|
### Retoques posteriores (`mardones_fix_fecha_y_target.php`, local+prod)
|
||||||
|
- **Fecha:** los posts se crearon con fecha de hoy → reajustados a **2012-10-10 19:26:00**
|
||||||
|
(la del propio homenaje 17990) para que no aparezcan como entradas nuevas en feeds/archivos.
|
||||||
|
- **Ventana nueva:** los enlaces del homenaje 17990 heredaban `target="_blank"` del original →
|
||||||
|
eliminado (`target="_blank"`/`rel`) para que abran en la **misma ventana** (navegación interna).
|
||||||
|
*Nota:* los enlaces externos DENTRO de cada post (p.ej. la conferencia iteso.mx en homenaje-3)
|
||||||
|
sí conservan `target="_blank"`.
|
||||||
|
|
||||||
|
## #138 — Galarreta
|
||||||
|
`galarreta_repoint_presentaciones.php` (local) / `galarreta_prod_fixes.php` (prod):
|
||||||
|
- 10 presentaciones de `presentaciones-gala` (19107) estaban mal mapeadas (apuntaban a artículos
|
||||||
|
random) → repuntadas a sus destinos correctos (19105/19104/19102/19106/19101/19119/19123/19132/19131/19143).
|
||||||
|
- «Libros y e-books» (en la página 17918) → tienda externa
|
||||||
|
`https://edicionesfeadulta.com/?filters=autor[galarreta]` (13 libros), `target="_blank"`.
|
||||||
|
- iframe de YouTube forzado a `https`.
|
||||||
|
- Auditados `articulos-galarreta` (19086) y `comentarios-galarreta` (19085): sin enlaces rotos reales.
|
||||||
|
|
||||||
|
## #130 / #131 / #133 / #137 (sync a prod)
|
||||||
|
- `issue130_prod_noticia_papa.php` — crea la noticia «León XIV en España…» (Joomla 9082, no migrada)
|
||||||
|
en cat 41 Noticias de alcance, idempotente por `_fgj2wp_old_id=9082`.
|
||||||
|
- `issue131_prod_footer_suma.php` — enlaza la imagen `este_portal.gif` del footer (template 42370)
|
||||||
|
a `/numeros/` (post 18036, «La suma de todos»).
|
||||||
|
- `issue133_prod_idiomas.php` — 5 reasignaciones de idioma + sobrescritura en sitio de los 3 `fr`
|
||||||
|
que contenían inglés (43755/47152/47136) + catalán 13339 → francés, enlazado al ES 13338.
|
||||||
|
- `issue137_prod_inmemoriam.php` — repunta In memoriam (19112): Galarreta→17918, Mardones→17990,
|
||||||
|
Mari Patxi Ayerra→23519 (artículo de fallecimiento).
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
// 1) presentaciones-gala 19107: repuntar 10
|
||||||
|
$map=[
|
||||||
|
"sugerencias-para-las-bodas"=>19105,"el-eterno-problema-del-mundo-y-los-hijos"=>19104,
|
||||||
|
"necesitamos-un-pepe"=>19102,"lucas-18-1-8"=>19106,"el-perdon-primero"=>19101,
|
||||||
|
"alabar-a-dios-y-darle-gracias"=>19119,"2-reyes-5-14-17-2-timoteo-2-8-13"=>19123,
|
||||||
|
"creer-en-dios-es-apostar-por-la-vida"=>19132,"lucas-17-5-10"=>19131,"la-gran-evasion"=>19143,
|
||||||
|
];
|
||||||
|
$c=get_post(19107)->post_content; $n=0;
|
||||||
|
foreach($map as $bad=>$dst){
|
||||||
|
if(!get_post($dst)){ echo " dst $dst NO EXISTE\n"; continue; }
|
||||||
|
$url=get_permalink($dst);
|
||||||
|
$c2=preg_replace('#https?://[^"]*?/'.preg_quote($bad,'#').'/#',$url,$c,1,$cnt);
|
||||||
|
if($cnt>0){ $c=$c2; $n++; }
|
||||||
|
}
|
||||||
|
wp_update_post(["ID"=>19107,"post_content"=>$c]);
|
||||||
|
echo "presentaciones repuntadas: $n/10\n";
|
||||||
|
// 2) libros + youtube en 17918
|
||||||
|
$c=get_post(17918)->post_content;
|
||||||
|
$ext="https://edicionesfeadulta.com/?filters=autor[galarreta]";
|
||||||
|
$c=preg_replace('#https?://[^"]*?/libros-y-e-books/#',$ext,$c,-1,$cl);
|
||||||
|
$c=preg_replace('#(<a [^>]*href=")'.preg_quote($ext,'#').'("(?![^>]*target))#','$1'.$ext.'$2 target="_blank" rel="noopener"',$c);
|
||||||
|
$c=str_replace('src="//www.youtube.com/embed/','src="https://www.youtube.com/embed/',$c);
|
||||||
|
wp_update_post(["ID"=>17918,"post_content"=>$c]);
|
||||||
|
echo "libros->externo: $cl | youtube https aplicado | foto JE: ".(strpos($c,'edicionesfeadulta.com')!==false?'ok':'?')."\n";
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
// Repuntar las 10 presentaciones de presentaciones-gala (19107) a sus destinos correctos
|
||||||
|
$map=[
|
||||||
|
"sugerencias-para-las-bodas"=>19105,
|
||||||
|
"el-eterno-problema-del-mundo-y-los-hijos"=>19104,
|
||||||
|
"necesitamos-un-pepe"=>19102,
|
||||||
|
"lucas-18-1-8"=>19106,
|
||||||
|
"el-perdon-primero"=>19101,
|
||||||
|
"alabar-a-dios-y-darle-gracias"=>19119,
|
||||||
|
"2-reyes-5-14-17-2-timoteo-2-8-13"=>19123,
|
||||||
|
"creer-en-dios-es-apostar-por-la-vida"=>19132,
|
||||||
|
"lucas-17-5-10"=>19131,
|
||||||
|
"la-gran-evasion"=>19143,
|
||||||
|
];
|
||||||
|
$c=get_post(19107)->post_content; $n=0; $report=[];
|
||||||
|
foreach($map as $badslug=>$dest){
|
||||||
|
$destUrl=get_permalink($dest);
|
||||||
|
// sustituir el href que contiene /fea/<badslug>/
|
||||||
|
$pat='#https?://[^"]*?/fea/'.preg_quote($badslug,'#').'/#';
|
||||||
|
$c2=preg_replace($pat,$destUrl,$c,1,$cnt);
|
||||||
|
if($cnt>0){ $c=$c2; $n++; $report[]="OK $badslug -> $destUrl"; }
|
||||||
|
else $report[]="NO ENCONTRADO $badslug";
|
||||||
|
}
|
||||||
|
wp_update_post(["ID"=>19107,"post_content"=>$c]);
|
||||||
|
echo implode("\n",$report)."\nTotal repuntados: $n/10\n";
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
global $wpdb;
|
||||||
|
$ex=$wpdb->get_var($wpdb->prepare("SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key='_fgj2wp_old_id' AND meta_value=%s","9082"));
|
||||||
|
if($ex){ echo "ya existe en prod: $ex\n"; return; }
|
||||||
|
$content=file_get_contents('/entrada/9082_content.html');
|
||||||
|
$id=wp_insert_post(["post_type"=>"post","post_status"=>"publish","post_author"=>890,
|
||||||
|
"post_title"=>"El plan (casi definitivo) de León XIV en España, al descubierto: de Carabanchel a Arguineguín, pasando por Montserrat",
|
||||||
|
"post_content"=>$content,"post_date"=>"2026-04-09 12:07:26","to_ping"=>"","pinged"=>"","post_content_filtered"=>""],true);
|
||||||
|
if(is_wp_error($id)){ echo "ERROR: ".$id->get_error_message()."\n"; return; }
|
||||||
|
update_post_meta($id,"_fgj2wp_old_id","9082");
|
||||||
|
wp_set_post_categories($id,[41]);
|
||||||
|
if(function_exists('pll_set_post_language')) pll_set_post_language($id,'es');
|
||||||
|
echo "creado prod $id cat=".implode(",",wp_get_post_categories($id))." status=".get_post_status($id)."\n";
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
$id=42370; $c=get_post($id)->post_content; $url=get_permalink(18036);
|
||||||
|
if(strpos($c,'<a href="'.$url.'"><img')!==false || preg_match('#<a [^>]*href="[^"]*numeros[^"]*"><img[^>]*este_portal#',$c)){ echo "ya enlazado\n"; return; }
|
||||||
|
// localizar el <img ... este_portal.gif ...> y envolverlo
|
||||||
|
if(preg_match('#<img[^>]*este_portal\.gif[^>]*/?>#i',$c,$m)){
|
||||||
|
$img=$m[0];
|
||||||
|
$c=str_replace($img,'<a href="'.esc_url($url).'">'.$img.'</a>',$c);
|
||||||
|
// linkDestination none->custom para el bloque (id 26996)
|
||||||
|
$c=preg_replace('#("id":26996,[^}]*?"linkDestination":")none(")#','${1}custom${2}',$c);
|
||||||
|
wp_update_post(["ID"=>$id,"post_content"=>$c]);
|
||||||
|
echo "OK enlazado este_portal -> $url\n";
|
||||||
|
} else echo "NO se encontró img este_portal.gif en 42370 (prod)\n";
|
||||||
|
// verificar
|
||||||
|
if(preg_match('#<a href="([^"]+)"><img[^>]*este_portal#',get_post($id)->post_content,$v)) echo "verif: -> ".$v[1]."\n";
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
// 1) reasignaciones de idioma (mismos IDs en prod)
|
||||||
|
$fix=[4424=>"fr",11129=>"it",12934=>"en",5891=>"en",3141=>"it"];
|
||||||
|
foreach($fix as $id=>$lang){
|
||||||
|
if(!get_post($id)){ echo " $id NO EXISTE\n"; continue; }
|
||||||
|
$old=pll_get_post_language($id); pll_set_post_language($id,$lang);
|
||||||
|
echo " $id: $old -> ".pll_get_post_language($id)."\n";
|
||||||
|
}
|
||||||
|
// 2) sobrescribir fr malos + catalan con francés correcto (overwrite en sitio)
|
||||||
|
$payload=json_decode(file_get_contents('/entrada/fr_payload.json'),true);
|
||||||
|
foreach($payload as $it){
|
||||||
|
$dst=$it['dst'];
|
||||||
|
if(!get_post($dst)){ echo " dst $dst NO EXISTE\n"; continue; }
|
||||||
|
wp_update_post(["ID"=>$dst,"post_title"=>$it['title'],"post_content"=>$it['content']]);
|
||||||
|
pll_set_post_language($dst,'fr');
|
||||||
|
echo " overwrite $dst -> fr (".mb_substr($it['title'],0,30).")\n";
|
||||||
|
}
|
||||||
|
// 3) catalan 13339: enlazar grupo con ES 13338
|
||||||
|
if(get_post(13338) && get_post(13339)){
|
||||||
|
pll_set_post_language(13338,'es');
|
||||||
|
pll_save_post_translations(["es"=>13338,"fr"=>13339]);
|
||||||
|
echo " grupo 13339: ".json_encode(pll_get_post_translations(13339))."\n";
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
$c=get_post(19112)->post_content;
|
||||||
|
$map=[
|
||||||
|
"el-bautismo-de-jesus-jesus-hijo-del-padre"=>17918, // Galarreta homenaje
|
||||||
|
"jean-baptiste-et-son-mouton"=>17990, // Mardones homenaje
|
||||||
|
"nuestra-vida-sea-servicio"=>23519, // Mari Patxi fallecimiento
|
||||||
|
];
|
||||||
|
$n=0;
|
||||||
|
foreach($map as $slug=>$id){
|
||||||
|
$url=get_permalink($id);
|
||||||
|
$pat='#https?://[^"]*?/'.preg_quote($slug,'#').'/#';
|
||||||
|
$c2=preg_replace($pat,$url,$c,1,$cnt);
|
||||||
|
if($cnt>0){ $c=$c2; $n++; echo " OK $slug -> $url\n"; } else echo " NO ENCONTRADO $slug\n";
|
||||||
|
}
|
||||||
|
if($n) wp_update_post(["ID"=>19112,"post_content"=>$c]);
|
||||||
|
// verificar 8 enlaces
|
||||||
|
preg_match_all('#href="([^"]+)"#',get_post(19112)->post_content,$m);
|
||||||
|
$ok=0;$bad=0;
|
||||||
|
foreach($m[1] as $h){ if(preg_match('#/([^/"]+)/?$#',$h,$sm)){ $p=get_posts(["name"=>$sm[1],"post_status"=>"publish","posts_per_page"=>1,"suppress_filters"=>true]); if($p)$ok++; else {$bad++; echo " ROTO $h\n";} } }
|
||||||
|
echo "repuntados=$n | enlaces in-memoriam OK=$ok bad=$bad\n";
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import re, json, html, glob
|
||||||
|
|
||||||
|
IMG_BASE = "/fea/wp-content/uploads/anterior-mardones/"
|
||||||
|
|
||||||
|
# filename -> (titulo, autor_wp, [imagenes])
|
||||||
|
ORAR_AUTHOR = 710 # Mardones
|
||||||
|
HOM_AUTHOR = 1 # genérico (como página 17990)
|
||||||
|
META = {
|
||||||
|
"orar-1-dejarmequerer":("Dejarme querer",ORAR_AUTHOR,[]),
|
||||||
|
"orar-2-escuchar":("Escuchar",ORAR_AUTHOR,[]),
|
||||||
|
"orar-3-estar":("Estar",ORAR_AUTHOR,[]),
|
||||||
|
"orar-4-hacersitioadios":("Hacer sitio a Dios",ORAR_AUTHOR,[]),
|
||||||
|
"orar-5-laoracionadulta":("La oración adulta",ORAR_AUTHOR,[]),
|
||||||
|
"orar-6-orarconevangelio":("Orar con el evangelio",ORAR_AUTHOR,[]),
|
||||||
|
"orar-7-orarrepitiendo":("Orar repitiendo una palabra o frase breve",ORAR_AUTHOR,[]),
|
||||||
|
"orar-8-tuestasdentro":("Tú estás dentro",ORAR_AUTHOR,[]),
|
||||||
|
"homenaje-segundo-aniversario":("En el segundo aniversario",HOM_AUTHOR,[]),
|
||||||
|
"homenaje-14noviembre":("14 de noviembre de 2007",HOM_AUTHOR,[]),
|
||||||
|
"homenaje-PRIMERaniversario":("Primer aniversario",HOM_AUTHOR,[]),
|
||||||
|
"homenaje-aniversario":("Programa en el aniversario",HOM_AUTHOR,["homenaje-charla-mejico.jpg"]),
|
||||||
|
"homenaje-rostrointerior":("Rostro interior de José María Mardones",HOM_AUTHOR,[]),
|
||||||
|
"homenaje-cartaReyes":("Carta a un Maestro",HOM_AUTHOR,[]),
|
||||||
|
"homenaje-9":("Semblanza",HOM_AUTHOR,[]),
|
||||||
|
"homenaje-8":("Álbum de fotos de José María Mardones",HOM_AUTHOR,
|
||||||
|
["homenaje-album-%d.jpg"%i for i in range(1,8)]),
|
||||||
|
"homenaje-1":("Funeral del sábado 24 de junio",HOM_AUTHOR,[]),
|
||||||
|
"homenaje-2":("Funeral del jueves 29 de junio",HOM_AUTHOR,[]),
|
||||||
|
"homenaje-5":("Obituario en El Mundo",HOM_AUTHOR,[]),
|
||||||
|
"homenaje-6":("Necrológicas en El País",HOM_AUTHOR,[]),
|
||||||
|
"homenaje-4":("Salmo a la Encarnación de Dios",HOM_AUTHOR,[]),
|
||||||
|
"homenaje-3":("Conferencia: ¿Por qué preocuparse por los demás? Ética y convivencia",HOM_AUTHOR,[]),
|
||||||
|
"homenaje-7":("Currículo de José María Mardones",HOM_AUTHOR,[]),
|
||||||
|
}
|
||||||
|
|
||||||
|
BOILER = re.compile(r'cristianos siglo veintiuno|^I N M E M O R I A M$|^HOMENAJE$|^homenaje a JOSE|APRENDIENDO A ORAR cristianos', re.I)
|
||||||
|
|
||||||
|
def extract(fn):
|
||||||
|
raw=open(fn,'rb').read().decode('windows-1252',errors='replace')
|
||||||
|
raw=re.sub(r'(?is)<(script|style).*?</\1>','',raw)
|
||||||
|
raw=re.sub(r'(?is)<!--.*?-->','',raw)
|
||||||
|
blocks=[]
|
||||||
|
for p in re.findall(r'(?is)<p\b[^>]*>(.*?)</p>',raw):
|
||||||
|
# preservar enlaces externos
|
||||||
|
links=re.findall(r'(?is)<a\s+[^>]*href="(https?://[^"]+)"[^>]*>(.*?)</a>',p)
|
||||||
|
t=re.sub(r'(?is)<[^>]+>','',p)
|
||||||
|
t=html.unescape(t).replace('\xa0',' ')
|
||||||
|
t=re.sub(r'\s+',' ',t).strip()
|
||||||
|
if len(t)<3:
|
||||||
|
# párrafo solo-enlace
|
||||||
|
if links:
|
||||||
|
for href,txt in links:
|
||||||
|
txt=re.sub(r'<[^>]+>','',txt).strip() or href
|
||||||
|
blocks.append(('link',href,txt))
|
||||||
|
continue
|
||||||
|
if BOILER.search(t):
|
||||||
|
continue
|
||||||
|
# ¿el párrafo contiene un enlace externo embebido? añadir como link extra
|
||||||
|
blocks.append(('p',t,links))
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
def build_html(blocks, imgs):
|
||||||
|
out=[]
|
||||||
|
for b in blocks:
|
||||||
|
if b[0]=='p':
|
||||||
|
if re.match(r'^https?://\S+$', b[1]):
|
||||||
|
out.append('<p><a href="%s" target="_blank" rel="noopener">%s</a></p>'%(html.escape(b[1]),html.escape(b[1])))
|
||||||
|
continue
|
||||||
|
txt=html.escape(b[1])
|
||||||
|
# re-incrustar enlaces externos que estaban en el párrafo
|
||||||
|
for href,atxt in b[2]:
|
||||||
|
atxt_clean=html.escape(re.sub(r'<[^>]+>','',atxt).strip() or href)
|
||||||
|
# no siempre está el texto en txt; añadimos al final si no
|
||||||
|
out.append("<p>%s</p>"%txt)
|
||||||
|
elif b[0]=='link':
|
||||||
|
out.append('<p><a href="%s" target="_blank" rel="noopener">%s</a></p>'%(html.escape(b[1]),html.escape(b[2])))
|
||||||
|
# imágenes al final
|
||||||
|
for im in imgs:
|
||||||
|
out.append('<p><img src="%s%s" alt="José María Mardones" style="max-width:100%%;height:auto"/></p>'%(IMG_BASE,im))
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
manifest=[]
|
||||||
|
for fn in sorted(glob.glob("*.htm")):
|
||||||
|
key=fn[:-4]
|
||||||
|
if key not in META:
|
||||||
|
print("SIN META:",key); continue
|
||||||
|
title,author,imgs=META[key]
|
||||||
|
blocks=extract(fn)
|
||||||
|
content=build_html(blocks,imgs)
|
||||||
|
manifest.append({"file":key,"title":title,"author":author,"content":content,"nparas":len([b for b in blocks if b[0]=='p']),"nimgs":len(imgs)})
|
||||||
|
|
||||||
|
json.dump(manifest,open("manifest.json","w"),ensure_ascii=False,indent=1)
|
||||||
|
print("manifest:",len(manifest),"posts")
|
||||||
|
for m in manifest: print(f" {m['file']:34} '{m['title'][:35]}' autor={m['author']} paras={m['nparas']} imgs={m['nimgs']} len={len(m['content'])}")
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
$man=json_decode(file_get_contents('/tmp/manifest.json'),true);
|
||||||
|
$map=[]; $created=0; $skipped=0;
|
||||||
|
foreach($man as $m){
|
||||||
|
// idempotencia
|
||||||
|
$ex=get_posts(["meta_key"=>"_mardones_src","meta_value"=>$m['file'],"post_type"=>"post","post_status"=>"any","posts_per_page"=>1,"suppress_filters"=>true]);
|
||||||
|
if($ex){ $map[$m['file']]=$ex[0]->ID; $skipped++; continue; }
|
||||||
|
$id=wp_insert_post([
|
||||||
|
"post_type"=>"post","post_status"=>"publish","post_author"=>(int)$m['author'],
|
||||||
|
"post_title"=>$m['title'],"post_content"=>$m['content'],
|
||||||
|
"to_ping"=>"","pinged"=>"","post_content_filtered"=>"",
|
||||||
|
],true);
|
||||||
|
if(is_wp_error($id)){ echo "ERROR ".$m['file'].": ".$id->get_error_message()."\n"; continue; }
|
||||||
|
update_post_meta($id,"_mardones_src",$m['file']);
|
||||||
|
wp_set_post_categories($id,[38]);
|
||||||
|
if(function_exists('pll_set_post_language')) pll_set_post_language($id,'es');
|
||||||
|
$map[$m['file']]=$id; $created++;
|
||||||
|
}
|
||||||
|
file_put_contents('/tmp/mardones_map.json',json_encode($map));
|
||||||
|
echo "creados=$created skipped=$skipped total=".count($map)."\n";
|
||||||
|
foreach($map as $f=>$id) echo " $f -> $id\n";
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
// 1) fecha: alinear con el homenaje 17990 (2012-10-10)
|
||||||
|
$date="2012-10-10 19:26:00"; $gmt=get_gmt_from_date($date);
|
||||||
|
$ids=get_posts(["meta_key"=>"_mardones_src","post_type"=>"post","posts_per_page"=>-1,"fields"=>"ids","post_status"=>"any","suppress_filters"=>true]);
|
||||||
|
$nd=0;
|
||||||
|
foreach($ids as $id){ wp_update_post(["ID"=>$id,"post_date"=>$date,"post_date_gmt"=>$gmt]); $nd++; }
|
||||||
|
echo "fechas actualizadas: $nd posts -> $date\n";
|
||||||
|
// 2) quitar target=_blank (y rel) de los enlaces internos del homenaje 17990
|
||||||
|
$c=get_post(17990)->post_content;
|
||||||
|
$before=substr_count(strtolower($c),'target="_blank"');
|
||||||
|
$c=str_replace([' target="_blank"',' rel="noopener"',' rel="noreferrer"'],'',$c);
|
||||||
|
wp_update_post(["ID"=>17990,"post_content"=>$c]);
|
||||||
|
$after=substr_count(strtolower(get_post(17990)->post_content),'target="_blank"');
|
||||||
|
echo "target=_blank en 17990: antes=$before despues=$after\n";
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
$man=json_decode(file_get_contents('/entrada/manifest_prod.json'),true);
|
||||||
|
$map=[]; $created=0;$skip=0;
|
||||||
|
foreach($man as $m){
|
||||||
|
$ex=get_posts(["meta_key"=>"_mardones_src","meta_value"=>$m['file'],"post_type"=>"post","post_status"=>"any","posts_per_page"=>1,"suppress_filters"=>true]);
|
||||||
|
if($ex){ $map[$m['file']]=$ex[0]->ID; $skip++; continue; }
|
||||||
|
$author=(int)$m['author']; if($author && !get_userdata($author)) $author=1;
|
||||||
|
$id=wp_insert_post(["post_type"=>"post","post_status"=>"publish","post_author"=>$author,
|
||||||
|
"post_title"=>$m['title'],"post_content"=>$m['content'],"to_ping"=>"","pinged"=>"","post_content_filtered"=>""],true);
|
||||||
|
if(is_wp_error($id)){ echo "ERR ".$m['file'].": ".$id->get_error_message()."\n"; continue; }
|
||||||
|
update_post_meta($id,"_mardones_src",$m['file']);
|
||||||
|
wp_set_post_categories($id,[38]);
|
||||||
|
if(function_exists('pll_set_post_language')) pll_set_post_language($id,'es');
|
||||||
|
$map[$m['file']]=$id; $created++;
|
||||||
|
}
|
||||||
|
echo "creados=$created skip=$skip total=".count($map)."\n";
|
||||||
|
// repuntar 17990
|
||||||
|
$c=get_post(17990)->post_content; $n=0;
|
||||||
|
foreach($map as $file=>$id){
|
||||||
|
$url=get_permalink($id);
|
||||||
|
$c2=preg_replace('#href="anterior/'.preg_quote($file,'#').'\.htm"#i','href="'.$url.'"',$c,1,$cnt);
|
||||||
|
if($cnt>0){ $c=$c2; $n++; }
|
||||||
|
}
|
||||||
|
wp_update_post(["ID"=>17990,"post_content"=>$c]);
|
||||||
|
$rest=preg_match_all('#anterior/[\w-]+\.htm#i',get_post(17990)->post_content,$mm);
|
||||||
|
echo "17990 repuntados=$n/23 | anterior/*.htm restantes=$rest\n";
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
$map=json_decode(file_get_contents('/tmp/mardones_map.json'),true);
|
||||||
|
$c=get_post(17990)->post_content; $n=0; $miss=[];
|
||||||
|
foreach($map as $file=>$id){
|
||||||
|
$url=get_permalink($id);
|
||||||
|
// href="anterior/FILE.htm" (puede tener mayúsculas y espacios delante)
|
||||||
|
$pat='#href="anterior/'.preg_quote($file,'#').'\.htm"#i';
|
||||||
|
$c2=preg_replace($pat,'href="'.$url.'"',$c,1,$cnt);
|
||||||
|
if($cnt>0){ $c=$c2; $n++; } else $miss[]=$file;
|
||||||
|
}
|
||||||
|
wp_update_post(["ID"=>17990,"post_content"=>$c]);
|
||||||
|
echo "repuntados=$n/23\n";
|
||||||
|
if($miss) echo "NO ENCONTRADOS: ".implode(", ",$miss)."\n";
|
||||||
|
// verificar que no quedan anterior/*.htm
|
||||||
|
$rest=preg_match_all('#anterior/[\w-]+\.htm#i',get_post(17990)->post_content,$mm);
|
||||||
|
echo "enlaces anterior/*.htm restantes: $rest\n";
|
||||||
|
if($rest) foreach($mm[0] as $x) echo " $x\n";
|
||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
+31
-7
@@ -45,6 +45,22 @@ def creds():
|
|||||||
KEY, GID = creds()
|
KEY, GID = creds()
|
||||||
H_JSON = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
|
H_JSON = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
# Voz clonada por autor (WP user_id -> voice_id). Issue #152: solo se aplica a
|
||||||
|
# los artículos de ese autor; el resto sigue con la voz por defecto (Nico).
|
||||||
|
AUTHOR_VOICES = {
|
||||||
|
382: "FrayMarcosFeadulta2026", # Fray Marcos
|
||||||
|
383: "PagolaFeadulta2026", # José Antonio Pagola
|
||||||
|
774: "SicreFeadulta2026", # José Luis Sicre
|
||||||
|
386: "ArregiFeadulta2026", # José Arregi
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def voice_for_author(author_id, default_voice):
|
||||||
|
try:
|
||||||
|
return AUTHOR_VOICES.get(int(author_id), default_voice)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default_voice
|
||||||
|
|
||||||
|
|
||||||
def _q(url):
|
def _q(url):
|
||||||
return f"{url}?GroupId={GID}" if GID else url
|
return f"{url}?GroupId={GID}" if GID else url
|
||||||
@@ -83,7 +99,7 @@ def get_post_text(pid):
|
|||||||
raw = html.unescape(raw)
|
raw = html.unescape(raw)
|
||||||
paras = [re.sub(r"\s+", " ", p).strip() for p in raw.split("\n") if len(p.strip()) > 1]
|
paras = [re.sub(r"\s+", " ", p).strip() for p in raw.split("\n") if len(p.strip()) > 1]
|
||||||
paras = trim_after_author_signature(paras)
|
paras = trim_after_author_signature(paras)
|
||||||
return d["title"], "\n\n".join(paras)
|
return d["title"], "\n\n".join(paras), d.get("author")
|
||||||
|
|
||||||
|
|
||||||
def is_author_signature(text):
|
def is_author_signature(text):
|
||||||
@@ -114,12 +130,20 @@ def is_author_signature(text):
|
|||||||
|
|
||||||
|
|
||||||
def trim_after_author_signature(paras):
|
def trim_after_author_signature(paras):
|
||||||
out = []
|
"""Corta tras la firma final del autor, buscando desde el final hacia
|
||||||
|
atrás para no confundirla con encabezados iniciales (p.ej. "CORPUS (A)",
|
||||||
|
"DOMINGO XI (A)") que también matchean la heurística de is_author_signature
|
||||||
|
pero aparecen como primer párrafo, no como firma. Solo cuenta como firma
|
||||||
|
si hay al menos 200 caracteres de contenido real antes de ella."""
|
||||||
|
prefix_len = 0
|
||||||
|
prefix_lens = []
|
||||||
for p in paras:
|
for p in paras:
|
||||||
out.append(p)
|
prefix_lens.append(prefix_len)
|
||||||
if is_author_signature(p):
|
prefix_len += len(p)
|
||||||
break
|
for i in range(len(paras) - 1, -1, -1):
|
||||||
return out
|
if prefix_lens[i] > 200 and is_author_signature(paras[i]):
|
||||||
|
return paras[: i + 1]
|
||||||
|
return paras
|
||||||
|
|
||||||
|
|
||||||
def _sent_pause(n_words, short, long_):
|
def _sent_pause(n_words, short, long_):
|
||||||
@@ -413,7 +437,7 @@ def main():
|
|||||||
elif cmd == "carta":
|
elif cmd == "carta":
|
||||||
pid, voice_id = sys.argv[2], sys.argv[3]
|
pid, voice_id = sys.argv[2], sys.argv[3]
|
||||||
model = sys.argv[4] if len(sys.argv) > 4 else "speech-2.8-turbo"
|
model = sys.argv[4] if len(sys.argv) > 4 else "speech-2.8-turbo"
|
||||||
title, text = get_post_text(int(pid))
|
title, text, _author = get_post_text(int(pid))
|
||||||
name = sys.argv[5] if len(sys.argv) > 5 else f"carta-minimax-{pid}-{model.split('-')[-1]}"
|
name = sys.argv[5] if len(sys.argv) > 5 else f"carta-minimax-{pid}-{model.split('-')[-1]}"
|
||||||
text = add_pauses(text)
|
text = add_pauses(text)
|
||||||
print(f"Post #{pid}: «{title}» ({len(text)} car con pausas)")
|
print(f"Post #{pid}: «{title}» ({len(text)} car con pausas)")
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
sync_audio_to_prod.py — Sube a PROD los mp3 de TTS ya generados/enlazados en
|
||||||
|
local (fea_audio_done=1) y fija el meta fea_audio_url en prod.
|
||||||
|
|
||||||
|
Prod vive en Hetzner (Coolify/Docker) desde el cutover de agosto 2026. Subida
|
||||||
|
del binario por stdin de ssh ("cat > ruta" dentro del contenedor vía
|
||||||
|
`docker exec -i` si FEA_PROD_DOCKER_CONTAINER está definido).
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
python3 sync_audio_to_prod.py --carta 54254 # sincroniza toda la cola de la carta
|
||||||
|
python3 sync_audio_to_prod.py --ids 912,919,3001 # ids concretos
|
||||||
|
python3 sync_audio_to_prod.py --carta 54254 --dry-run # solo plan, no toca prod
|
||||||
|
|
||||||
|
Rollback (despublica en prod lo que este script publicó):
|
||||||
|
python3 sync_audio_to_prod.py --rollback --carta 54254
|
||||||
|
python3 sync_audio_to_prod.py --rollback --ids 912,919
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
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_SSH_HOST", "")
|
||||||
|
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
|
||||||
|
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
|
||||||
|
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
|
||||||
|
# define, el helper y el mp3 se suben/ejecutan dentro del contenedor.
|
||||||
|
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
|
||||||
|
PROD_HELPER = "/tmp/fea_post_io.php"
|
||||||
|
PROD_UPLOADS_TTS = os.environ.get("FEA_PROD_UPLOADS_TTS", "/var/www/html/wp-content/uploads/tts")
|
||||||
|
|
||||||
|
HELPER_SRC = Path(__file__).resolve().parent / "fea_post_io.php"
|
||||||
|
LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
|
||||||
|
|
||||||
|
LOG_FILE = Path(os.environ.get(
|
||||||
|
"FEA_AUDIO_SYNC_LOG",
|
||||||
|
str(Path(__file__).resolve().parent.parent / "logs/feadulta-audio-sync.log"),
|
||||||
|
))
|
||||||
|
STATE_FILE = Path(os.environ.get(
|
||||||
|
"FEA_AUDIO_SYNC_STATE",
|
||||||
|
str(Path(__file__).resolve().parent.parent / "logs/feadulta-audio-sync-state.json"),
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg: str) -> None:
|
||||||
|
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
|
||||||
|
print(line, flush=True)
|
||||||
|
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with LOG_FILE.open("a", encoding="utf-8") as f:
|
||||||
|
f.write(line + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def sh(cmd: list[str], *, input_bytes: bytes | None = None, timeout: int = 120) -> str:
|
||||||
|
r = subprocess.run(cmd, input=input_bytes, capture_output=True, timeout=timeout)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(f"cmd falló ({r.returncode}): {' '.join(cmd[:4])}…\n{r.stderr.decode(errors='replace')[:400]}")
|
||||||
|
return r.stdout.decode(errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Local ────────────────────────────────────────────────────────────────────
|
||||||
|
def local_meta(post_id: int, key: str) -> str:
|
||||||
|
r = subprocess.run(
|
||||||
|
["docker", "exec", WP_CONTAINER, "php", "/tmp/fea_post_io.php", "getmeta", str(post_id), key],
|
||||||
|
capture_output=True, text=True, timeout=60,
|
||||||
|
)
|
||||||
|
return r.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
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;")
|
||||||
|
r = subprocess.run(
|
||||||
|
["docker", "exec", DB_CONTAINER, "mysql", f"-u{DB_USER}", f"-p{DB_PASS}",
|
||||||
|
DB_NAME, "-N", "-e", q],
|
||||||
|
capture_output=True, text=True, timeout=60,
|
||||||
|
)
|
||||||
|
return [int(x) for x in r.stdout.split() if x.isdigit()]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Prod (Hetzner/Docker: todo por ssh, mp3 por stdin de "cat") ────────────────
|
||||||
|
def _ssh_text(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
||||||
|
# El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
|
||||||
|
# solo se antepone si hay contraseña configurada (servidor viejo CDMON).
|
||||||
|
if PROD_PASS:
|
||||||
|
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
|
else:
|
||||||
|
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
|
r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}…\n{r.stderr.strip()[:400]}")
|
||||||
|
return r.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def sh_quote(s: str) -> str:
|
||||||
|
return "'" + s.replace("'", "'\\''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_wrap(inner_cmd: str) -> str:
|
||||||
|
"""Envuelve un comando para que corra dentro del contenedor Docker de prod
|
||||||
|
si FEA_PROD_DOCKER_CONTAINER está definido; si no, corre en el host tal cual.
|
||||||
|
|
||||||
|
El wrapping (incluidas redirecciones como `< ruta`) debe quedar DENTRO de la
|
||||||
|
shell del contenedor (`sh -c '...'`): `docker exec CONTENEDOR cmd < ruta`
|
||||||
|
resuelve esa redirección en el filesystem del HOST, no en el contenedor.
|
||||||
|
"""
|
||||||
|
if PROD_DOCKER_CONTAINER:
|
||||||
|
return f"docker exec -i {PROD_DOCKER_CONTAINER} sh -c {sh_quote(inner_cmd)}"
|
||||||
|
return inner_cmd
|
||||||
|
|
||||||
|
|
||||||
|
def _ssh_upload_bytes(data: bytes, remote_path: str, *, timeout: int = 180) -> None:
|
||||||
|
remote_cmd = _remote_wrap(f"cat > {remote_path}")
|
||||||
|
if PROD_PASS:
|
||||||
|
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
|
else:
|
||||||
|
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
|
sh(cmd, input_bytes=data, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
_prod_helper_ready = False
|
||||||
|
|
||||||
|
|
||||||
|
def prod_helper(subcmd: str, *args: str) -> str:
|
||||||
|
global _prod_helper_ready
|
||||||
|
if not _prod_helper_ready:
|
||||||
|
_ssh_upload_bytes(HELPER_SRC.read_bytes(), PROD_HELPER)
|
||||||
|
_prod_helper_ready = True
|
||||||
|
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
||||||
|
return _ssh_text(_remote_wrap(inner), timeout=60)
|
||||||
|
|
||||||
|
|
||||||
|
def prod_upload_mp3(post_id: int) -> None:
|
||||||
|
src = LOCAL_TTS_DIR / f"{post_id}.mp3"
|
||||||
|
data = src.read_bytes()
|
||||||
|
remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3"
|
||||||
|
_ssh_upload_bytes(data, remote_path)
|
||||||
|
# Verificación de tamaño: no fiarse ciegamente del rc=0 de ssh. `wc -c` debe
|
||||||
|
# correr (y resolver la redirección) DENTRO del contenedor — ver _remote_wrap.
|
||||||
|
remote_size = int(_ssh_text(_remote_wrap(f"wc -c < {remote_path}")).strip())
|
||||||
|
if remote_size != len(data):
|
||||||
|
raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}")
|
||||||
|
|
||||||
|
|
||||||
|
def prod_remove_mp3(post_id: int) -> None:
|
||||||
|
_ssh_text(_remote_wrap(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Estado ───────────────────────────────────────────────────────────────────
|
||||||
|
def load_state() -> dict:
|
||||||
|
if STATE_FILE.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(STATE_FILE.read_text())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return {"synced": []}
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(state: dict) -> None:
|
||||||
|
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sync ─────────────────────────────────────────────────────────────────────
|
||||||
|
def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
|
||||||
|
if local_meta(post_id, "fea_audio_done") != "1":
|
||||||
|
return "sin-audio-local"
|
||||||
|
if not (LOCAL_TTS_DIR / f"{post_id}.mp3").exists():
|
||||||
|
return "mp3-local-ausente"
|
||||||
|
if dry_run:
|
||||||
|
return "PLAN: subiría mp3 + setaudio"
|
||||||
|
|
||||||
|
voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
|
||||||
|
prod_upload_mp3(post_id)
|
||||||
|
prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice)
|
||||||
|
if post_id not in state["synced"]:
|
||||||
|
state["synced"].append(post_id)
|
||||||
|
save_state(state)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def rollback_one(post_id: int, state: dict) -> str:
|
||||||
|
prod_helper("unsetaudio", str(post_id))
|
||||||
|
prod_remove_mp3(post_id)
|
||||||
|
if post_id in state["synced"]:
|
||||||
|
state["synced"].remove(post_id)
|
||||||
|
save_state(state)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="Sincroniza audio TTS local→prod (o lo despublica con --rollback).")
|
||||||
|
ap.add_argument("--carta", type=int, default=0, help="WP post ID de la carta local; sincroniza toda su cola.")
|
||||||
|
ap.add_argument("--ids", default="", help="Lista CSV de post IDs concretos.")
|
||||||
|
ap.add_argument("--rollback", action="store_true", help="Despublica en prod en vez de publicar.")
|
||||||
|
ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no toca prod.")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.ids:
|
||||||
|
ids = [int(x) for x in args.ids.split(",") if x.strip().isdigit()]
|
||||||
|
elif args.carta:
|
||||||
|
ids = carta_article_ids(args.carta)
|
||||||
|
else:
|
||||||
|
ap.error("hace falta --carta o --ids")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
state = load_state()
|
||||||
|
mode = "ROLLBACK" if args.rollback else "SYNC"
|
||||||
|
log(f"=== INICIO {mode} audio→prod. {len(ids)} posts candidatos: {ids} ===")
|
||||||
|
|
||||||
|
ok = skip = err = 0
|
||||||
|
for pid in ids:
|
||||||
|
try:
|
||||||
|
if args.rollback:
|
||||||
|
if pid not in state["synced"] and not args.ids:
|
||||||
|
res = "no-estaba-sincronizado"
|
||||||
|
skip += 1
|
||||||
|
else:
|
||||||
|
res = rollback_one(pid, state)
|
||||||
|
ok += 1
|
||||||
|
else:
|
||||||
|
res = sync_one(pid, state, dry_run=args.dry_run)
|
||||||
|
if res == "ok" or res.startswith("PLAN"):
|
||||||
|
ok += 1
|
||||||
|
else:
|
||||||
|
skip += 1
|
||||||
|
log(f" #{pid}: {res}")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
err += 1
|
||||||
|
log(f" #{pid}: ERROR {exc}")
|
||||||
|
|
||||||
|
log(f"=== FIN {mode}. ok={ok} skip={skip} error={err}. Estado: {STATE_FILE} ===")
|
||||||
|
return 1 if err else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -44,7 +44,10 @@ WP_CONTAINER = os.environ.get("FEA_WP_CONTAINER", "wordpress-web")
|
|||||||
|
|
||||||
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
|
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
|
||||||
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
|
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
|
||||||
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
|
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
|
||||||
|
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
|
||||||
|
# define, el helper se sube y se ejecuta dentro del contenedor.
|
||||||
|
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
|
||||||
PROD_HELPER = "/tmp/fea_translate_helper.php"
|
PROD_HELPER = "/tmp/fea_translate_helper.php"
|
||||||
|
|
||||||
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
|
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
|
||||||
@@ -78,18 +81,41 @@ _prod_ready = False
|
|||||||
|
|
||||||
|
|
||||||
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
||||||
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
# El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
|
||||||
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
# solo se antepone si hay contraseña configurada (servidor viejo CDMON).
|
||||||
|
if PROD_PASS:
|
||||||
|
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
|
else:
|
||||||
|
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
return sh(cmd, stdin=stdin, timeout=timeout)
|
return sh(cmd, stdin=stdin, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def sh_quote(s: str) -> str:
|
||||||
|
return "'" + s.replace("'", "'\\''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_wrap(inner_cmd: str) -> str:
|
||||||
|
"""Envuelve un comando para que corra dentro del contenedor Docker de prod
|
||||||
|
si FEA_PROD_DOCKER_CONTAINER está definido; si no, corre en el host tal cual.
|
||||||
|
|
||||||
|
El wrapping (incluidas redirecciones como `< ruta`) debe quedar DENTRO de la
|
||||||
|
shell del contenedor (`sh -c '...'`): `docker exec CONTENEDOR cmd < ruta`
|
||||||
|
resuelve esa redirección en el filesystem del HOST, no en el contenedor.
|
||||||
|
"""
|
||||||
|
if PROD_DOCKER_CONTAINER:
|
||||||
|
return f"docker exec -i {PROD_DOCKER_CONTAINER} sh -c {sh_quote(inner_cmd)}"
|
||||||
|
return inner_cmd
|
||||||
|
|
||||||
|
|
||||||
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
|
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
|
||||||
global _prod_ready
|
global _prod_ready
|
||||||
if not _prod_ready:
|
if not _prod_ready:
|
||||||
_ssh(f"cat > {PROD_HELPER}", stdin=HELPER_SRC.read_text(encoding="utf-8"))
|
_ssh(_remote_wrap(f"cat > {PROD_HELPER}"), stdin=HELPER_SRC.read_text(encoding="utf-8"))
|
||||||
_prod_ready = True
|
_prod_ready = True
|
||||||
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
||||||
return _ssh(inner, stdin=stdin, timeout=180)
|
return _ssh(_remote_wrap(inner), stdin=stdin, timeout=180)
|
||||||
|
|
||||||
|
|
||||||
def prod_read_full(post_id: int) -> dict:
|
def prod_read_full(post_id: int) -> dict:
|
||||||
@@ -234,9 +260,10 @@ def main() -> int:
|
|||||||
ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no escribe en local.")
|
ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no escribe en local.")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
if not PROD_HOST or not PROD_PASS:
|
if not PROD_HOST:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"Faltan FEA_PROD_SSH_HOST / FEA_PROD_SSH_PASS en el entorno.\n"
|
"Falta FEA_PROD_SSH_HOST en el entorno (FEA_PROD_SSH_PASS es opcional: "
|
||||||
|
"el Hetzner nuevo usa auth por clave).\n"
|
||||||
"Antes de ejecutar: source ~/.hermes/profiles/feadulta/.env"
|
"Antes de ejecutar: source ~/.hermes/profiles/feadulta/.env"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,12 @@ DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
|
|||||||
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
|
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
|
||||||
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
||||||
|
|
||||||
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
|
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
|
||||||
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
|
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
|
||||||
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-nuevo/wp-load.php")
|
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
|
||||||
|
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
|
||||||
|
# define, el helper se sube y se ejecuta dentro del contenedor.
|
||||||
|
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
|
||||||
PROD_HELPER = "/tmp/fea_translate_helper.php"
|
PROD_HELPER = "/tmp/fea_translate_helper.php"
|
||||||
|
|
||||||
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
|
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
|
||||||
@@ -45,7 +48,7 @@ STATUS = os.environ.get("FEA_SYNC_STATUS", "draft")
|
|||||||
# instalación cuelga de la raíz. Se reescriben al desplegar para no dejar enlaces
|
# instalación cuelga de la raíz. Se reescriben al desplegar para no dejar enlaces
|
||||||
# rotos (Tailscale es inaccesible para los visitantes).
|
# rotos (Tailscale es inaccesible para los visitantes).
|
||||||
LOCAL_BASE = os.environ.get("FEA_LOCAL_BASE", "https://farmer.taild3aaf6.ts.net/fea")
|
LOCAL_BASE = os.environ.get("FEA_LOCAL_BASE", "https://farmer.taild3aaf6.ts.net/fea")
|
||||||
PROD_BASE = os.environ.get("FEA_PROD_BASE", "https://wp-nuevo.feadulta.com")
|
PROD_BASE = os.environ.get("FEA_PROD_BASE", "https://www.feadulta.com")
|
||||||
|
|
||||||
|
|
||||||
def log(msg: str) -> None:
|
def log(msg: str) -> None:
|
||||||
@@ -76,7 +79,7 @@ def parse_csv_ints(raw: str) -> list[int]:
|
|||||||
def localize_urls(text: str | None) -> tuple[str, int]:
|
def localize_urls(text: str | None) -> tuple[str, int]:
|
||||||
"""Reescribe URLs absolutas local→prod en el contenido antes de subirlo.
|
"""Reescribe URLs absolutas local→prod en el contenido antes de subirlo.
|
||||||
|
|
||||||
Equivale al search-replace `farmer.taild3aaf6.ts.net/fea` → `wp-nuevo.feadulta.com`
|
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).
|
pero aplicado en origen, así el contenido llega ya correcto a prod (issue #91).
|
||||||
Devuelve (texto, nº de reemplazos).
|
Devuelve (texto, nº de reemplazos).
|
||||||
"""
|
"""
|
||||||
@@ -159,18 +162,41 @@ _prod_ready = False
|
|||||||
|
|
||||||
|
|
||||||
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
||||||
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
# El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
|
||||||
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
# solo se antepone si hay contraseña configurada (servidor viejo CDMON).
|
||||||
|
if PROD_PASS:
|
||||||
|
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
|
else:
|
||||||
|
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
return sh(cmd, stdin=stdin, timeout=timeout)
|
return sh(cmd, stdin=stdin, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_wrap(inner_cmd: str) -> str:
|
||||||
|
"""Envuelve un comando para que corra dentro del contenedor Docker de prod
|
||||||
|
si FEA_PROD_DOCKER_CONTAINER está definido; si no, corre en el host tal cual.
|
||||||
|
|
||||||
|
El wrapping (incluidas redirecciones como `< ruta`) debe quedar DENTRO de la
|
||||||
|
shell del contenedor (`sh -c '...'`): `docker exec CONTENEDOR cmd < ruta`
|
||||||
|
resuelve esa redirección en el filesystem del HOST, no en el contenedor.
|
||||||
|
"""
|
||||||
|
if PROD_DOCKER_CONTAINER:
|
||||||
|
return f"docker exec -i {PROD_DOCKER_CONTAINER} sh -c {sh_quote(inner_cmd)}"
|
||||||
|
return inner_cmd
|
||||||
|
|
||||||
|
|
||||||
|
def sh_quote(s: str) -> str:
|
||||||
|
return "'" + s.replace("'", "'\\''") + "'"
|
||||||
|
|
||||||
|
|
||||||
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
|
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
|
||||||
global _prod_ready
|
global _prod_ready
|
||||||
if not _prod_ready:
|
if not _prod_ready:
|
||||||
_ssh(f"cat > {PROD_HELPER}", stdin=HELPER_SRC.read_text(encoding="utf-8"))
|
_ssh(_remote_wrap(f"cat > {PROD_HELPER}"), stdin=HELPER_SRC.read_text(encoding="utf-8"))
|
||||||
_prod_ready = True
|
_prod_ready = True
|
||||||
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
||||||
return _ssh(inner, stdin=stdin, timeout=180)
|
return _ssh(_remote_wrap(inner), stdin=stdin, timeout=180)
|
||||||
|
|
||||||
|
|
||||||
def prod_create(origin: int, lang: str, title: str, content: str) -> int:
|
def prod_create(origin: int, lang: str, title: str, content: str) -> int:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+12
-8
@@ -1,8 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Orquestador nocturno: locuta cartas ES del gap con MiniMax (voz Nico), una a
|
"""Orquestador nocturno: locuta cartas ES del gap con MiniMax, una a una,
|
||||||
una, repartido en el tiempo. Reanudable (meta fea_audio_done) y con freno ante
|
repartido en el tiempo. Voz por defecto Nico; los artículos de autores con voz
|
||||||
la cuota (para tras N fallos seguidos). NO toca el front; solo genera el mp3 y
|
clonada (AUTHOR_VOICES en minimax_tts.py, ej. Fray Marcos) usan la suya propia.
|
||||||
asocia la URL al post (meta fea_audio_url).
|
Reanudable (meta fea_audio_done) y con freno ante la cuota (para tras N fallos
|
||||||
|
seguidos). NO toca el front; solo genera el mp3 y asocia la URL al post (meta
|
||||||
|
fea_audio_url).
|
||||||
|
|
||||||
Lanzar: nohup ~/tts-local/xtts-venv/bin/python scripts/tts_produce.py > /tmp/feadulta-tts-prod.out 2>&1 &
|
Lanzar: nohup ~/tts-local/xtts-venv/bin/python scripts/tts_produce.py > /tmp/feadulta-tts-prod.out 2>&1 &
|
||||||
Log: /tmp/feadulta-tts-prod.log
|
Log: /tmp/feadulta-tts-prod.log
|
||||||
@@ -78,7 +80,7 @@ def main():
|
|||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
title, text = mm.get_post_text(pid)
|
title, text, author = mm.get_post_text(pid)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
log(f"#{pid}: error leyendo ({e}); skip")
|
log(f"#{pid}: error leyendo ({e}); skip")
|
||||||
php("setflag", str(pid), "fea_audio_skip", "1")
|
php("setflag", str(pid), "fea_audio_skip", "1")
|
||||||
@@ -90,15 +92,17 @@ def main():
|
|||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
rc = mm.t2a(mm.add_pauses(text), VOICE, MODEL, f"prod-{pid}")
|
voice = mm.voice_for_author(author, VOICE)
|
||||||
|
rc = mm.t2a(mm.add_pauses(text), voice, MODEL, f"prod-{pid}")
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
src = mm.OUT / f"prod-{pid}.mp3"
|
src = mm.OUT / f"prod-{pid}.mp3"
|
||||||
dst = PROD / f"{pid}.mp3"
|
dst = PROD / f"{pid}.mp3"
|
||||||
shutil.move(str(src), str(dst))
|
shutil.move(str(src), str(dst))
|
||||||
php("setaudio", str(pid), f"/wp-content/uploads/tts/{pid}.mp3")
|
php("setaudio", str(pid), f"/wp-content/uploads/tts/{pid}.mp3", voice)
|
||||||
ok += 1
|
ok += 1
|
||||||
consec = 0
|
consec = 0
|
||||||
log(f"#{pid} OK «{title[:45]}» → tts/{pid}.mp3 (total {ok})")
|
voice_tag = f" [{voice}]" if voice != VOICE else ""
|
||||||
|
log(f"#{pid} OK «{title[:45]}»{voice_tag} → tts/{pid}.mp3 (total {ok})")
|
||||||
i += 1
|
i += 1
|
||||||
time.sleep(INTERVAL)
|
time.sleep(INTERVAL)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* verify_carta_lang_links.php
|
||||||
|
*
|
||||||
|
* QA de una carta traducida: para cada idioma (en/fr/it/pt), revisa TODOS los
|
||||||
|
* enlaces internos de feadulta.com y comprueba que apuntan a contenido en ese
|
||||||
|
* mismo idioma (prefijo /en/, /fr/, /it/, /pt/ y slug correcto).
|
||||||
|
*
|
||||||
|
* Detecta dos tipos de fallo:
|
||||||
|
* - SIN_TRADUCIR: el link apunta al artículo en español porque nunca se
|
||||||
|
* tradujo/creó su equivalente en ese idioma.
|
||||||
|
* - MISMATCH: el link tiene prefijo de idioma pero el slug no es el
|
||||||
|
* correcto (p.ej. suffix -2 divergente entre local y prod), y resuelve a
|
||||||
|
* contenido de OTRO idioma o a nada.
|
||||||
|
*
|
||||||
|
* Solo reporta, no modifica nada (a diferencia de repoint_carta_links.php).
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* FEA_WP_LOAD=/web/wp-load.php CARTA=54254 php verify_carta_lang_links.php
|
||||||
|
* CARTA=54254 php verify_carta_lang_links.php (usa /var/www/html/wp-load.php, para Docker local)
|
||||||
|
*/
|
||||||
|
|
||||||
|
require getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
|
||||||
|
|
||||||
|
$CARTA = (int)(getenv('CARTA') ?: 0);
|
||||||
|
if (!$CARTA) {
|
||||||
|
fwrite(STDERR, "Falta CARTA=<id del post en español>\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$LANGS = ['en', 'fr', 'it', 'pt'];
|
||||||
|
|
||||||
|
function resolve_post_from_href($href) {
|
||||||
|
if (preg_match('~[?&]p=(\d+)~', $href, $m)) {
|
||||||
|
return (int)$m[1];
|
||||||
|
}
|
||||||
|
$path = preg_replace('~^https?://[^/]+~i', '', $href);
|
||||||
|
$path = preg_replace('~[?#].*$~', '', $path);
|
||||||
|
$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 0; // categorías, home, rutas multi-segmento: no aplica
|
||||||
|
}
|
||||||
|
$p = get_page_by_path($segs[0], OBJECT, ['post', 'page']);
|
||||||
|
return $p ? $p->ID : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function href_lang_prefix($href) {
|
||||||
|
$path = preg_replace('~^https?://[^/]+~i', '', $href);
|
||||||
|
$path = preg_replace('~^/fea~', '', $path);
|
||||||
|
if (preg_match('~^/(en|fr|it|pt)(/|$)~', $path, $m)) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$translations = pll_get_post_translations($CARTA);
|
||||||
|
if (empty($translations)) {
|
||||||
|
fwrite(STDERR, "El post $CARTA no tiene grupo de traducciones Polylang.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Verificando enlaces de la carta ES=$CARTA\n";
|
||||||
|
echo "Traducciones: " . json_encode($translations) . "\n\n";
|
||||||
|
|
||||||
|
$total_sin_traducir = 0;
|
||||||
|
$total_mismatch = 0;
|
||||||
|
$total_rotos = 0;
|
||||||
|
|
||||||
|
foreach ($LANGS as $lang) {
|
||||||
|
if (empty($translations[$lang])) {
|
||||||
|
echo "[$lang] SIN TRADUCCIÓN DE LA CARTA (no existe post en este idioma)\n\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$pid = $translations[$lang];
|
||||||
|
$post = get_post($pid);
|
||||||
|
if (!$post) {
|
||||||
|
echo "[$lang] post $pid no encontrado\n\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Captura cualquier href absoluto http(s) — funciona tanto en local (dominio
|
||||||
|
// Tailscale) como en prod (www.feadulta.com); resolve_post_from_href() ya
|
||||||
|
// descarta lo que no resuelva a un post interno (externos, mailto, etc.).
|
||||||
|
preg_match_all('~href="(https?://[^"]+)"~i', $post->post_content, $m);
|
||||||
|
$hrefs = array_values(array_unique($m[1]));
|
||||||
|
|
||||||
|
$issues = [];
|
||||||
|
foreach ($hrefs as $href) {
|
||||||
|
$target_id = resolve_post_from_href($href);
|
||||||
|
if (!$target_id) {
|
||||||
|
continue; // home, categorías, rutas no resolubles a un post concreto
|
||||||
|
}
|
||||||
|
$target_lang = pll_get_post_language($target_id) ?: 'es';
|
||||||
|
$prefix = href_lang_prefix($href);
|
||||||
|
|
||||||
|
if ($target_lang === $lang && $prefix === $lang) {
|
||||||
|
continue; // todo correcto
|
||||||
|
}
|
||||||
|
|
||||||
|
// Averiguar el ES de origen del contenido apuntado, para buscar la traducción correcta
|
||||||
|
if ($target_lang === 'es') {
|
||||||
|
$es_id = $target_id;
|
||||||
|
} else {
|
||||||
|
$tr = pll_get_post_translations($target_id);
|
||||||
|
$es_id = $tr['es'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$correct_id = $es_id ? pll_get_post($es_id, $lang) : null;
|
||||||
|
|
||||||
|
if ($correct_id && $correct_id != $target_id) {
|
||||||
|
$correct_url = get_permalink($correct_id);
|
||||||
|
$issues[] = [
|
||||||
|
'tipo' => 'MISMATCH',
|
||||||
|
'detalle' => "href=\"$href\" resuelve a post $target_id (lang=$target_lang) — correcto: $correct_url (post $correct_id)",
|
||||||
|
];
|
||||||
|
$total_mismatch++;
|
||||||
|
} elseif ($correct_id && $correct_id == $target_id && $prefix !== $lang) {
|
||||||
|
// resuelve al post correcto pero con URL/slug distinta a la actual (permalink cambió)
|
||||||
|
$correct_url = get_permalink($correct_id);
|
||||||
|
$issues[] = [
|
||||||
|
'tipo' => 'MISMATCH',
|
||||||
|
'detalle' => "href=\"$href\" no coincide con el permalink actual: $correct_url (post $correct_id)",
|
||||||
|
];
|
||||||
|
$total_mismatch++;
|
||||||
|
} elseif (!$correct_id && $target_lang !== $lang) {
|
||||||
|
$issues[] = [
|
||||||
|
'tipo' => 'SIN_TRADUCIR',
|
||||||
|
'detalle' => "href=\"$href\" -> post $target_id (lang=$target_lang) sin traducción en '$lang'",
|
||||||
|
];
|
||||||
|
$total_sin_traducir++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[$lang] post $pid — " . count($hrefs) . " enlaces internos, " . count($issues) . " con problemas\n";
|
||||||
|
foreach ($issues as $issue) {
|
||||||
|
echo " {$issue['tipo']}: {$issue['detalle']}\n";
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "RESUMEN: $total_sin_traducir sin traducir, $total_mismatch mismatch de idioma/slug\n";
|
||||||
|
exit(($total_sin_traducir + $total_mismatch + $total_rotos) > 0 ? 2 : 0);
|
||||||
Executable
+90
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Integración local para POST /wp-json/fea/v1/subir-avatar (#175).
|
||||||
|
# Requiere:
|
||||||
|
# FEA_TEST_URL=http://localhost:8081
|
||||||
|
# FEA_TEST_AUTH='usuario:application-password'
|
||||||
|
# FEA_TEST_USER_ID=<usuario temporal existente>
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${FEA_TEST_URL:?Falta FEA_TEST_URL}"
|
||||||
|
: "${FEA_TEST_AUTH:?Falta FEA_TEST_AUTH}"
|
||||||
|
: "${FEA_TEST_USER_ID:?Falta FEA_TEST_USER_ID}"
|
||||||
|
|
||||||
|
endpoint="${FEA_TEST_URL%/}/wp-json/fea/v1/subir-avatar"
|
||||||
|
tmpdir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmpdir"' EXIT
|
||||||
|
|
||||||
|
# PNG RGB válido de 640×640, generado sin dependencias externas.
|
||||||
|
python3 - "$tmpdir/avatar.png" <<'PY'
|
||||||
|
import struct, sys, zlib
|
||||||
|
width = height = 640
|
||||||
|
raw = b''.join(b'\x00' + bytes((35, 100, 180)) * width for _ in range(height))
|
||||||
|
def chunk(kind, data):
|
||||||
|
return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data) & 0xffffffff)
|
||||||
|
png = b'\x89PNG\r\n\x1a\n'
|
||||||
|
png += chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0))
|
||||||
|
png += chunk(b'IDAT', zlib.compress(raw, 9))
|
||||||
|
png += chunk(b'IEND', b'')
|
||||||
|
open(sys.argv[1], 'wb').write(png)
|
||||||
|
PY
|
||||||
|
|
||||||
|
# PNG válido pero con una dimensión que excede el máximo permitido; queda muy
|
||||||
|
# comprimido a propósito para cubrir la defensa contra image bombs.
|
||||||
|
python3 - "$tmpdir/too-wide.png" <<'PY'
|
||||||
|
import struct, sys, zlib
|
||||||
|
width, height = 4097, 512
|
||||||
|
raw = b''.join(b'\x00' + bytes((35, 100, 180)) * width for _ in range(height))
|
||||||
|
def chunk(kind, data):
|
||||||
|
return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data) & 0xffffffff)
|
||||||
|
png = b'\x89PNG\r\n\x1a\n'
|
||||||
|
png += chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0))
|
||||||
|
png += chunk(b'IDAT', zlib.compress(raw, 9))
|
||||||
|
png += chunk(b'IEND', b'')
|
||||||
|
open(sys.argv[1], 'wb').write(png)
|
||||||
|
PY
|
||||||
|
|
||||||
|
assert_status() {
|
||||||
|
local expected="$1" actual="$2" label="$3" response_file="$4"
|
||||||
|
if [[ "$actual" != "$expected" ]]; then
|
||||||
|
echo "FAIL: $label — esperado HTTP $expected, recibido $actual" >&2
|
||||||
|
cat "$response_file" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# La ruta existe y no permite una llamada sin autenticar.
|
||||||
|
status="$(curl -sS -o "$tmpdir/unauth.json" -w '%{http_code}' -X POST "$endpoint")"
|
||||||
|
assert_status 401 "$status" 'llamada sin autenticar' "$tmpdir/unauth.json"
|
||||||
|
|
||||||
|
# Autenticada, pero sin destino: no puede persistir nada.
|
||||||
|
status="$(curl -sS -o "$tmpdir/no-user.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -X POST "$endpoint")"
|
||||||
|
assert_status 400 "$status" 'falta user_id' "$tmpdir/no-user.json"
|
||||||
|
|
||||||
|
# Un usuario válido sin imagen no puede crear attachments vacíos.
|
||||||
|
status="$(curl -sS -o "$tmpdir/no-avatar.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "user_id=$FEA_TEST_USER_ID" "$endpoint")"
|
||||||
|
assert_status 400 "$status" 'falta avatar' "$tmpdir/no-avatar.json"
|
||||||
|
|
||||||
|
# Una imagen-bomba comprimida se rechaza por dimensiones antes de abrir el editor.
|
||||||
|
status="$(curl -sS -o "$tmpdir/too-wide.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "user_id=$FEA_TEST_USER_ID" -F "avatar=@$tmpdir/too-wide.png;type=image/png" "$endpoint")"
|
||||||
|
assert_status 400 "$status" 'dimensiones excesivas' "$tmpdir/too-wide.json"
|
||||||
|
|
||||||
|
# Usuario inexistente: no puede crear attachments huérfanos.
|
||||||
|
status="$(curl -sS -o "$tmpdir/no-such-user.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F 'user_id=999999999' -F "avatar=@$tmpdir/avatar.png;type=image/png" "$endpoint")"
|
||||||
|
assert_status 404 "$status" 'usuario inexistente' "$tmpdir/no-such-user.json"
|
||||||
|
|
||||||
|
# Camino feliz: el endpoint crea attachment y devuelve el avatar asignado.
|
||||||
|
status="$(curl -sS -o "$tmpdir/success.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "user_id=$FEA_TEST_USER_ID" -F "avatar=@$tmpdir/avatar.png;type=image/png" "$endpoint")"
|
||||||
|
assert_status 201 "$status" 'subida válida' "$tmpdir/success.json"
|
||||||
|
|
||||||
|
python3 - "$tmpdir/success.json" "$FEA_TEST_USER_ID" <<'PY'
|
||||||
|
import json, os, sys
|
||||||
|
payload = json.load(open(sys.argv[1]))
|
||||||
|
assert int(payload['user_id']) == int(sys.argv[2]), payload
|
||||||
|
assert int(payload['attachment_id']) > 0, payload
|
||||||
|
assert payload['avatar_url'].startswith(('http://', 'https://')), payload
|
||||||
|
assert payload['width'] == 512 and payload['height'] == 512, payload
|
||||||
|
previous = os.environ.get('FEA_TEST_PREVIOUS_ATTACHMENT_ID')
|
||||||
|
if previous:
|
||||||
|
assert int(payload['previous_attachment_id']) == int(previous), payload
|
||||||
|
print('PASS: avatar asignado', payload['attachment_id'])
|
||||||
|
PY
|
||||||
Reference in New Issue
Block a user