Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea5ecafc3d | |||
| 696934016f | |||
| 06d7057e76 | |||
| ef2978fc21 | |||
| 587b10c2b3 | |||
| 6b966a3b5f | |||
| 754915b91f | |||
| 75c0f07eab | |||
| f0946be1fc | |||
| d836297c89 | |||
| d9ff5e891c | |||
| 5cf588ffe3 | |||
| a734ffc933 | |||
| b3a2ae682b | |||
| 825434a0b7 | |||
| 908188ddf2 | |||
| 68b95aae89 | |||
| 82c352aa15 | |||
| 51ae723c5a | |||
| 7a623e8bb9 | |||
| 5055e92143 | |||
| 638f5b70c7 | |||
| 15d3d72c70 | |||
| 809d4d9b81 |
@@ -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,428 @@
|
|||||||
|
# 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}` |
|
||||||
|
|
||||||
|
## Evangelio y comentarios: regla y paso obligatorio para Mixbot
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Primero guarda la carta (puede quedar como borrador) con esa sección ya completa y
|
||||||
|
anota su ID. Después asigna ese ID a **cada comentario al evangelio** de la semana con
|
||||||
|
el endpoint `POST /wp-json/fea/v1/carta-id/<id-del-comentario>` y el cuerpo
|
||||||
|
`{"carta_id": <id-de-la-carta>}`.
|
||||||
|
|
||||||
|
No hay que escribir la cita ni el texto bíblico a mano: al asignar `_carta_id`, el
|
||||||
|
plugin localiza la única lectura bíblica enlazada en la sección y añade
|
||||||
|
`_cita_evangelio` automáticamente, incluso si el comentario ya estaba publicado. Si
|
||||||
|
la carta tiene cero o varias lecturas, el plugin no adivina: corrige la sección y
|
||||||
|
vuelve a asignar el ID de carta.
|
||||||
|
|
||||||
|
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.
@@ -0,0 +1,171 @@
|
|||||||
|
<?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);
|
||||||
|
|
||||||
|
/** Completa la cita cuando llega la carta, aunque el comentario ya estuviera publicado. */
|
||||||
|
function fea_evangelio_capture_meta_on_carta_assignment($meta_id, $post_id, $meta_key, $meta_value) {
|
||||||
|
if ($meta_key !== '_carta_id' || !(int) $meta_value) return;
|
||||||
|
fea_evangelio_capture_meta((int) $post_id);
|
||||||
|
}
|
||||||
|
add_action('added_post_meta', 'fea_evangelio_capture_meta_on_carta_assignment', 20, 4);
|
||||||
|
add_action('updated_post_meta', 'fea_evangelio_capture_meta_on_carta_assignment', 20, 4);
|
||||||
|
|
||||||
|
/** Añade referencia y texto plegado, cerrado por defecto, antes del comentario. */
|
||||||
|
add_filter('the_content', function($content) {
|
||||||
|
if (!is_single() || is_admin()) return $content;
|
||||||
|
global $post;
|
||||||
|
if (!$post || !fea_evangelio_is_comment($post->ID)) return $content;
|
||||||
|
$cita = trim((string) fea_evangelio_comment_meta($post->ID, '_cita_evangelio'));
|
||||||
|
if (!$cita) return $content;
|
||||||
|
$reading_id = fea_evangelio_reading_id($cita);
|
||||||
|
$lang = function_exists('pll_current_language') ? pll_current_language() : 'es';
|
||||||
|
if ($reading_id && $lang && $lang !== 'es' && function_exists('pll_get_post')) $reading_id = (int) (pll_get_post($reading_id, $lang) ?: $reading_id);
|
||||||
|
$reading = $reading_id ? get_post($reading_id) : null;
|
||||||
|
$starts_with_cita = preg_match('/^\s*' . preg_quote($cita, '/') . '(?:[\s\.,:;\-–—]|$)/iu', wp_strip_all_tags($content));
|
||||||
|
$html = '<section class="fea-com-evangelio" aria-label="' . esc_attr(fea_evangelio_summary_label($lang)) . '">';
|
||||||
|
if (!$starts_with_cita) $html .= '<p class="fea-com-evangelio-cita">' . esc_html($cita) . '</p>';
|
||||||
|
if ($reading && trim(wp_strip_all_tags($reading->post_content)) !== '') {
|
||||||
|
$html .= '<details class="fea-com-evangelio-details"><summary>' . esc_html(fea_evangelio_summary_label($lang)) . '</summary>'
|
||||||
|
. wpautop(do_shortcode($reading->post_content)) . '</details>';
|
||||||
|
}
|
||||||
|
return $html . '</section>' . $content;
|
||||||
|
}, 8);
|
||||||
|
|
||||||
|
add_action('wp_head', function() {
|
||||||
|
if (!is_single()) return;
|
||||||
|
global $post;
|
||||||
|
if (!$post || !fea_evangelio_is_comment($post->ID)) return;
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
.fea-com-evangelio { margin: 0 0 1.75rem; padding: 1rem 1.15rem; background: #f7f4ed; border-left: 4px solid #a77b2d; }
|
||||||
|
.fea-com-evangelio-cita { margin: 0 0 .65rem; font-weight: 700; }
|
||||||
|
.fea-com-evangelio-details summary { cursor: pointer; font-weight: 700; color: #51401e; }
|
||||||
|
.fea-com-evangelio-details > :last-child { margin-bottom: 0; }
|
||||||
|
.fea-com-evangelio-details[open] summary { margin-bottom: .85rem; }
|
||||||
|
</style>
|
||||||
|
<?php
|
||||||
|
});
|
||||||
@@ -2044,8 +2044,12 @@ add_filter('the_content', function($content) {
|
|||||||
global $post;
|
global $post;
|
||||||
if (!in_category('comentarios-al-evangelio', $post)) return $content;
|
if (!in_category('comentarios-al-evangelio', $post)) return $content;
|
||||||
|
|
||||||
$carta_id = get_post_meta($post->ID, '_carta_id', true);
|
$carta_id = function_exists('fea_evangelio_comment_meta')
|
||||||
$cita = get_post_meta($post->ID, '_cita_evangelio', true);
|
? fea_evangelio_comment_meta($post->ID, '_carta_id')
|
||||||
|
: get_post_meta($post->ID, '_carta_id', true);
|
||||||
|
$cita = function_exists('fea_evangelio_comment_meta')
|
||||||
|
? fea_evangelio_comment_meta($post->ID, '_cita_evangelio')
|
||||||
|
: get_post_meta($post->ID, '_cita_evangelio', true);
|
||||||
|
|
||||||
if (!$carta_id && !$cita) return $content;
|
if (!$carta_id && !$cita) return $content;
|
||||||
|
|
||||||
@@ -2066,23 +2070,6 @@ add_filter('the_content', function($content) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Cita del evangelio — link directo al anchor en el índice ---
|
|
||||||
if ($cita) {
|
|
||||||
$book_post_id = ['Mt' => 43908, 'Mc' => 43909, 'Lc' => 43907, 'Jn' => 43906];
|
|
||||||
$abbr = substr($cita, 0, 2);
|
|
||||||
$anchor = fea_cita_to_anchor($cita);
|
|
||||||
$index_url = isset($book_post_id[$abbr])
|
|
||||||
? esc_url(get_permalink($book_post_id[$abbr]) . '#' . $anchor)
|
|
||||||
: '';
|
|
||||||
|
|
||||||
$html .= '<div class="fea-com-row fea-com-cita">'
|
|
||||||
. '<span class="fea-com-label">Evangelio</span>'
|
|
||||||
. ($index_url
|
|
||||||
? '<a href="' . $index_url . '">' . esc_html($cita) . '</a>'
|
|
||||||
: '<span>' . esc_html($cita) . '</span>')
|
|
||||||
. '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Otros comentarios de esta misma cita ---
|
// --- Otros comentarios de esta misma cita ---
|
||||||
if ($cita) {
|
if ($cita) {
|
||||||
$others = get_posts([
|
$others = get_posts([
|
||||||
|
|||||||
@@ -0,0 +1,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]);
|
||||||
|
}
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
sync_audio_to_prod.py — Sube a PROD los mp3 de TTS ya generados/enlazados en
|
sync_audio_to_prod.py — Sube a PROD los mp3 de TTS ya generados/enlazados en
|
||||||
local (fea_audio_done=1) y fija el meta fea_audio_url en prod.
|
local (fea_audio_done=1) y fija el meta fea_audio_url en prod.
|
||||||
|
|
||||||
Prod (134.0.10.170) tiene glibc rota: scp/sftp NO funcionan (connection closed).
|
Prod vive en Hetzner (Coolify/Docker) desde el cutover de agosto 2026. Subida
|
||||||
Workaround: subir el binario por stdin de ssh ("cat > ruta"), igual que el resto
|
del binario por stdin de ssh ("cat > ruta" dentro del contenedor vía
|
||||||
de scripts que tocan ese servidor (ver feadulta-server-glibc-rota.md).
|
`docker exec -i` si FEA_PROD_DOCKER_CONTAINER está definido).
|
||||||
|
|
||||||
Uso:
|
Uso:
|
||||||
python3 sync_audio_to_prod.py --carta 54254 # sincroniza toda la cola de la carta
|
python3 sync_audio_to_prod.py --carta 54254 # sincroniza toda la cola de la carta
|
||||||
@@ -31,11 +31,14 @@ DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
|
|||||||
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
|
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
|
||||||
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
||||||
|
|
||||||
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
|
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
|
||||||
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
|
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
|
||||||
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
|
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
|
||||||
|
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
|
||||||
|
# define, el helper y el mp3 se suben/ejecutan dentro del contenedor.
|
||||||
|
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
|
||||||
PROD_HELPER = "/tmp/fea_post_io.php"
|
PROD_HELPER = "/tmp/fea_post_io.php"
|
||||||
PROD_UPLOADS_TTS = "/web/wp-content/uploads/tts"
|
PROD_UPLOADS_TTS = os.environ.get("FEA_PROD_UPLOADS_TTS", "/var/www/html/wp-content/uploads/tts")
|
||||||
|
|
||||||
HELPER_SRC = Path(__file__).resolve().parent / "fea_post_io.php"
|
HELPER_SRC = Path(__file__).resolve().parent / "fea_post_io.php"
|
||||||
LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
|
LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
|
||||||
@@ -85,19 +88,47 @@ def carta_article_ids(carta_id: int) -> list[int]:
|
|||||||
return [int(x) for x in r.stdout.split() if x.isdigit()]
|
return [int(x) for x in r.stdout.split() if x.isdigit()]
|
||||||
|
|
||||||
|
|
||||||
# ── Prod (glibc rota: nada de scp/sftp, todo por ssh + cat) ────────────────────
|
# ── Prod (Hetzner/Docker: todo por ssh, mp3 por stdin de "cat") ────────────────
|
||||||
def _ssh_text(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
def _ssh_text(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
||||||
|
# El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
|
||||||
|
# solo se antepone si hay contraseña configurada (servidor viejo CDMON).
|
||||||
|
if PROD_PASS:
|
||||||
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
|
else:
|
||||||
|
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
|
r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}…\n{r.stderr.strip()[:400]}")
|
raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}…\n{r.stderr.strip()[:400]}")
|
||||||
return r.stdout
|
return r.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def sh_quote(s: str) -> str:
|
||||||
|
return "'" + s.replace("'", "'\\''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_wrap(inner_cmd: str) -> str:
|
||||||
|
"""Envuelve un comando para que corra dentro del contenedor Docker de prod
|
||||||
|
si FEA_PROD_DOCKER_CONTAINER está definido; si no, corre en el host tal cual.
|
||||||
|
|
||||||
|
El wrapping (incluidas redirecciones como `< ruta`) debe quedar DENTRO de la
|
||||||
|
shell del contenedor (`sh -c '...'`): `docker exec CONTENEDOR cmd < ruta`
|
||||||
|
resuelve esa redirección en el filesystem del HOST, no en el contenedor.
|
||||||
|
"""
|
||||||
|
if PROD_DOCKER_CONTAINER:
|
||||||
|
return f"docker exec -i {PROD_DOCKER_CONTAINER} sh -c {sh_quote(inner_cmd)}"
|
||||||
|
return inner_cmd
|
||||||
|
|
||||||
|
|
||||||
def _ssh_upload_bytes(data: bytes, remote_path: str, *, timeout: int = 180) -> None:
|
def _ssh_upload_bytes(data: bytes, remote_path: str, *, timeout: int = 180) -> None:
|
||||||
|
remote_cmd = _remote_wrap(f"cat > {remote_path}")
|
||||||
|
if PROD_PASS:
|
||||||
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
"-o", "ConnectTimeout=20", PROD_HOST, f"cat > {remote_path}"]
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
|
else:
|
||||||
|
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
sh(cmd, input_bytes=data, timeout=timeout)
|
sh(cmd, input_bytes=data, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
@@ -110,7 +141,7 @@ def prod_helper(subcmd: str, *args: str) -> str:
|
|||||||
_ssh_upload_bytes(HELPER_SRC.read_bytes(), PROD_HELPER)
|
_ssh_upload_bytes(HELPER_SRC.read_bytes(), PROD_HELPER)
|
||||||
_prod_helper_ready = True
|
_prod_helper_ready = True
|
||||||
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
||||||
return _ssh_text(inner, timeout=60)
|
return _ssh_text(_remote_wrap(inner), timeout=60)
|
||||||
|
|
||||||
|
|
||||||
def prod_upload_mp3(post_id: int) -> None:
|
def prod_upload_mp3(post_id: int) -> None:
|
||||||
@@ -118,14 +149,15 @@ def prod_upload_mp3(post_id: int) -> None:
|
|||||||
data = src.read_bytes()
|
data = src.read_bytes()
|
||||||
remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3"
|
remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3"
|
||||||
_ssh_upload_bytes(data, remote_path)
|
_ssh_upload_bytes(data, remote_path)
|
||||||
# Verificación de tamaño (glibc rota => sin fiarse ciegamente del rc=0 de ssh)
|
# Verificación de tamaño: no fiarse ciegamente del rc=0 de ssh. `wc -c` debe
|
||||||
remote_size = int(_ssh_text(f"wc -c < {remote_path}").strip())
|
# 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):
|
if remote_size != len(data):
|
||||||
raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}")
|
raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}")
|
||||||
|
|
||||||
|
|
||||||
def prod_remove_mp3(post_id: int) -> None:
|
def prod_remove_mp3(post_id: int) -> None:
|
||||||
_ssh_text(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3")
|
_ssh_text(_remote_wrap(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3"))
|
||||||
|
|
||||||
|
|
||||||
# ── Estado ───────────────────────────────────────────────────────────────────
|
# ── Estado ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ WP_CONTAINER = os.environ.get("FEA_WP_CONTAINER", "wordpress-web")
|
|||||||
|
|
||||||
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
|
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
|
||||||
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
|
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
|
||||||
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
|
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
|
||||||
|
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
|
||||||
|
# define, el helper se sube y se ejecuta dentro del contenedor.
|
||||||
|
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
|
||||||
PROD_HELPER = "/tmp/fea_translate_helper.php"
|
PROD_HELPER = "/tmp/fea_translate_helper.php"
|
||||||
|
|
||||||
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
|
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
|
||||||
@@ -78,18 +81,41 @@ _prod_ready = False
|
|||||||
|
|
||||||
|
|
||||||
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
||||||
|
# El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
|
||||||
|
# solo se antepone si hay contraseña configurada (servidor viejo CDMON).
|
||||||
|
if PROD_PASS:
|
||||||
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
|
else:
|
||||||
|
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
return sh(cmd, stdin=stdin, timeout=timeout)
|
return sh(cmd, stdin=stdin, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def sh_quote(s: str) -> str:
|
||||||
|
return "'" + s.replace("'", "'\\''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_wrap(inner_cmd: str) -> str:
|
||||||
|
"""Envuelve un comando para que corra dentro del contenedor Docker de prod
|
||||||
|
si FEA_PROD_DOCKER_CONTAINER está definido; si no, corre en el host tal cual.
|
||||||
|
|
||||||
|
El wrapping (incluidas redirecciones como `< ruta`) debe quedar DENTRO de la
|
||||||
|
shell del contenedor (`sh -c '...'`): `docker exec CONTENEDOR cmd < ruta`
|
||||||
|
resuelve esa redirección en el filesystem del HOST, no en el contenedor.
|
||||||
|
"""
|
||||||
|
if PROD_DOCKER_CONTAINER:
|
||||||
|
return f"docker exec -i {PROD_DOCKER_CONTAINER} sh -c {sh_quote(inner_cmd)}"
|
||||||
|
return inner_cmd
|
||||||
|
|
||||||
|
|
||||||
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
|
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
|
||||||
global _prod_ready
|
global _prod_ready
|
||||||
if not _prod_ready:
|
if not _prod_ready:
|
||||||
_ssh(f"cat > {PROD_HELPER}", stdin=HELPER_SRC.read_text(encoding="utf-8"))
|
_ssh(_remote_wrap(f"cat > {PROD_HELPER}"), stdin=HELPER_SRC.read_text(encoding="utf-8"))
|
||||||
_prod_ready = True
|
_prod_ready = True
|
||||||
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
||||||
return _ssh(inner, stdin=stdin, timeout=180)
|
return _ssh(_remote_wrap(inner), stdin=stdin, timeout=180)
|
||||||
|
|
||||||
|
|
||||||
def prod_read_full(post_id: int) -> dict:
|
def prod_read_full(post_id: int) -> dict:
|
||||||
@@ -234,9 +260,10 @@ def main() -> int:
|
|||||||
ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no escribe en local.")
|
ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no escribe en local.")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
if not PROD_HOST or not PROD_PASS:
|
if not PROD_HOST:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"Faltan FEA_PROD_SSH_HOST / FEA_PROD_SSH_PASS en el entorno.\n"
|
"Falta FEA_PROD_SSH_HOST en el entorno (FEA_PROD_SSH_PASS es opcional: "
|
||||||
|
"el Hetzner nuevo usa auth por clave).\n"
|
||||||
"Antes de ejecutar: source ~/.hermes/profiles/feadulta/.env"
|
"Antes de ejecutar: source ~/.hermes/profiles/feadulta/.env"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,12 @@ DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
|
|||||||
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
|
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
|
||||||
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
||||||
|
|
||||||
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
|
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
|
||||||
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
|
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
|
||||||
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
|
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
|
||||||
|
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
|
||||||
|
# define, el helper se sube y se ejecuta dentro del contenedor.
|
||||||
|
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
|
||||||
PROD_HELPER = "/tmp/fea_translate_helper.php"
|
PROD_HELPER = "/tmp/fea_translate_helper.php"
|
||||||
|
|
||||||
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
|
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
|
||||||
@@ -159,18 +162,41 @@ _prod_ready = False
|
|||||||
|
|
||||||
|
|
||||||
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
||||||
|
# El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
|
||||||
|
# solo se antepone si hay contraseña configurada (servidor viejo CDMON).
|
||||||
|
if PROD_PASS:
|
||||||
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
|
else:
|
||||||
|
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
return sh(cmd, stdin=stdin, timeout=timeout)
|
return sh(cmd, stdin=stdin, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_wrap(inner_cmd: str) -> str:
|
||||||
|
"""Envuelve un comando para que corra dentro del contenedor Docker de prod
|
||||||
|
si FEA_PROD_DOCKER_CONTAINER está definido; si no, corre en el host tal cual.
|
||||||
|
|
||||||
|
El wrapping (incluidas redirecciones como `< ruta`) debe quedar DENTRO de la
|
||||||
|
shell del contenedor (`sh -c '...'`): `docker exec CONTENEDOR cmd < ruta`
|
||||||
|
resuelve esa redirección en el filesystem del HOST, no en el contenedor.
|
||||||
|
"""
|
||||||
|
if PROD_DOCKER_CONTAINER:
|
||||||
|
return f"docker exec -i {PROD_DOCKER_CONTAINER} sh -c {sh_quote(inner_cmd)}"
|
||||||
|
return inner_cmd
|
||||||
|
|
||||||
|
|
||||||
|
def sh_quote(s: str) -> str:
|
||||||
|
return "'" + s.replace("'", "'\\''") + "'"
|
||||||
|
|
||||||
|
|
||||||
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
|
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
|
||||||
global _prod_ready
|
global _prod_ready
|
||||||
if not _prod_ready:
|
if not _prod_ready:
|
||||||
_ssh(f"cat > {PROD_HELPER}", stdin=HELPER_SRC.read_text(encoding="utf-8"))
|
_ssh(_remote_wrap(f"cat > {PROD_HELPER}"), stdin=HELPER_SRC.read_text(encoding="utf-8"))
|
||||||
_prod_ready = True
|
_prod_ready = True
|
||||||
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
||||||
return _ssh(inner, stdin=stdin, timeout=180)
|
return _ssh(_remote_wrap(inner), stdin=stdin, timeout=180)
|
||||||
|
|
||||||
|
|
||||||
def prod_create(origin: int, lang: str, title: str, content: str) -> int:
|
def prod_create(origin: int, lang: str, title: str, content: str) -> int:
|
||||||
|
|||||||
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