Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc3af3cbb9 | |||
| 68b95aae89 | |||
| 82c352aa15 | |||
| 51ae723c5a | |||
| 7a623e8bb9 | |||
| 5055e92143 | |||
| 638f5b70c7 |
@@ -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,411 @@
|
|||||||
|
# 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}` |
|
||||||
|
|
||||||
|
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,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,276 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] a { 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; }
|
||||||
|
|
||||||
|
/* 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() {
|
||||||
|
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.
@@ -13,11 +13,23 @@ function fea_audio_player_html(): string {
|
|||||||
$url = get_post_meta($id, 'fea_audio_url', true);
|
$url = get_post_meta($id, 'fea_audio_url', true);
|
||||||
if (!$url) return '';
|
if (!$url) return '';
|
||||||
$voice = get_post_meta($id, 'fea_audio_voice', true) ?: 'NicoFeadulta2026';
|
$voice = get_post_meta($id, 'fea_audio_voice', true) ?: 'NicoFeadulta2026';
|
||||||
return '<div class="fea-audio">'
|
$sha256 = get_post_meta($id, 'fea_audio_sha256', true);
|
||||||
. '<span class="fea-audio-label">'
|
$label = '<span class="fea-audio-label">'
|
||||||
. '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">'
|
. '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">'
|
||||||
. '<path fill="currentColor" d="M3 10v4h4l5 5V5L7 10H3zm13.5 2a4.5 4.5 0 0 0-2.5-4.03v8.06A4.5 4.5 0 0 0 16.5 12zM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54z"/>'
|
. '<path fill="currentColor" d="M3 10v4h4l5 5V5L7 10H3zm13.5 2a4.5 4.5 0 0 0-2.5-4.03v8.06A4.5 4.5 0 0 0 16.5 12zM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54z"/>'
|
||||||
. '</svg> Escucha</span>'
|
. '</svg> Escucha</span>';
|
||||||
|
// Para un asset con hash registrado, descarga y verifica el fichero completo
|
||||||
|
// antes de delegar playback al navegador. Evita que un stream Range mezclado
|
||||||
|
// por una caché reproduzca fragmentos de otro MP3.
|
||||||
|
if (preg_match('/^[a-f0-9]{64}$/i', $sha256)) {
|
||||||
|
return '<div class="fea-audio">' . $label
|
||||||
|
. '<button type="button" class="fea-audio-verified-play" data-fea-audio-verified-play'
|
||||||
|
. ' data-src="' . esc_url($url) . '" data-sha256="' . esc_attr(strtolower($sha256)) . '"'
|
||||||
|
. ' data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '">Reproducir audio</button>'
|
||||||
|
. '<audio controls preload="none" hidden data-fea-audio-track data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '"></audio>'
|
||||||
|
. '<span class="fea-audio-status" role="status" aria-live="polite"></span></div>';
|
||||||
|
}
|
||||||
|
return '<div class="fea-audio">' . $label
|
||||||
. '<audio controls preload="none" src="' . esc_url($url) . '"'
|
. '<audio controls preload="none" src="' . esc_url($url) . '"'
|
||||||
. ' data-fea-audio-track data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '"></audio>'
|
. ' data-fea-audio-track data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '"></audio>'
|
||||||
. '</div>';
|
. '</div>';
|
||||||
@@ -56,6 +68,9 @@ add_action('wp_head', function () {
|
|||||||
.fea-audio-label{display:inline-flex;align-items:center;gap:.35rem;font-size:.78rem;
|
.fea-audio-label{display:inline-flex;align-items:center;gap:.35rem;font-size:.78rem;
|
||||||
font-weight:600;color:#8b1a2e;white-space:nowrap;line-height:1}
|
font-weight:600;color:#8b1a2e;white-space:nowrap;line-height:1}
|
||||||
.fea-audio audio{height:32px;width:230px;max-width:44vw}
|
.fea-audio audio{height:32px;width:230px;max-width:44vw}
|
||||||
|
.fea-audio-verified-play{border:1px solid #8b1a2e;border-radius:6px;background:#fff;color:#8b1a2e;padding:.35rem .6rem;font:inherit;font-size:.82rem;font-weight:600;cursor:pointer}
|
||||||
|
.fea-audio-verified-play:disabled{opacity:.65;cursor:wait}
|
||||||
|
.fea-audio-status{font-size:.76rem;color:#6b5c60}
|
||||||
@media(max-width:600px){
|
@media(max-width:600px){
|
||||||
.fea-audio{margin-left:0;width:100%;margin-top:.5rem}
|
.fea-audio{margin-left:0;width:100%;margin-top:.5rem}
|
||||||
.fea-audio audio{flex:1 1 auto;width:auto;max-width:none}
|
.fea-audio audio{flex:1 1 auto;width:auto;max-width:none}
|
||||||
@@ -73,6 +88,40 @@ add_action('wp_footer', function () {
|
|||||||
if (!get_post_meta(get_queried_object_id(), 'fea_audio_url', true)) return;
|
if (!get_post_meta(get_queried_object_id(), 'fea_audio_url', true)) return;
|
||||||
?>
|
?>
|
||||||
<script>
|
<script>
|
||||||
|
(function () {
|
||||||
|
function hex(buffer) {
|
||||||
|
return Array.from(new Uint8Array(buffer)).map(function (n) {
|
||||||
|
return n.toString(16).padStart(2, '0');
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
document.querySelectorAll('[data-fea-audio-verified-play]').forEach(function (button) {
|
||||||
|
button.addEventListener('click', async function () {
|
||||||
|
var wrap = button.closest('.fea-audio');
|
||||||
|
var audio = wrap.querySelector('audio[data-fea-audio-track]');
|
||||||
|
var status = wrap.querySelector('.fea-audio-status');
|
||||||
|
button.disabled = true;
|
||||||
|
status.textContent = 'Verificando audio…';
|
||||||
|
try {
|
||||||
|
// Se descarga el objeto entero una vez y se valida su hash. Sólo
|
||||||
|
// entonces se crea un blob local: el <audio> no hará Range contra
|
||||||
|
// CDN y jamás podrá mezclar fragmentos de otro MP3.
|
||||||
|
var response = await fetch(button.dataset.src, {cache: 'no-store'});
|
||||||
|
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||||
|
var bytes = await response.arrayBuffer();
|
||||||
|
var digest = hex(await crypto.subtle.digest('SHA-256', bytes));
|
||||||
|
if (digest !== button.dataset.sha256) throw new Error('La copia recibida no coincide con el audio verificado');
|
||||||
|
audio.src = URL.createObjectURL(new Blob([bytes], {type: 'audio/mpeg'}));
|
||||||
|
audio.hidden = false;
|
||||||
|
button.hidden = true;
|
||||||
|
status.textContent = '';
|
||||||
|
await audio.play();
|
||||||
|
} catch (error) {
|
||||||
|
status.textContent = 'No se pudo verificar el audio. Inténtalo de nuevo.';
|
||||||
|
button.disabled = false;
|
||||||
|
console.error('Fe Adulta audio verification failed:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
document.querySelectorAll('audio[data-fea-audio-track]').forEach(function (audio) {
|
document.querySelectorAll('audio[data-fea-audio-track]').forEach(function (audio) {
|
||||||
var fired = false;
|
var fired = false;
|
||||||
audio.addEventListener('play', function () {
|
audio.addEventListener('play', function () {
|
||||||
@@ -84,6 +133,7 @@ add_action('wp_footer', function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}());
|
||||||
</script>
|
</script>
|
||||||
<?php
|
<?php
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -19,6 +19,7 @@ Rollback (despublica en prod lo que este script publicó):
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -31,11 +32,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 +89,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,22 +142,28 @@ 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) -> str:
|
||||||
src = LOCAL_TTS_DIR / f"{post_id}.mp3"
|
src = LOCAL_TTS_DIR / f"{post_id}.mp3"
|
||||||
data = src.read_bytes()
|
data = src.read_bytes()
|
||||||
|
local_sha256 = hashlib.sha256(data).hexdigest()
|
||||||
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+contenido: un rc=0 de ssh no acredita que el asset
|
||||||
remote_size = int(_ssh_text(f"wc -c < {remote_path}").strip())
|
# sea el mismo. El hash se persiste como meta y permite playback verificado.
|
||||||
|
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}")
|
||||||
|
remote_sha256 = _ssh_text(_remote_wrap(f"sha256sum {remote_path} | cut -d' ' -f1")).strip()
|
||||||
|
if remote_sha256 != local_sha256:
|
||||||
|
raise RuntimeError(f"SHA-256 no coincide tras subir #{post_id}: local={local_sha256} remoto={remote_sha256}")
|
||||||
|
return local_sha256
|
||||||
|
|
||||||
|
|
||||||
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 ───────────────────────────────────────────────────────────────────
|
||||||
@@ -153,8 +191,9 @@ def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
|
|||||||
return "PLAN: subiría mp3 + setaudio"
|
return "PLAN: subiría mp3 + setaudio"
|
||||||
|
|
||||||
voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
|
voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
|
||||||
prod_upload_mp3(post_id)
|
sha256 = prod_upload_mp3(post_id)
|
||||||
prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice)
|
prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice)
|
||||||
|
prod_helper("setflag", str(post_id), "fea_audio_sha256", sha256)
|
||||||
if post_id not in state["synced"]:
|
if post_id not in state["synced"]:
|
||||||
state["synced"].append(post_id)
|
state["synced"].append(post_id)
|
||||||
save_state(state)
|
save_state(state)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user