Compare commits

..

10 Commits

Author SHA1 Message Date
rafa b3a2ae682b fix: style author archives in dark mode
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-12 07:05:57 -04:00
rafa 825434a0b7 fix: preserve dark menu background in articles
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-12 06:11:06 -04:00
rafa 68b95aae89 fix: extend accessible typography
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-09 10:50:56 -04:00
rafa 82c352aa15 fix: refine dark search states
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-09 01:01:26 -04:00
rafa 51ae723c5a fix: correct dark mode search and slider
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-09 00:39:32 -04:00
rafa 7a623e8bb9 accesibilidad: Atkinson Hyperlegible + toggle claro/oscuro (#196)
Mu-plugin fea-accesibilidad.php:
- Atkinson Hyperlegible (OFL) self-hosted, subset latin regular+bold,
  sustituye la tipografia global del cuerpo sin selector.
- Toggle claro/oscuro persistente (localStorage), anti-flash en wp_head,
  boton inyectado junto al selector de idioma en el header.
- color-scheme: dark para controles nativos (audio, inputs) + overrides
  puntuales donde el sitio fija colores claros a mano (hero, tarjetas,
  selector de idioma, buscador movil, beta feedback, cookie consent).
- Acento mas claro (#e8899e) solo en modo oscuro: el carmesi de marca
  (#8b1a2e) no llega a 2:1 de contraste sobre el fondo casi negro.

QA local (docker wordpress-web, capturas Playwright en tools/e2e/out/):
portada, tarjetas y single verificados en claro y oscuro.
2026-08-09 00:15:45 -04:00
rafa 5055e92143 Rescatar 10 docs/ del main local que nunca llegaron a Gitea (issue #194)
main local y origin/main son dos historias sin ancestro comun (ver #194).
Analisis del agente: de las 46 commits solo-en-local, el unico contenido que
no esta ya reflejado en origin/main via el snapshot de julio (PR #179) son
estos 10 ficheros de docs/ (guias, handoffs, planes, benchmark, revisiones de
issues). Copiados tal cual desde main con "git show main:docs/X > docs/X".

Redactada una password SSH y una password cPanel/FTP en texto plano que
tenia handoff-carta-46956-2026-06-17.md (servidor CDMON antiguo) antes de
subirlo -- ver .env, nunca en docs versionados.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 08:39:06 -04:00
rafa 638f5b70c7 Apuntar sync_translations/audio/carta_from_prod.py al Hetzner nuevo
Los tres scripts asumian el CDMON viejo (134.0.10.170, sin Docker): host y
password hardcodeados, y el helper PHP ejecutandose directamente en el host.
Desde el cutover a Hetzner (Coolify/Docker, 2026-08-03) eso ya no aplica.

- FEA_PROD_HOST/FEA_PROD_PASS pasan a FEA_PROD_SSH_HOST/FEA_PROD_SSH_PASS
  (mismo nombre en los tres scripts; sync_carta_from_prod.py ya los usaba).
- Nueva FEA_PROD_DOCKER_CONTAINER: si esta definida, el helper se sube y se
  ejecuta con "docker exec -i" dentro del contenedor en vez de en el host.
- Auth por clave (ed25519 claude-code@feadulta): sshpass solo se usa si
  FEA_PROD_SSH_PASS no esta vacia.
- Fix del gotcha de "docker exec CONTENEDOR cmd < ruta": esa redireccion la
  resuelve el HOST, no el contenedor. El wrapping va dentro de "sh -c" para
  que la redireccion se resuelva en el sitio correcto (afecta a la subida
  del helper y a la verificacion de tamano del mp3 en audio).

Verificado: dry-run de los tres contra el WP local, y una lectura real de
prod (sync_carta_from_prod.py --carta 55487 --dry-run) que recupero
correctamente el cluster de la carta 738 y el contenido de un post concreto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 07:32:38 -04:00
rafa 15d3d72c70 feat(api): endpoint para subir avatar de autor (#175) 2026-07-16 07:06:55 -04:00
rafa 809d4d9b81 Merge pull request 'Sincronizar mu-plugins/ y scripts/ con el estado real del sitio (2026-07-16)' (#179) from sync-flat-2026-07-16 into main 2026-07-16 00:40:49 +00:00
20 changed files with 2409 additions and 30 deletions
+1
View File
@@ -17,6 +17,7 @@ scripts/ — scripts Python/PHP de migración, traducción, TTS y mantenimi
| `fea-homepage.php` | Tarjetas de portada |
| `fea-share.php` | Sección "Comparte FeAdulta" (Facebook, Instagram, Imprimir) |
| `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-search.php` | Buscador móvil nativo |
| `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/
```
+99
View File
@@ -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 15 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.
+411
View File
@@ -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).
+121
View File
@@ -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.
+86
View File
@@ -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 4693746955). De ellos **15 son ES** (4693746951) y 4 ya venían en otros idiomas desde Joomla.
- **Cluster multiidioma** 4695146955 = 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 **4695947018**), `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.60.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 **4707947082**), 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 (4707947082), 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 (4693747082) 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** (4693747082 + 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 4693746955 con IDs preservados, metas (`_carta_id`, `_fgj2wp_old_k2_id`), categorías, idioma ES. Revisar.
2. [ ] Cluster multiidioma 4695146955: enlazar Polylang en prod (los 5 ya existirían tras paso 1).
3. [ ] Traducciones EN/FR/IT/PT (carta + artículos): 4695947018 con IDs preservados, Polylang, categorías.
4. [ ] Evangelio: crear en prod las 4 traducciones del 2682 (4707947082) 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.
+99
View File
@@ -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ú.
+88
View File
@@ -0,0 +1,88 @@
# Plan de implementación — Buscador con Typesense (feadulta #8, fase 2)
> **Estado:** plan para ejecutar (pensado para que lo ejecute un agente Sonnet).
> **Fase 1 (MVP nativo) ya está en producción** — ver `feadulta-buscador-8` / issue #8:
> barra de búsqueda visible en móvil (`mu-plugins/fea-search.php`) + resultados en
> rejilla (`scripts/set_search_template.php`). Esta fase 2 sustituye el **motor** por
> Typesense manteniendo/мejorando esa UI, y añade **búsqueda por autor** (faceta).
>
> **DECISIÓN PENDIENTE (Rafa) — dónde corre Typesense en producción.** Todo lo demás
> se puede construir y validar en **local** sin esa decisión (Typesense en Docker local).
> Opciones de despliegue público (decidir luego): (a) PC + Cloudflare Tunnel
> `search.feadulta.com` (coste 0, atado al PC encendido, fallback al buscador nativo);
> (b) VPS pequeño dedicado ~4-6€/mes (fiable); (c) Typesense Cloud (gestionado).
> El hosting actual (cPanel compartido, sin Docker/systemd/root) **no sirve** para el motor.
## Decisiones ya tomadas
- **Integración:** Custom InstantSearch (indexador propio + UI InstantSearch.js), NO plugin.
- **Motor:** Typesense self-host en Docker.
- **Fallback:** si Typesense no responde, mantener el buscador nativo (`/?s=`) ya desplegado.
## Arquitectura
```
Navegador del usuario
└── InstantSearch.js (typesense-instantsearch-adapter) ── HTTPS ──► Typesense
(página /buscar en WP, search-only API key) (Docker)
WP (wp-nuevo) ── indexador (wp-cli/PHP) admin API key ──────────────────────┘
└── hook publish_post / cron: reindexa incremental
```
## Pasos (ejecutables por Sonnet)
### A. Typesense local de desarrollo (no depende de la decisión de infra)
1. Añadir servicio `typesense` a `docker-compose.yml` (imagen `typesense/typesense:27.x`),
volumen de datos, `--api-key` admin de desarrollo, puerto 8108 **solo en localhost**.
2. Levantar y verificar salud: `GET http://localhost:8108/health``{"ok":true}`.
### B. Schema de la colección `feadulta_posts`
Campos: `id`(post_id), `title`, `content` (texto plano, sin HTML), `excerpt`,
`author_id`(int32, facet), `author_name`(string, facet), `date`(int64, sort),
`url`, `lang`(string, facet: es/en/fr/it/pt), `categories`(string[], facet),
`thumbnail`(opcional). `default_sorting_field`: `date`.
### C. Indexador (`scripts/typesense_index.php`, wp-cli `wp eval-file`)
1. Recorre posts `publish` (`post`), excluye los template/feedback CPT.
2. Por cada post construye el documento (content = `wp_strip_all_tags`, author_name =
`display_name` del `post_author`, lang vía Polylang `pll_get_post_language`,
categories por slugs). Sube en **lotes** (import JSONL, action=upsert).
3. Idempotente, reanudable, con `--since` para incremental. ~24.778 posts.
4. Reindexado en caliente: hook `publish_post`/`save_post` → upsert de ese documento;
`before_delete_post` → delete. (Encolar para no penalizar el guardado.)
### D. UI InstantSearch (sustituye/mejora la página de resultados)
1. Página `/buscar` (o el propio template `search`) con InstantSearch.js +
`typesense-instantsearch-adapter`, apuntando a la URL pública de Typesense y la
**search-only** key (nunca la admin) — ambas configurables (constantes en un
mu-plugin `fea-search-typesense.php`, vacías hasta decidir la infra → fallback nativo).
2. Widgets: searchbox, hits (tarjetas con la estética actual `fea-archive-card`),
**refinement por autor** (faceta `author_name`), por idioma y por categoría,
paginación. Resaltado de coincidencias.
3. Multiidioma: filtrar `lang` = idioma actual por defecto; textos de UI por idioma.
4. Mantener la barra móvil de fase 1 como entrada; en desktop, integrar con el buscador
del menú.
### E. Seguridad de claves
- **Admin API key**: solo en el servidor (indexador), nunca en el front.
- **Search-only key**: restringida a la colección, embebida en el front (es segura por diseño).
- CORS de Typesense limitado al dominio del sitio.
### F. Exposición pública (BLOQUEADO por la decisión de infra)
- Opción PC+Tunnel: `cloudflared``search.feadulta.com``localhost:8108`.
- Opción VPS: Typesense en el VPS + Caddy/HTTPS en `search.feadulta.com`.
- Opción Cloud: usar el endpoint y keys que da Typesense Cloud.
- En los tres casos, la UI solo necesita la **URL pública + search-only key**.
## Verificación
- Local: `/health` ok; `typesense_index.php` indexa N docs; la página `/buscar` devuelve
resultados relevantes y la faceta de autor filtra (probar «González Amaro», «Pagola»).
- Comparar relevancia vs buscador nativo en consultas reales del feedback
(«reflexiones», búsqueda por autor).
- Prod: tras exponer el motor, repetir desde un navegador (Cloudflare bloquea headless).
## Riesgos / notas
- **Disponibilidad** si corre en el PC: caídas → debe degradar al buscador nativo, no a error.
- **RAM**: Typesense mantiene el índice en memoria; 24.778 posts de texto entran de sobra
en <1 GB, pero dimensionar el host en consecuencia.
- **Reindexado masivo inicial** puede tardar; hacerlo por lotes y fuera de hora punta.
- No exponer nunca la admin key; CORS restringido.
@@ -0,0 +1,55 @@
# API de subida de avatar (#175) — Plan de implementación
> **For Hermes:** implementar por pasos pequeños, con prueba de integración local antes de tocar el código de producción.
**Objetivo:** permitir que Mixbot/Inma asigne o reemplace de forma segura la foto de un autor mediante `POST /wp-json/fea/v1/subir-avatar`, sin depender del wp-admin ni de Rafa.
**Arquitectura:** un mu-plugin nuevo y autónomo en `mu-plugins/`, paralelo a `fea-crear-autor-api.php`. Reutiliza el mismo modelo de autenticación (Application Password autenticada + capacidad `edit_others_posts`). Recibe un `multipart/form-data` con `user_id` exacto e imagen en el campo `avatar`; valida, normaliza a un cuadrado de 512 px, crea el attachment de WordPress y actualiza únicamente el meta ACF `foto_perfil`.
**Decisiones deliberadas de contrato:**
- **Multipart**, no base64: evita codificación innecesaria y usa el manejo seguro nativo de uploads de WordPress.
- **`user_id` obligatorio**, no slug: evita ambigüedades al asignar una imagen a una persona.
- **JPEG, PNG y WebP; máximo 5 MB; mínimo 512×512 px; máximo 4096 px por lado y 16 megapíxeles.**
- Se conserva el attachment previo; la respuesta devuelve `previous_attachment_id` para rollback manual. No se borra ningún avatar anterior.
- La imagen se recorta centrada y se normaliza a **512×512 px** en el servidor.
**Alcance de seguridad:** el endpoint no permite elegir meta, ruta de filesystem, MIME arbitrario ni otro usuario distinto del `user_id` explícito. Requiere autenticación y permiso de editor o superior.
---
### Task 1: Añadir prueba de integración local para el contrato vacío
**Files:**
- Create: `tests/integration/test_subir_avatar_api.sh`
**Step 1:** probar contra el WordPress Docker local que la ruta no está disponible antes de cargar el nuevo mu-plugin.
**Step 2:** el script debe cubrir, tras cargar el plugin: falta de autenticación (401), falta de `user_id` (400), usuario inexistente (404), falta de fichero (400) y una subida correcta a un usuario temporal.
**Step 3:** el caso correcto debe verificar respuesta, attachment creado, meta `foto_perfil` actualizado y que el attachment anterior no se elimina. Debe limpiar el usuario/attachment temporal al terminar.
### Task 2: Implementar el mu-plugin mínimo
**Files:**
- Create: `mu-plugins/fea-subir-avatar-api.php`
**Step 1:** registrar `POST /fea/v1/subir-avatar` y reutilizar una callback de autorización equivalente a la de `crear-autor`.
**Step 2:** validar `user_id` y el fichero `avatar` antes de persistir nada.
**Step 3:** procesar la imagen con APIs nativas de WordPress, normalizarla a 512×512 y crear su attachment con metadata.
**Step 4:** actualizar exclusivamente `foto_perfil` y devolver ID del usuario, attachment nuevo, attachment anterior y URL.
### Task 3: Ejecutar integración local y revisión de seguridad
**Files:**
- Modify only if a test demuestra una necesidad real.
**Step 1:** copiar temporalmente el mu-plugin y el script de prueba a la instalación WordPress Docker local. No tocar producción.
**Step 2:** ejecutar la prueba y verificar que falla de forma esperada antes de implementar, y pasa después.
**Step 3:** ejecutar `php -l` sobre el mu-plugin y revisar `git diff --check` / `git diff`.
**Step 4:** dejar los cambios solo en la rama aislada `feat/subir-avatar-175`; no hacer push, PR ni despliegue sin aprobación explícita de Rafa.
+94
View File
@@ -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
+299
View File
@@ -0,0 +1,299 @@
<?php
/**
* Plugin Name: Fe Adulta — Accesibilidad (tipografía + contraste)
* Description: Tipografía Atkinson Hyperlegible global (self-hosted) + toggle
* claro/oscuro persistente, pensado para el público mayor de
* Fe Adulta (issue rafa/feadulta#196, evidencia en comment #609).
* Version: 1.0
*
* Alcance decidido en el issue (2026-08-08): SOLO dos cosas, sin más superficie
* de UI que la necesaria —
* 1. Atkinson Hyperlegible sustituye la tipografía del cuerpo, sin selector.
* 2. Un único toggle claro/oscuro persistente (claro por defecto).
* Nada de panel de accesibilidad, A+/Aa ni cambio de tamaño de letra.
*/
if (!defined('ABSPATH')) exit;
if (!defined('FEA_A11Y_ACCENT_DARK')) {
// Variante clara del carmesí de marca (#8b1a2e) para que enlaces/foco
// cumplan contraste AA sobre fondo casi negro (el carmesí original da
// ~1.8:1 sobre #0d0d0d, muy por debajo del 4.5:1 que exige texto).
define('FEA_A11Y_ACCENT_DARK', '#e8899e');
}
/* ── 1) Anti-flash: fija el tema ANTES de que el navegador pinte nada ─────
* Se engancha con prioridad muy baja para salir de los primeros bytes de
* <head>, igual que el resto del sitio guarda preferencias en localStorage
* (ver fea-beta-feedback.php). Sin esto habría un parpadeo claro→oscuro. */
add_action('wp_head', function () {
if (is_admin()) return;
?>
<script>(function(){try{
var t = localStorage.getItem('fea_theme');
if (t === 'dark') document.documentElement.setAttribute('data-fea-theme', 'dark');
}catch(e){}})();</script>
<?php
}, 1);
/* ── 2) Fuente self-hosted + estilos de tema oscuro ───────────────────── */
add_action('wp_head', function () {
if (is_admin()) return;
$base = plugins_url('fea-accesibilidad/fonts', __FILE__);
$accent_dark = FEA_A11Y_ACCENT_DARK;
?>
<style id="fea-a11y">
/* ── Atkinson Hyperlegible (Braille Institute, OFL), subset latin ──── */
@font-face {
font-family: 'Atkinson Hyperlegible';
font-style: normal;
font-weight: 100 500;
font-display: swap;
src: url('<?php echo esc_url($base); ?>/atkinson-hyperlegible-regular.woff2') format('woff2');
}
@font-face {
font-family: 'Atkinson Hyperlegible';
font-style: normal;
font-weight: 501 900;
font-display: swap;
src: url('<?php echo esc_url($base); ?>/atkinson-hyperlegible-bold.woff2') format('woff2');
}
/* Sustitución global del cuerpo de texto (no opcional, issue #196).
Especificidad baja a propósito: el menú (fea-ui.php, issue #95) usa
una sans de UI más específica y debe seguir ganando. */
body {
font-family: 'Atkinson Hyperlegible', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
h1, h2, h3, h4, h5, h6,
.fea-hero-title,
.fea-section-title {
font-family: 'Atkinson Hyperlegible', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
input, textarea, select, button {
font-family: inherit;
}
/* ── Toggle claro/oscuro: botón inyectado junto al selector de idioma ── */
#fea-theme-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 30px; height: 30px; border-radius: 50%;
border: 1px solid rgba(0,0,0,0.25); background: none; color: inherit;
cursor: pointer; font-size: 0.95rem; line-height: 1; padding: 0;
}
#fea-theme-btn:hover { background: rgba(0,0,0,0.06); }
/* ══════════════════════════════════════════════════════════════════
MODO OSCURO — activado por [data-fea-theme="dark"] en <html>
══════════════════════════════════════════════════════════════════ */
html[data-fea-theme="dark"] {
/* Deja que el navegador oscurezca controles nativos gratis:
audio/video, scrollbars, checkboxes, selects sin estilo propio. */
color-scheme: dark;
--wp--preset--color--base: #0d0d0d;
--wp--preset--color--contrast: #ececec;
}
html[data-fea-theme="dark"] body {
background-color: #0d0d0d !important;
color: #ececec !important;
}
/* En artículos, fea-ui.php fija --wp--preset--color--base en el body
para el modo claro. El contenedor desplegable del menú hereda esa
variable, así que hay que restaurarla aquí en oscuro. */
html[data-fea-theme="dark"] body.single-post {
--wp--preset--color--base: #0d0d0d;
--wp--preset--color--contrast: #ececec;
}
html[data-fea-theme="dark"] a { color: <?php echo esc_html($accent_dark); ?>; }
html[data-fea-theme="dark"] a:focus-visible,
html[data-fea-theme="dark"] button:focus-visible {
outline-color: <?php echo esc_html($accent_dark); ?> !important;
}
/* Cabecera/pie y bloques de grupo sin fondo explícito heredan el body;
solo hay que corregir los pocos sitios con color fijo en claro. */
html[data-fea-theme="dark"] input,
html[data-fea-theme="dark"] textarea,
html[data-fea-theme="dark"] select {
background-color: #1c1c1c;
color: #ececec;
border-color: #3a3a3a;
}
/* Search block uses more-specific own color rules. */
html[data-fea-theme="dark"] .wp-block-search__inside-wrapper,
html[data-fea-theme="dark"] .wp-block-search__input {
background-color: #1c1c1c;
color: #ececec;
border-color: #3a3a3a;
}
html[data-fea-theme="dark"] .wp-block-search__button {
background-color: #ececec;
color: #0d0d0d;
border-color: #ececec;
}
html[data-fea-theme="dark"] .fea-adv-link-wrap { background: #161311; border-bottom-color: #2a2320; }
html[data-fea-theme="dark"] .fea-adv-link { color: <?php echo esc_html($accent_dark); ?>; }
/* Crop the white edge embedded in slide images in dark mode only. */
html[data-fea-theme="dark"] .fea-hero-slider .n2-ss-slide-background-image {
overflow: hidden;
}
html[data-fea-theme="dark"] .fea-hero-slider .n2-ss-slide-background-image img {
transform: scale(1.015);
}
/* Portada — franja hero (fea-homepage.php) */
html[data-fea-theme="dark"] .fea-hero-band {
background: linear-gradient(180deg, #1a1512, #0d0d0d);
border-bottom-color: #2a2320;
}
html[data-fea-theme="dark"] .fea-hero-title,
html[data-fea-theme="dark"] .fea-section-title {
color: #ececec;
}
html[data-fea-theme="dark"] .fea-section-label,
html[data-fea-theme="dark"] .fea-section-more,
html[data-fea-theme="dark"] .fea-card-author {
color: <?php echo esc_html($accent_dark); ?>;
}
html[data-fea-theme="dark"] .fea-hero-meta { color: #b8afa8; }
/* Tarjetas de autor/artículo (fea-homepage.php) */
html[data-fea-theme="dark"] .fea-card {
background: #161311;
border-color: #2a2320;
}
html[data-fea-theme="dark"] .fea-card-title a { color: #ececec; }
/* Archivos (categoría, búsqueda y autor) — fea-homepage.php fija aquí
fondos y texto claros que no heredan del tema. */
html[data-fea-theme="dark"] .wp-block-query-title {
color: #ececec !important;
}
html[data-fea-theme="dark"] .fea-archive-card {
background: #161311;
border-color: #2a2320;
}
html[data-fea-theme="dark"] .fea-archive-title a {
color: #ececec;
}
html[data-fea-theme="dark"] .fea-archive-excerpt {
color: #b8afa8;
}
/* 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);
+247
View File
@@ -0,0 +1,247 @@
<?php
/**
* Plugin Name: Fe Adulta — API subir avatar
* Description: Endpoint REST acotado para asignar la foto de perfil de un autor.
* Version: 1.0
*
* POST /wp-json/fea/v1/subir-avatar
* multipart/form-data: user_id=<id>, avatar=<imagen JPEG|PNG|WebP>
*
* Ver issue gitea.feadulta.com/rafa/feadulta#175.
*/
if (!defined('ABSPATH')) exit;
const FEA_AVATAR_MAX_BYTES = 5242880; // 5 MiB.
const FEA_AVATAR_SIZE = 512;
const FEA_AVATAR_MIN_SIZE = 512;
const FEA_AVATAR_MAX_DIMENSION = 4096;
const FEA_AVATAR_MAX_PIXELS = 16777216; // 16 megapíxeles.
add_action('rest_api_init', function () {
register_rest_route('fea/v1', '/subir-avatar', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'fea_subir_avatar_handle',
'permission_callback' => 'fea_subir_avatar_can_call',
// Se valida dentro del handler, después del permission_callback: una
// llamada sin autenticar siempre recibe 401, incluso si omite user_id.
'args' => [
'user_id' => [
'sanitize_callback' => 'absint',
],
],
]);
});
/** Editor o superior: mismo nivel que el endpoint /crear-autor. */
function fea_subir_avatar_can_call(WP_REST_Request $request) {
if (!is_user_logged_in()) {
return new WP_Error(
'fea_subir_avatar_not_authenticated',
'Debes autenticarte para asignar un avatar.',
['status' => 401]
);
}
if (!current_user_can('edit_others_posts')) {
return new WP_Error(
'fea_subir_avatar_forbidden',
'No tienes permiso para asignar avatares.',
['status' => 403]
);
}
return true;
}
function fea_subir_avatar_handle(WP_REST_Request $request) {
$user_id = absint($request->get_param('user_id'));
if (!$user_id) {
return new WP_Error(
'fea_subir_avatar_invalid_user',
'user_id es obligatorio.',
['status' => 400]
);
}
$user = get_userdata($user_id);
if (!$user) {
return new WP_Error(
'fea_subir_avatar_user_not_found',
'No existe el usuario indicado.',
['status' => 404]
);
}
$files = $request->get_file_params();
$file = $files['avatar'] ?? null;
$validation = fea_subir_avatar_validate_file($file);
if (is_wp_error($validation)) return $validation;
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
$uploaded = wp_handle_upload($file, [
'test_form' => false,
'mimes' => [
'jpg|jpeg|jpe' => 'image/jpeg',
'png' => 'image/png',
'webp' => 'image/webp',
],
]);
if (!empty($uploaded['error'])) {
return new WP_Error(
'fea_subir_avatar_upload_failed',
'No se pudo guardar la imagen: ' . $uploaded['error'],
['status' => 400]
);
}
$normalized = fea_subir_avatar_normalize($uploaded['file']);
if (is_wp_error($normalized)) {
wp_delete_file($uploaded['file']);
return $normalized;
}
$attachment_id = wp_insert_attachment([
'post_mime_type' => $normalized['mime-type'],
'post_title' => 'Avatar — ' . $user->display_name,
'post_status' => 'inherit',
], $normalized['path'], 0, true);
if (is_wp_error($attachment_id)) {
wp_delete_file($normalized['path']);
return new WP_Error(
'fea_subir_avatar_attachment_failed',
'No se pudo crear el attachment del avatar.',
['status' => 500]
);
}
$metadata = wp_generate_attachment_metadata($attachment_id, $normalized['path']);
if (is_wp_error($metadata) || !is_array($metadata) || !$metadata) {
wp_delete_attachment($attachment_id, true);
return new WP_Error(
'fea_subir_avatar_metadata_failed',
'No se pudo generar la metadata del avatar.',
['status' => 500]
);
}
wp_update_attachment_metadata($attachment_id, $metadata);
if (!wp_get_attachment_metadata($attachment_id)) {
wp_delete_attachment($attachment_id, true);
return new WP_Error(
'fea_subir_avatar_metadata_failed',
'No se pudo guardar la metadata del avatar.',
['status' => 500]
);
}
$previous_attachment_id = (int) get_user_meta($user_id, 'foto_perfil', true);
if (!update_user_meta($user_id, 'foto_perfil', (string) $attachment_id)) {
wp_delete_attachment($attachment_id, true);
return new WP_Error(
'fea_subir_avatar_assignment_failed',
'No se pudo asignar el avatar al usuario.',
['status' => 500]
);
}
$size = wp_getimagesize($normalized['path']);
return new WP_REST_Response([
'user_id' => $user_id,
'attachment_id' => (int) $attachment_id,
'previous_attachment_id' => $previous_attachment_id ?: null,
'avatar_url' => wp_get_attachment_image_url($attachment_id, 'full'),
'width' => (int) ($size[0] ?? 0),
'height' => (int) ($size[1] ?? 0),
], 201);
}
function fea_subir_avatar_validate_file($file) {
if (!is_array($file) || empty($file['tmp_name']) || !isset($file['error'])) {
return new WP_Error(
'fea_subir_avatar_missing_file',
'avatar es obligatorio.',
['status' => 400]
);
}
if ((int) $file['error'] !== UPLOAD_ERR_OK) {
return new WP_Error(
'fea_subir_avatar_file_error',
'La subida de avatar falló.',
['status' => 400]
);
}
if ((int) $file['size'] > FEA_AVATAR_MAX_BYTES) {
return new WP_Error(
'fea_subir_avatar_file_too_large',
'La imagen no puede superar 5 MB.',
['status' => 400]
);
}
$image = wp_getimagesize($file['tmp_name']);
if (!$image) {
return new WP_Error(
'fea_subir_avatar_invalid_image',
'avatar debe ser una imagen válida.',
['status' => 400]
);
}
$width = (int) $image[0];
$height = (int) $image[1];
if ($width < FEA_AVATAR_MIN_SIZE || $height < FEA_AVATAR_MIN_SIZE) {
return new WP_Error(
'fea_subir_avatar_invalid_image',
'avatar debe ser una imagen de al menos 512×512 píxeles.',
['status' => 400]
);
}
if ($width > FEA_AVATAR_MAX_DIMENSION || $height > FEA_AVATAR_MAX_DIMENSION || ($width * $height) > FEA_AVATAR_MAX_PIXELS) {
return new WP_Error(
'fea_subir_avatar_image_too_large',
'avatar no puede superar 4096 píxeles por lado ni 16 megapíxeles.',
['status' => 400]
);
}
$allowed_mimes = ['image/jpeg', 'image/png', 'image/webp'];
if (!in_array($image['mime'], $allowed_mimes, true)) {
return new WP_Error(
'fea_subir_avatar_unsupported_type',
'Solo se admiten imágenes JPEG, PNG o WebP.',
['status' => 400]
);
}
return true;
}
/** Recorta al centro y normaliza la imagen al tamaño estándar del avatar. */
function fea_subir_avatar_normalize(string $path) {
$editor = wp_get_image_editor($path);
if (is_wp_error($editor)) {
return new WP_Error(
'fea_subir_avatar_editor_unavailable',
'No se pudo procesar la imagen subida.',
['status' => 400]
);
}
$resized = $editor->resize(FEA_AVATAR_SIZE, FEA_AVATAR_SIZE, true);
if (is_wp_error($resized)) {
return new WP_Error(
'fea_subir_avatar_resize_failed',
'No se pudo normalizar el avatar.',
['status' => 400]
);
}
$saved = $editor->save($path);
if (is_wp_error($saved) || empty($saved['path'])) {
return new WP_Error(
'fea_subir_avatar_save_failed',
'No se pudo guardar el avatar normalizado.',
['status' => 500]
);
}
return $saved;
}
+48 -16
View File
@@ -3,9 +3,9 @@
sync_audio_to_prod.py — Sube a PROD los mp3 de TTS ya generados/enlazados en
local (fea_audio_done=1) y fija el meta fea_audio_url en prod.
Prod (134.0.10.170) tiene glibc rota: scp/sftp NO funcionan (connection closed).
Workaround: subir el binario por stdin de ssh ("cat > ruta"), igual que el resto
de scripts que tocan ese servidor (ver feadulta-server-glibc-rota.md).
Prod vive en Hetzner (Coolify/Docker) desde el cutover de agosto 2026. Subida
del binario por stdin de ssh ("cat > ruta" dentro del contenedor vía
`docker exec -i` si FEA_PROD_DOCKER_CONTAINER está definido).
Uso:
python3 sync_audio_to_prod.py --carta 54254 # sincroniza toda la cola de la carta
@@ -31,11 +31,14 @@ DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
# define, el helper y el mp3 se suben/ejecutan dentro del contenedor.
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
PROD_HELPER = "/tmp/fea_post_io.php"
PROD_UPLOADS_TTS = "/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"
LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
@@ -85,19 +88,47 @@ def carta_article_ids(carta_id: int) -> list[int]:
return [int(x) for x in r.stdout.split() if x.isdigit()]
# ── 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:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
# El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
# solo se antepone si hay contraseña configurada (servidor viejo CDMON).
if PROD_PASS:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
else:
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
if r.returncode != 0:
raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}\n{r.stderr.strip()[:400]}")
return r.stdout
def sh_quote(s: str) -> str:
return "'" + s.replace("'", "'\\''") + "'"
def _remote_wrap(inner_cmd: str) -> str:
"""Envuelve un comando para que corra dentro del contenedor Docker de prod
si FEA_PROD_DOCKER_CONTAINER está definido; si no, corre en el host tal cual.
El wrapping (incluidas redirecciones como `< ruta`) debe quedar DENTRO de la
shell del contenedor (`sh -c '...'`): `docker exec CONTENEDOR cmd < ruta`
resuelve esa redirección en el filesystem del HOST, no en el contenedor.
"""
if PROD_DOCKER_CONTAINER:
return f"docker exec -i {PROD_DOCKER_CONTAINER} sh -c {sh_quote(inner_cmd)}"
return inner_cmd
def _ssh_upload_bytes(data: bytes, remote_path: str, *, timeout: int = 180) -> None:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, f"cat > {remote_path}"]
remote_cmd = _remote_wrap(f"cat > {remote_path}")
if PROD_PASS:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
else:
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
sh(cmd, input_bytes=data, timeout=timeout)
@@ -110,7 +141,7 @@ def prod_helper(subcmd: str, *args: str) -> str:
_ssh_upload_bytes(HELPER_SRC.read_bytes(), PROD_HELPER)
_prod_helper_ready = True
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
return _ssh_text(inner, timeout=60)
return _ssh_text(_remote_wrap(inner), timeout=60)
def prod_upload_mp3(post_id: int) -> None:
@@ -118,14 +149,15 @@ def prod_upload_mp3(post_id: int) -> None:
data = src.read_bytes()
remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3"
_ssh_upload_bytes(data, remote_path)
# Verificación de tamaño (glibc rota => sin fiarse ciegamente del rc=0 de ssh)
remote_size = int(_ssh_text(f"wc -c < {remote_path}").strip())
# Verificación de tamaño: no fiarse ciegamente del rc=0 de ssh. `wc -c` debe
# correr (y resolver la redirección) DENTRO del contenedor — ver _remote_wrap.
remote_size = int(_ssh_text(_remote_wrap(f"wc -c < {remote_path}")).strip())
if remote_size != len(data):
raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}")
def prod_remove_mp3(post_id: int) -> None:
_ssh_text(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3")
_ssh_text(_remote_wrap(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3"))
# ── Estado ───────────────────────────────────────────────────────────────────
+34 -7
View File
@@ -44,7 +44,10 @@ WP_CONTAINER = os.environ.get("FEA_WP_CONTAINER", "wordpress-web")
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/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"
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:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
# El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
# solo se antepone si hay contraseña configurada (servidor viejo CDMON).
if PROD_PASS:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
else:
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
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:
global _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
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:
@@ -234,9 +260,10 @@ def main() -> int:
ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no escribe en local.")
args = ap.parse_args()
if not PROD_HOST or not PROD_PASS:
if not PROD_HOST:
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"
)
+33 -7
View File
@@ -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_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
# define, el helper 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"
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:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
# El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
# solo se antepone si hay contraseña configurada (servidor viejo CDMON).
if PROD_PASS:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
else:
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
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:
global _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
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:
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Integración local para POST /wp-json/fea/v1/subir-avatar (#175).
# Requiere:
# FEA_TEST_URL=http://localhost:8081
# FEA_TEST_AUTH='usuario:application-password'
# FEA_TEST_USER_ID=<usuario temporal existente>
set -euo pipefail
: "${FEA_TEST_URL:?Falta FEA_TEST_URL}"
: "${FEA_TEST_AUTH:?Falta FEA_TEST_AUTH}"
: "${FEA_TEST_USER_ID:?Falta FEA_TEST_USER_ID}"
endpoint="${FEA_TEST_URL%/}/wp-json/fea/v1/subir-avatar"
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
# PNG RGB válido de 640×640, generado sin dependencias externas.
python3 - "$tmpdir/avatar.png" <<'PY'
import struct, sys, zlib
width = height = 640
raw = b''.join(b'\x00' + bytes((35, 100, 180)) * width for _ in range(height))
def chunk(kind, data):
return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data) & 0xffffffff)
png = b'\x89PNG\r\n\x1a\n'
png += chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0))
png += chunk(b'IDAT', zlib.compress(raw, 9))
png += chunk(b'IEND', b'')
open(sys.argv[1], 'wb').write(png)
PY
# PNG válido pero con una dimensión que excede el máximo permitido; queda muy
# comprimido a propósito para cubrir la defensa contra image bombs.
python3 - "$tmpdir/too-wide.png" <<'PY'
import struct, sys, zlib
width, height = 4097, 512
raw = b''.join(b'\x00' + bytes((35, 100, 180)) * width for _ in range(height))
def chunk(kind, data):
return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data) & 0xffffffff)
png = b'\x89PNG\r\n\x1a\n'
png += chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0))
png += chunk(b'IDAT', zlib.compress(raw, 9))
png += chunk(b'IEND', b'')
open(sys.argv[1], 'wb').write(png)
PY
assert_status() {
local expected="$1" actual="$2" label="$3" response_file="$4"
if [[ "$actual" != "$expected" ]]; then
echo "FAIL: $label — esperado HTTP $expected, recibido $actual" >&2
cat "$response_file" >&2 || true
exit 1
fi
}
# La ruta existe y no permite una llamada sin autenticar.
status="$(curl -sS -o "$tmpdir/unauth.json" -w '%{http_code}' -X POST "$endpoint")"
assert_status 401 "$status" 'llamada sin autenticar' "$tmpdir/unauth.json"
# Autenticada, pero sin destino: no puede persistir nada.
status="$(curl -sS -o "$tmpdir/no-user.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -X POST "$endpoint")"
assert_status 400 "$status" 'falta user_id' "$tmpdir/no-user.json"
# Un usuario válido sin imagen no puede crear attachments vacíos.
status="$(curl -sS -o "$tmpdir/no-avatar.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "user_id=$FEA_TEST_USER_ID" "$endpoint")"
assert_status 400 "$status" 'falta avatar' "$tmpdir/no-avatar.json"
# Una imagen-bomba comprimida se rechaza por dimensiones antes de abrir el editor.
status="$(curl -sS -o "$tmpdir/too-wide.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "user_id=$FEA_TEST_USER_ID" -F "avatar=@$tmpdir/too-wide.png;type=image/png" "$endpoint")"
assert_status 400 "$status" 'dimensiones excesivas' "$tmpdir/too-wide.json"
# Usuario inexistente: no puede crear attachments huérfanos.
status="$(curl -sS -o "$tmpdir/no-such-user.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F 'user_id=999999999' -F "avatar=@$tmpdir/avatar.png;type=image/png" "$endpoint")"
assert_status 404 "$status" 'usuario inexistente' "$tmpdir/no-such-user.json"
# Camino feliz: el endpoint crea attachment y devuelve el avatar asignado.
status="$(curl -sS -o "$tmpdir/success.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "user_id=$FEA_TEST_USER_ID" -F "avatar=@$tmpdir/avatar.png;type=image/png" "$endpoint")"
assert_status 201 "$status" 'subida válida' "$tmpdir/success.json"
python3 - "$tmpdir/success.json" "$FEA_TEST_USER_ID" <<'PY'
import json, os, sys
payload = json.load(open(sys.argv[1]))
assert int(payload['user_id']) == int(sys.argv[2]), payload
assert int(payload['attachment_id']) > 0, payload
assert payload['avatar_url'].startswith(('http://', 'https://')), payload
assert payload['width'] == 512 and payload['height'] == 512, payload
previous = os.environ.get('FEA_TEST_PREVIOUS_ATTACHMENT_ID')
if previous:
assert int(payload['previous_attachment_id']) == int(previous), payload
print('PASS: avatar asignado', payload['attachment_id'])
PY