Documentación inicial del proyecto

20 páginas cubriendo operación, arquitectura WP, áreas funcionales,
migración Joomla→WP y roadmap. Información extraída de master-feadulta.md,
sesiones OpenClaw (Brevo, Autores, EFFA, Evangelios) y los .md previos
del repo (AUDITORIA, PLAN, WORDPRESS_CONFIG, CARTAS_PARA_MIGRAR).
OpenClaw Agent
2026-05-20 09:11:28 -04:00
parent 17f467a6ab
commit 7448baf225
20 changed files with 1612 additions and 1 deletions
+56
@@ -0,0 +1,56 @@
# Alta al boletín (Brevo)
Formulario de suscripción al boletín. Embebido como iframe del formulario de Brevo (antes SendinBlue).
## Páginas
| Entorno | Page ID | Slug |
|---|---|---|
| Local | 43892 | `alta` |
| Producción | 43893 | `alta` |
Título de ambas: **"Recibir la carta de novedades"**.
## URL del formulario Brevo
Cuenta Brevo ID `c3555982`. URL completa del iframe:
```
https://c3555982.sibforms.com/serve/MUIFANWlhl9iWHgEiWy2i3jSLlsBjc5BpOUzbn8JmYAR7P7A9V-3KDE3A8IhRNYE4TNG7iL2ahP-3WQlPmDLNe2zVm3bLd2BKdJF6RuF9rhobQCn4q-ryKV0XMSJLpkaLgT4h-DlCVDpt3BDNggapNhNRIT2AZoHvRpfqN91HGQ3p_38M3VAi7o2eVmDgSH4ARfqAK6bz8tlm6Lw0A==
```
## Contenido de la página (HTML)
Bloque HTML único con CSS embebido + iframe:
```html
<!-- wp:html -->
<style>
.page-id-43892 .wp-block-post-title,
.page-id-43893 .wp-block-post-title {
font-size: 2rem !important;
text-align: center !important;
}
.brevo-frame-wrap { max-width: 640px; margin: 0 auto }
.brevo-frame-wrap iframe { width: 100%; height: 735px; border: none }
</style>
<div class="brevo-frame-wrap">
<iframe src="https://c3555982.sibforms.com/serve/..." scrolling="auto" allowfullscreen></iframe>
</div>
<!-- /wp:html -->
```
## Detalle CSS importante
El selector del título es **`.wp-block-post-title`** (FSE), NO `.entry-title` (Astra). Esto es porque las páginas usan el template FSE de Twenty Twenty-Five, no el layout clásico de Astra.
Si el título no se centra o aparece muy grande, comprobar que estás apuntando al selector FSE correcto. Ver [Arquitectura WordPress](Arquitectura-WordPress) para entender por qué.
## Entrada en el menú principal
Añadida en `wp_navigation` ID=1 (el nav block FSE).
| Entorno | URL |
|---|---|
| Local | `https://farmer.taild3aaf6.ts.net/fea/alta/` |
| Producción | `http://feadulta.org/alta/` |
+67
@@ -0,0 +1,67 @@
# Arquitectura WordPress
## Tema y motor de plantillas
El sitio mezcla **dos motores** de plantillas. Esto es importante porque el tema activo NO controla todo.
| Capa | Quién la controla |
|---|---|
| Tema activo | **Astra** (clásico, opciones del Customizer) |
| Single post (post individual) | **Twenty Twenty-Five FSE** (template en BD, `wp_posts` ID 42359, `post_type=wp_template`) |
| Portada | Plantilla FSE + shortcodes inyectados desde mu-plugin |
| EFFA, Brevo, Autores, Evangelios | Páginas con HTML plano + shortcodes |
> **Regla:** para modificar el layout del *single post* hay que editar `wp_posts` ID 42359 o ir a *Apariencia → Editor → Plantillas*. Cambiar el Customizer de Astra ahí no tiene efecto.
Ver [CSS y bugs del tema Astra](CSS-y-bugs-Astra) para los problemas conocidos del tema.
## Plugins activos
| Plugin | Función |
|---|---|
| **FG Joomla to WordPress Premium + K2** | Importador de la migración. Sigue activo porque maneja redirects 301 K2 (regex automática) y conserva la BD `wp_fg_redirect` con 17.853 entradas |
| **Yoast SEO** | SEO básico, sitemaps |
| **Advanced Custom Fields (ACF)** | Campos personalizados (poco uso por ahora) |
| **Filebird** | Organización en carpetas del media library |
| **Smart Slider 3** | Slider de la portada (n2-ss-slide) |
| **UpdraftPlus** | Backups |
| **Polylang** | Multiidioma — ver [Polylang](Polylang-multiidioma) |
## mu-plugins (must-use)
En `wp-content/mu-plugins/`. Trackeados en git.
| Fichero | Qué hace |
|---|---|
| `fea-homepage.php` | Shortcodes de portada, EFFA, autores, evangelios, listado de noticias, CSS condicional |
| `carta-semana-plugin.php` | Redirects de `/carta-de-la-semana/``/category/cartasemana/` y al post directo cuando solo hay uno |
| `fa-custom-css.php.disabled` | Inactivo |
| `stop-redirects.php` | Cortocircuita redirects en escenarios de debug |
Ver detalles en [Portada y shortcodes](Portada-y-shortcodes), [Carta de la semana](Carta-de-la-semana), [EFFA (Escuela)](EFFA-Escuela), [Autores](Autores).
> **Aviso de fragilidad:** los mu-plugins se han perdido del contenedor local en sesiones pasadas tras `docker recreate` o restore de UpdraftPlus. La versión canónica vive en producción: `/web/wp-content/mu-plugins/`.
## Menús
- El menú visible en el header usa un **navigation block** (`wp_navigation` ID=1) con URLs hardcodeadas (`kind:"custom"`).
- El menú clásico `mainmenu` está en BD pero **no se renderiza** — editarlo no tiene efecto.
- El menú multiidioma se inyecta vía JS en `wp_footer` con un mapa PHP construido con `$wpdb` directo (bypasa el filtro de Polylang).
## Estructura de contenido
- **post**: artículos editoriales (la mayoría del contenido, ~24.700 items)
- **page**: páginas estáticas (autores, alta, Evangelios convertidos a posts ya, etc.)
- **attachment**: 6.195 medios migrados
- **wp_navigation**: menús FSE (ID=1 es el principal)
- **wp_template / wp_template_part**: plantillas y partes FSE (ej. footer central con `[fea_noticia_centro]`)
## Categorías importantes
Ver [Categorías y términos](Categorias-y-terminos) para el listado completo de term_ids relevantes (EFFA, evangelios, lecturas, eucaristía, multimedia, artículos, cartas).
## URLs y permalinks
- Estructura: `/%postname%/`
- Idiomas como subdirectorios gestionados por Polylang (`/en/`, `/fr/`, `/it/`, `/pt/`)
- Categorías: `/category/<slug>/`
+145
@@ -0,0 +1,145 @@
# Auditoría de migración
Estado de la migración Joomla → WordPress en base a la auditoría del **2026-03-04** y deltas posteriores.
## Resumen de contenido
### Origen: Joomla
| Origen | Total | Publicados | En papelera |
|---|---|---|---|
| `jos_content` (Joomla nativo) | 102 | 55 | — |
| `ew4r_k2_items` (K2) | 17.712 | 15.764 | 54 |
| `jos_categories` | 33 | 33 | — |
| `ew4r_k2_categories` | 7 | 7 | — |
| K2 items en borrador | 1.885 | — | — |
> Los artículos de `jos_content` son páginas de estructura de Joomla (demo, categorías), no contenido editorial. Todo el contenido real vivía en K2.
### Destino: WordPress
| Tipo | Total | Publicados | Borrador |
|---|---|---|---|
| `wp_posts` (post) | 26.554 (auditoría) → 24.778 (actual tras delta de mayo) | 24.631 | 1.922 |
| `wp_posts` (attachment) | 6.195 | 6.195 | — |
| `wp_posts` (page) | 1 → varias (autores, alta, evangelios convertidos, EFFA) | — | — |
| Categorías | 1.351 | — | — |
| Tags | 12 | — | — |
| Usuarios | 1.182 | — | — |
| Menús importados | 19 → 6 útiles tras limpieza | — | — |
## Cobertura K2 → WP
| Métrica | Valor |
|---|---|
| K2 items publicados (no papelera) en Joomla | **15.764** |
| WP posts con `_fgj2wp_old_k2_id` (migrados de K2) | **17.649** |
| WP posts con `_fgj2wp_old_id` (migrados de `jos_content`) | **8.904** |
**No hay artículos K2 publicados sin migrar.** Diferencia ~1.885 = K2 que estaban en borrador en Joomla, también importados como drafts en WP (decisión: se mantienen como drafts).
## Distribución por año (comparativa)
| Año | Joomla K2 | WordPress |
|---|---|---|
| 2008 | 217 | 217 |
| 2009 | 254 | 254 |
| 2010 | 452 | 451 |
| 2011 | 630 | 613 |
| 2012 | 923 | 1.730 |
| 2013 | 986 | 1.645 |
| 2014 | 931 | 1.696 |
| 2015 | 920 | 1.641 |
| 2016 | 830 | 1.442 |
| 2017 | 739 | 1.288 |
| 2018 | 729 | 1.284 |
| 2019 | 1.002 | 1.495 |
| 2020 | 1.053 | 1.584 |
| 2021 | 1.065 | 1.594 |
| 2022 | 1.032 | 1.615 |
| 2023 | 1.276 | 1.797 |
| 2024 | 1.348 | 2.410 |
| 2025 | 1.380 | 1.814 |
| 2026 | 51 | 61 |
> Los años 2012-2018 muestran más posts en WP que en Joomla K2 → probablemente por la importación de `jos_content` (páginas de estructura). Revisión pendiente de duplicados en ese rango.
## Problemas detectados y estado
### 🔴 Críticos — todos resueltos
| Problema | Estado | Cómo se resolvió |
|---|---|---|
| 1.922 posts en borrador | ✅ Decisión 2026-03-04 | Se mantienen como borradores (eran drafts en Joomla) |
| 508 posts con enlaces internos rotos (`index.php?option=`) | ✅ 2026-03-04 | `scripts/fix_joomla_links.php`**93.030 links reemplazados**, 42 sin resolver (K2 papelera) |
| 429 posts con URLs `/es/ayuda/` | ✅ 2026-03-04 | Mismo script |
### 🟡 Importantes
| Problema | Estado | Cómo se resolvió |
|---|---|---|
| 100 categorías con nombres numéricos | ✅ 2026-03-04 | Causa: FG importó el campo K2 "Autor" (extra field id=12) como nombre de categoría. `scripts/fix_numeric_categories.php` → 5 renombradas, 95 fusionadas con autores existentes |
| 333 posts con URLs absolutas a `feadulta.com` | ✅ 2026-03-04 | Son archivos externos (`/anterior/`, `/ediciones/`) — se mantienen |
| 81 posts publicados con `< 100` chars | ✅ 2026-03-04 | 13 convertidos a borrador, resto válidos |
| 1 post publicado con contenido vacío | ✅ 2026-03-04 | Revisado |
### 🟢 Menores
| Problema | Estado | Cómo se resolvió |
|---|---|---|
| Redirects 301 | ✅ 2026-03-04 | `scripts/generate_k2_redirects.php`**17.853 entradas** en `wp_fg_redirect`, regex automática del plugin FG K2. Verificado: 301 funcional para `/es/.../item/NNN-alias.html` |
| Solo 1 página en WP | ✅ Solucionado | Creadas páginas de Autores, EFFA, Alta, Evangelios convertidos a posts |
| 13 menús técnicos de Joomla (`art1menu`, `comentmenu`, `eucamenu`…) | ✅ 2026-03-04 | Eliminados. Quedan 6 útiles: `mainmenu`, `secciones`, `colaboradores`, `resumeneslibros`, `idioma`, `libros` |
## Medios (attachments)
| Métrica | Valor |
|---|---|
| Attachments en WP | 6.195 |
| Attachments con archivo asociado | 6.195 ✅ |
| Posts con URLs absolutas a `feadulta.com` | 333 (archivos externos legítimos) |
| Imágenes con rutas legacy `/images/stories/` | 0 ✅ |
| Imágenes con rutas legacy `/media/k2/` | 0 ✅ |
## Usuarios
- **1.182 usuarios** migrados
- Roles verificados (2026-03-04): 1.162 subscribers, 14 authors, 6 administrators ✅
## Delta de mayo 2026 (post-auditoría)
- **169 K2 items nuevos** (id > 17873) → WP IDs **4391444082**
- **8 cartas nuevas** (catid 27/40/41, id > 9043) → WP IDs **4408344090**
- **58 ítems `ew4r_content`** no-carta → WP IDs **4409144151**
- **8 noticias de alcance del gap** (Joomla ids 89779034) → WP IDs **4415244159**
- **`_carta_id`** asignado a los 169 K2 items nuevos por matching de fecha
Scripts: ver [Scripts de migración](Scripts-de-migracion).
## Pendientes documentados en su día (auditoría 2026-03-04)
- [ ] Verificar scripts de importación sin log: `import_pensamientos.php`, `import_libros_sub.php`, `import_remaining.php`, `bulk_import.php`, `fix_attachments.php`
- [ ] (Resuelto en marzo) Estructura "Carta semanal → artículos relacionados" → implementado via `_carta_id`
- [ ] Configurar página de inicio del sitio → ✅ hecho con 5 portadas Polylang
## Plan de acción priorizado (histórico)
| Prioridad | Tarea | Estado |
|---|---|---|
| 1 | Arreglar 508 links `index.php?option=` | ✅ |
| 1 | Arreglar 429 links `/es/ayuda/` | ✅ |
| 1 | Renombrar 100 categorías numéricas | ✅ |
| 2 | 1.922 borradores → revisar | ✅ (decisión: mantener) |
| 2 | Revisar 81 posts cortos | ✅ |
| 2 | Revisar 333 referencias a `feadulta.com` | ✅ |
| 3 | Implementar redirects 301 | ✅ |
| 3 | Verificar scripts de importación sin log | ⏳ pendiente |
| 3 | Estructura Cartas Semanales | ✅ (`_carta_id`) |
| 3 | Revisar roles de usuarios | ✅ |
| 3 | Simplificar menús | ✅ |
| 4 | Página de inicio | ✅ |
| 4 | Plugins post-launch (AdSense, TTS, Wordfence) | ⏳ v2 |
| 4 | Test completo | ⏳ pre-cutover |
| 4 | Configurar DNS y lanzar | ⏳ pre-cutover |
Ver [Roadmap](Roadmap) y el board de issues para el estado actual.
+84
@@ -0,0 +1,84 @@
# Autores
Listado de autores del portal, en una página dedicada con dos shortcodes.
## Página "Listado de Autores"
- **ID:** 18636
- **Slug:** `autores-lista`
- **Contenido:** HTML plano con dos shortcodes (sin bloques Gutenberg — ver más abajo)
## Shortcodes (en `fea-homepage.php`)
### `[fea_autores_habituales]`
Autores con **≥ 30 artículos en español**, ordenados por número descendente. Muestra conteo entre paréntesis.
Constantes relevantes:
```php
FEA_AUTORES_EXCLUIR = [1, 890, 1049, 1540] // Fe Adulta x2, Ediciones Feadulta, José Chicharro
FEA_LANG_ES_TTID = 1404 // term_taxonomy_id del idioma ES en Polylang
```
Exclusiones extra para "habituales": IDs 948 y 1048 (Inma Calvo, duplicados de usuario). Por eso la llamada interna es:
```php
fea_autores_query(30, [948, 1048])
```
### `[fea_autores_completo]`
Todos los autores con al menos un artículo en ES, orden A-Z. Layout 3 columnas (responsive: 2 en tablet, 1 en móvil). Clase wrapper `fea-autores-completo`.
## Helpers
```php
fea_autores_query($min_count, $extra_exclude)
fea_autores_html($rows, $show_count, $extra_class)
```
`fea_autores_query` hace `JOIN` con `wp_term_relationships` filtrando por el `term_taxonomy_id` del idioma ES — así solo cuenta artículos en español.
## Avatares circulares (solución definitiva)
`border-radius: 50%` y `clip-path` aplicados en CSS son **ignorados por Astra**. La única forma que funciona es un wrapper `<span>` con `overflow:hidden` y los estilos inline:
```html
<span style="display:inline-block;width:40px;height:40px;min-width:40px;border-radius:50%;overflow:hidden;flex-shrink:0;">
<img src="..." width="40" height="40"
style="width:40px;height:40px;object-fit:cover;display:block;">
</span>
```
El `overflow:hidden` del span hace el recorte circular real. CSS externo NO puede sobreescribir inline styles porque la cascada lo posiciona después.
## Bullets eliminados
`list-style: none` aplicado en clase CSS es **también ignorado por Astra**. Solución: inline en el `<ul>`:
```php
'<ul class="fea-autores-lista' . $extra . '" style="list-style:none;padding-left:0;">'
```
## CSS en `wp_head` (solo en página `autores-lista`)
- `.fea-autores-summary` — cabecera colapsable con flecha ▶ azul `#046bd2` que rota al abrir
- `details[open] > .fea-autores-summary::before { transform: rotate(90deg); }`
- Secciones con `<details>/<summary>` nativo HTML (sin JS), empiezan colapsadas
## Archivo de autor — 30 posts por página
```php
add_action('pre_get_posts', function($query) {
if ($query->is_main_query() && $query->is_author()) {
$query->set('posts_per_page', 30);
}
});
```
## Aviso: contenido de páginas WP desde CLI
El contenido de la página 18636 se guardó como **HTML plano con shortcodes**, sin bloques Gutenberg (`<!-- wp:... -->`). Al actualizar con `wp post update --post_content` desde CLI, los bloques se guardaban como texto literal y se mostraban en la página.
Ver [CSS y bugs del tema Astra](CSS-y-bugs-Astra) para esta y otras gotchas.
+93
@@ -0,0 +1,93 @@
# CSS y bugs del tema Astra
Reglas y *gotchas* aprendidas durante la implementación. Antes de pelearte con CSS, lee esto.
## Resumen ejecutivo
> Astra **sobreescribe** algunas propiedades visuales en su cascada con suficiente especificidad como para que un CSS externo con `!important` NO baste. La única solución probada es **inline styles** sobre el elemento.
## Propiedades que Astra ignora desde CSS externo
| Propiedad | Síntoma | Workaround |
|---|---|---|
| `border-radius` (en imágenes) | El radio no se aplica | Wrapper `<span>` con `overflow:hidden` + inline styles |
| `list-style` | Bullets siguen visibles aunque pongas `none` | Inline `style="list-style:none;padding-left:0;"` en el `<ul>` |
| `height: auto` en `<img>` | El alto se fuerza por Astra | Inline `style="height:auto;"` |
| `clip-path` (en imágenes) | Ignorado | Wrapper con `overflow:hidden` |
## Avatares circulares (caso canónico)
Patrón que funciona en este sitio:
```html
<span style="display:inline-block;width:40px;height:40px;min-width:40px;border-radius:50%;overflow:hidden;flex-shrink:0;">
<img src="..." width="40" height="40"
style="width:40px;height:40px;object-fit:cover;display:block;">
</span>
```
- El `border-radius:50%` está en el span (no la imagen).
- El `overflow:hidden` del span hace el recorte real.
- La imagen es un block sólido sin radio propio.
## `!important` NO siempre gana
Cuando Astra aplica sus reglas con igual o mayor especificidad en cascada, `!important` en CSS externo NO basta. La cascada de inline styles está por encima del CSS de tema, así que inline siempre gana.
## NO añadir esto a CSS global
```css
html { font-size: 125% }
zoom: 1.25
```
**Rompe Smart Slider 3.** El slider de la portada se descalibra (overflow, posicionamiento incorrecto de slides). Si necesitas escalar tipografía, hazlo selector por selector.
## Selectores FSE vs Astra
| Tipo de página | Selector del título |
|---|---|
| Post / page con template FSE (Twenty Twenty-Five) | `.wp-block-post-title` |
| Resto de páginas Astra clásicas | `.entry-title` |
Si tu CSS de título no aplica, lo más probable es que estés apuntando al selector equivocado para ese tipo de página. Casi todo el sitio ya está bajo FSE.
## CSS condicional inyectado en `wp_head`
`fea-homepage.php` inyecta CSS solo en las páginas que lo necesitan, en lugar de cargarlo en todo el sitio:
- Portada: `fea_is_front_page()` true
- Página `autores-lista`: ID 18636
- Páginas EFFA: `strpos($post->post_content, 'effa_') !== false` || `post_name` contiene `effa`
- Brevo: por `page-id-43892` / `page-id-43893` en la propia página
Esto evita conflictos con el resto del sitio.
## `<table>` para layout en bloques FSE
CSS grid / flexbox **no funciona bien dentro de bloques Gutenberg FSE** en este sitio (algún bug de breakpoints). El layout multicolumna se hace con HTML `<table>` + inline styles. Ejemplos: EFFA hub, listado autores en 3 columnas (en su variante anidada), etc.
## Contenido de páginas WP desde CLI: NO usar bloques Gutenberg
`wp post update --post_content '<!-- wp:html --> ... <!-- /wp:html -->'` desde CLI guarda el bloque **como texto literal** y se renderiza así en la página (`<!-- wp:html -->` se ve a ojo en pantalla).
**Regla:** desde CLI, escribir **HTML plano con shortcodes**. Los bloques solo desde el editor de Gutenberg (UI), nunca desde `wp-cli`.
## `str_replace` en patches de PHP
Antes de hacer `str_replace` para parchear `fea-homepage.php` u otro fichero PHP, **siempre verificar el string exacto con `grep`**. Espacios, comillas y saltos de línea cambian entre revisiones y el `str_replace` silenciosamente no reemplaza nada.
```bash
grep -n 'fragmento exacto' wordpress/wp-content/mu-plugins/fea-homepage.php
```
Confirma que el match existe antes de aplicar el patch.
## Resumen visual
Si algo no se ve como esperas:
1. ¿Es border-radius / list-style / height en imagen? → inline.
2. ¿Es bloque FSE? → `.wp-block-*`, no `.entry-*`.
3. ¿Es selector con `!important` y no aplica? → probablemente Astra te sobreescribe. Usa inline.
4. ¿Es layout multicolumna en Gutenberg FSE? → `<table>` con inline styles.
+55
@@ -0,0 +1,55 @@
# Carta de la semana
Sección editorial principal del sitio. Cada semana se publica una "carta" + un conjunto de artículos asociados que la desarrollan.
## Plugin
`carta-semana-plugin.php` (mu-plugin).
- **Local:** `wordpress/wp-content/mu-plugins/carta-semana-plugin.php`
- **Container:** `/var/www/html/wp-content/mu-plugins/carta-semana-plugin.php`
- **Producción:** `/web/wp-content/mu-plugins/carta-semana-plugin.php`
> **Aviso histórico:** este mu-plugin **se vació en local** en una sesión pasada (probablemente por un `docker recreate` o restauración UpdraftPlus). Si vuelve a desaparecer/quedar vacío, restaurar desde producción. Si no, las categorías `cartasemana` / `carta-semana-pasada` muestran el archivo de categoría en vez de redirigir al post de la semana.
## Comportamiento
1. Redirige `/carta-de-la-semana/``/category/cartasemana/`.
2. Si la categoría `cartasemana` tiene `count == 1`, `template_redirect` redirige a ese post directamente.
3. Lo mismo para `carta-semana-pasada`.
## Categorías relacionadas
| term_id | Slug | Uso |
|---|---|---|
| 6 | `cartasemana` | Categoría principal de cartas |
| 21 / 22 | `carta-semana-pasada` | (revisar slugs exactos en BD) |
Ver [Categorías y términos](Categorias-y-terminos).
## Relación carta ↔ artículos
Cada artículo de una semana lleva un meta `_carta_id` con el ID del post-carta al que pertenece. Esto permite renderizar `[fea_articulos_semana]` en la portada y los listados internos.
- Total de metas `_carta_id` en producción: **3.528** (migrados 2026-03-15).
- El meta se asigna por **matching de fecha**: artículos cuya `post_date` cae en el rango de una carta se vinculan a esa carta. Lógica implementada en los scripts de delta (`import_new_k2_items.py` + `fix_imported_k2_metas.py`).
## Bug conocido: 404 en idiomas
`/category/cartasemana/` en EN/FR/IT/PT devuelve **404**.
- **Issue:** [#2 Fix carta de la semana 404 en idiomas EN/FR/IT/PT](http://localhost:3000/rafa/feadulta/issues/2)
- **Causa raíz:** combinación del bug WP_Tax_Query con Polylang. Detallado en [Polylang multiidioma](Polylang-multiidioma).
- **Síntoma SQL:** `get_posts(['lang'=>$lang, 'category__in'=>[$cat]])` genera `AND 0=1` en la query, no devuelve nada y WP responde 404.
- **Fix esperado:** no pasar `lang` explícito a `get_posts` cuando ya se está dentro del contexto de un idioma — Polylang filtra automáticamente.
## Cartas históricas
Histórico de cartas en `analisis-cartas/` del repo. Documentación de cartas para migrar (legacy) en el repo: [`CARTAS_PARA_MIGRAR.md`](http://localhost:3000/rafa/feadulta/src/branch/main/CARTAS_PARA_MIGRAR.md).
## Delta de mayo 2026
- Importadas 8 cartas nuevas (ew4r_content id > 9043, catid 27/40/41) → WP IDs **4408344090** (`scripts/import_new_cartas.py`).
- `_carta_id` reasignado para los 169 K2 items nuevos por matching de fecha.
Ver [Scripts de migración](Scripts-de-migracion).
+92
@@ -0,0 +1,92 @@
# Categorías y términos
Listado de `term_id`s y slugs relevantes para el código y para operaciones de mantenimiento.
## Categorías principales
| term_id | Slug | Nombre / uso |
|---|---|---|
| 6 | `cartasemana` | Carta de la semana (categoría principal de cartas) |
| 21 | (revisar) | Carta semana pasada |
| 22 | (revisar) | Carta semana pasada |
| 28 | `evangelios-y-comentarios` | Posts de los 4 evangelios (`Jn/Lc/Mt/Mc`) |
| 63 | `effa` | EFFA (categoría padre) |
| 64 | `proyecat` | Artículos del hub EFFA (8 items) |
| 1645 | (revisar) | Lecturas bíblicas |
| 1646 | (revisar) | Comentario editorial (primer comentario no-lectura por carta) |
| 1647 | (revisar) | Comentarios al evangelio (los que renderiza `[fea_citas_evangelio]`) |
| 1648 | `eucaristia` | Eucaristía |
| 1649 | `multimedia` | Multimedia |
| 1650 | `articulos` | Artículos |
## Subcategorías EFFA
| term_id | Slug |
|---|---|
| 1653 | `effa-espiritualidad` |
| 1654 | `effa-biblia-at` |
| 1655 | `effa-biblia-nt` |
| 1656 | `effa-cristologia` |
| 1657 | `effa-nuestras-creencias` |
| 1658 | `effa-fe-y-cultura` |
## Idiomas (Polylang)
| Idioma | term_taxonomy_id |
|---|---|
| ES | **1404** (constante `FEA_LANG_ES_TTID` en `fea-homepage.php`) |
| EN | (consultar en `wp_term_taxonomy` con `taxonomy='language'`) |
| FR | … |
| IT | … |
| PT | … |
## Bug crítico: `wp post term set` con IDs numéricos
```bash
wp post term set 12345 category 28
```
**No asigna term_id 28.** wp-cli interpreta `28` como **nombre** de término — si no existe, crea un término nuevo llamado literalmente `"28"`. (Confirmado en su día con `term_id=3299` creado por error).
```bash
wp post term set 12345 category evangelios-y-comentarios
```
✅ Correcto. Usar siempre el slug.
## Mapeo idioma EN/FR/IT/PT × `get_term_by`
`get_term_by('slug', $slug, 'category')` **devuelve `false`** cuando el idioma actual es EN/FR/IT/PT.
Workaround: `$wpdb` directo. Ver [Polylang multiidioma](Polylang-multiidioma).
## Exclusiones de autores
`FEA_AUTORES_EXCLUIR = [1, 890, 1049, 1540]`
| ID | Usuario |
|---|---|
| 1 | Fe Adulta |
| 890 | Fe Adulta (duplicado) |
| 1049 | Ediciones Feadulta |
| 1540 | José Chicharro |
Adicionalmente para `[fea_autores_habituales]`:
`fea_autores_query(30, [948, 1048])` → excluye IDs 948 y 1048 (Inma Calvo duplicada).
## Cómo verificar un term_id
```bash
# Local
docker exec wordpress-mysql mysql -uwordpress_user -pwordpress_pass wordpress_db -e "
SELECT t.term_id, t.name, t.slug, tt.taxonomy, tt.count
FROM wp_terms t
JOIN wp_term_taxonomy tt ON t.term_id = tt.term_id
WHERE tt.taxonomy = 'category'
ORDER BY t.term_id;"
# Prod
ssh feadultada@feadulta.org "mysql -h127.0.0.1 -umyfeadultaa5 -pKjyGU29h \
278025353wordpress20260112013937 -e \"SELECT t.term_id, t.name, t.slug, tt.count FROM wp_terms t JOIN wp_term_taxonomy tt USING(term_id) WHERE tt.taxonomy='category' ORDER BY t.term_id;\""
```
+62
@@ -0,0 +1,62 @@
# Credenciales y accesos
> **Wiki privada.** Estas credenciales solo son visibles para usuarios autenticados en Gitea local.
## HTTP Basic Auth (protege la web hasta el cutover)
Se quita en el cutover. Mientras está activo, cualquier petición a `feadulta.org` pide usuario/password antes de cargar WordPress.
| | |
|---|---|
| Usuario | `feadul316` |
| Contraseña | `X5nWjWrnPg7F` |
## WordPress Admin
| Entorno | URL | Usuario | Contraseña |
|---|---|---|---|
| Local (Tailscale) | https://farmer.taild3aaf6.ts.net/fea/wp-admin | `eqpyk` | `NuevaFeAdulta2024!` |
| Producción | http://feadulta.org/wp-admin/ | `eqpyk` | `NuevaFeAdulta2026!` |
## SSH producción
| | |
|---|---|
| Host | `feadulta.org` |
| Usuario | `feadultada` |
| Contraseña | `mzdY69rn0B2N-UIX` |
| WP root | `/web/` |
## Base de datos producción
Solo accesible desde el server (no expuesta al exterior).
| | |
|---|---|
| Host | `127.0.0.1` |
| DB | `278025353wordpress20260112013937` |
| Usuario | `myfeadultaa5` |
| Contraseña | `KjyGU29h` |
Comando habitual:
```bash
mysql -h127.0.0.1 -umyfeadultaa5 -pKjyGU29h 278025353wordpress20260112013937
```
## Base de datos local (Docker)
| | |
|---|---|
| Host | `172.18.0.2` |
| DB | `wordpress_db` |
| Usuario | `wordpress_user` |
| Contraseña | `wordpress_pass` |
## Brevo (boletín)
- Cuenta SendinBlue/Brevo ID `c3555982`
- URL del formulario embebido: ver [Alta-boletin-Brevo](Alta-boletin-Brevo)
## Joomla (legacy, solo lectura)
Container local `joomla-web` (puerto 8080) sigue disponible para consulta histórica de la BD K2 original. Credenciales del container en `/home/rafa/joomla-migration/docker-compose.yml`.
+62
@@ -0,0 +1,62 @@
# Cutover DNS
Operación final: pasar el dominio `feadulta.org` del Joomla viejo al WordPress nuevo, quitar el HTTP Basic Auth y dejar el sitio público.
**Previsto: ~junio 2026.**
## Estado actual
- WordPress vive en `/web/` del mismo hosting Dreamhost que el Joomla original.
- El sitio está **protegido por HTTP Basic Auth** (`feadul316` / `X5nWjWrnPg7F`) → ningún visitante puede verlo accidentalmente.
- El dominio `feadulta.org` todavía resuelve al Joomla (configuración legacy).
- Los redirects 301 K2 ya están listos: 17.853 entradas en `wp_fg_redirect`.
## Script
`/home/rafa/joomla-migration/scripts/cutover_feadulta_com.sh`
> El script existe pero **debe revisarse** antes de ejecutarlo. Issue: [#3 Preparar y revisar script de cutover DNS](http://localhost:3000/rafa/feadulta/issues/3).
Acciones que típicamente debe contemplar (revisar):
- Sincronizar BD local → prod si hay deltas pendientes (issue [#4](http://localhost:3000/rafa/feadulta/issues/4)).
- Quitar HTTP Basic Auth (`.htaccess` / panel de Dreamhost).
- Cambiar config de WP para que `siteurl` / `home` apunten a `https://feadulta.org`.
- Re-generar permalinks.
- Limpiar caché.
- Verificar HTTPS / certificado.
## Pre-cutover (lista de verificación)
Estos son los issues abiertos del milestone `v1: Pre-cutover` que deberían cerrarse antes:
- [#1](http://localhost:3000/rafa/feadulta/issues/1) Test completo antes del cutover DNS
- [#2](http://localhost:3000/rafa/feadulta/issues/2) Fix carta de la semana 404 en idiomas EN/FR/IT/PT
- [#3](http://localhost:3000/rafa/feadulta/issues/3) Preparar y revisar script de cutover DNS
- [#4](http://localhost:3000/rafa/feadulta/issues/4) Sincronizar base de datos local → producción antes del cutover
- Auditoría visual reciente: issues **#18-#32** (ver [Roadmap](Roadmap))
## Post-cutover inmediato
1. Verificar que todas las URLs antiguas de K2 (`/es/.../item/NNN-alias.html`) redirigen 301 al post WP correspondiente.
2. Test multiidioma: portadas, navegación, categorías.
3. Test del formulario Brevo (alta boletín).
4. Revisar Yoast SEO sitemaps regenerados.
5. **Quitar `noindex`/`nofollow`** ([#19](http://localhost:3000/rafa/feadulta/issues/19)).
## Post-cutover (v2: Post-launch)
Estas tareas requieren el dominio activo y/o son mejoras no bloqueantes:
- [#5](http://localhost:3000/rafa/feadulta/issues/5) Instalar y configurar AdSense (requiere dominio activo)
- [#6](http://localhost:3000/rafa/feadulta/issues/6) TTS (text-to-speech)
- [#7](http://localhost:3000/rafa/feadulta/issues/7) Wordfence
- [#8](http://localhost:3000/rafa/feadulta/issues/8) Buscador avanzado con Typesense (~24.778 posts; Relevanssi descartado por 570MB de índice)
## Rollback
Si algo va mal:
1. Restaurar `.htaccess` (vuelve a poner HTTP Basic Auth → sitio inaccesible).
2. Volver a apuntar DNS al Joomla viejo si Dreamhost mantiene la configuración.
3. UpdraftPlus tiene los últimos backups de WP.
Documentar mejor el rollback en el script antes de ejecutarlo.
+82
@@ -0,0 +1,82 @@
# EFFA (Escuela de Formación de Fe Adulta)
Sección de cursos / formación. Hub principal + 6 subpáginas temáticas. Implementada el **2026-03-06**, restaurada el **2026-03-14** tras perderse los shortcodes y CSS (probablemente por un docker recreate o un restore UpdraftPlus).
## Estructura de páginas
| Page ID | Slug | Permalink | Contenido |
|---|---|---|---|
| 42726 | `escuela` | `/fea/escuela/` | Hub (logo + intro + nav + `[effa_proyecto]` + CTA) |
| 42727 | `effa-espiritualidad` | `/fea/escuela/effa-espiritualidad/` | Subpágina con `[effa_seccion cat="..."]` |
| 42728 | `effa-biblia-at` | … | Antiguo Testamento |
| 42729 | `effa-biblia-nt` | … | Nuevo Testamento |
| 42730 | `effa-cristologia` | … | Cristología |
| 42731 | `effa-nuestras-creencias` | … | Nuestras creencias |
| 42732 | `effa-fe-y-cultura` | … | Fe y cultura |
## Categorías
| term_id | Nombre |
|---|---|
| 63 | EFFA (padre) |
| 64 | proyecat (los 8 artículos del hub) |
| 1653 | effa-espiritualidad |
| 1654 | effa-biblia-at |
| 1655 | effa-biblia-nt |
| 1656 | effa-cristologia |
| 1657 | effa-nuestras-creencias |
| 1658 | effa-fe-y-cultura |
## Shortcodes (en `fea-homepage.php`)
### `[effa_proyecto]` — Hub
Renderiza los 8 artículos de `proyecat`.
- Extrae la primera `<img>` del `post_content` como thumbnail
- Muestra: imagen + título (bold 0.85rem) + extracto 12 palabras (gris 0.75rem)
- Ordena por `post_title` ASC
- Layout: HTML `<table>` con inline styles, 4 columnas, `array_chunk($items, 4)`
### `[effa_seccion cat="slug"]` — Subpáginas (videos)
- Ordena por `meta_key => '_effa_joomla_alias'` ASC (`secc1col01`, `secc1col02`…) para mantener el orden original de Joomla
- Thumbnail: prueba en orden:
1. `get_the_post_thumbnail_url()`
2. URL de YouTube extraída por regex
3. Primera `<img>` del content
- Muestra: imagen + título (bold). Sin extracto.
- Layout: HTML `<table>` 4 columnas con inline styles
## ¿Por qué `<table>` y no flex/grid?
> **CSS grid y flexbox NO funcionan dentro de un bloque Gutenberg FSE** en este sitio. Se intentó y se rompe el layout en breakpoints raros. La única solución probada que funciona es HTML `<table>` con inline styles.
## CSS de EFFA
Inyectado en `wp_head` con priority 20 condicionalmente: si el `post->post_content` contiene `effa_` **o** el `post_name` contiene `effa`.
| Clase | Estilo |
|---|---|
| `.effa-logo` | `centered`, max-width 200px |
| `.effa-intro` | `text-align: center` |
| `.effa-nav` | Flex pills, borde inferior 2px `#e5e5e5` |
| `.effa-nav a` | Pill redondeado, hover/active: bg `#E89A1A` (dorado) |
| `.effa-cta-wrap` | `text-align: center` |
| `.effa-cta` | Botón dorado `#E89A1A`, border-radius 999px |
## Contenido de cada subpágina
```
[logo] + [intro] + <ul class="effa-nav">...</ul> + [shortcode] + [CTA]
```
## Fragilidad / restauración
Los shortcodes y el CSS se han perdido del container local **al menos una vez** (2026-03-14). Para restaurarlos sin perder tiempo, buscar el código completo en el transcript del 2026-03-06:
```
/home/rafa/.claude/projects/-home-rafa-joomla-migration/65e742c0-99e4-41b0-94f7-0866b38089a0.jsonl
```
Buscar `effa_proyecto` — última ocurrencia ~pos 15.301.049. Producción siempre tiene la versión canónica en `/web/wp-content/mu-plugins/fea-homepage.php`.
+82
@@ -0,0 +1,82 @@
# Evangelios y Comentarios
Sección con los 4 evangelios canónicos como posts; cada uno renderiza dinámicamente los comentarios asociados a sus versículos.
Completado el **2026-03-15** (último gran hito antes del delta de mayo).
## Posts de los evangelios
IDs **iguales en local y producción** (sincronizados con `UPDATE wp_posts.ID` directo, ver [Sincronización local → producción](Sincronizacion-local-prod)).
| ID | Título | Slug | Abrev |
|---|---|---|---|
| 43906 | Evangelio de Juan | `jn` | Jn |
| 43907 | Evangelio de Lucas | `lc` | Lc |
| 43908 | Evangelio de Mateo | `mt` | Mt |
| 43909 | Evangelio de Marcos | `mc` | Mc |
> Creados originalmente como `page` el 2026-03-14 y convertidos a `post` el 2026-03-15. La conversión fue necesaria para que aparecieran dentro de la categoría 28.
## Categoría
`EVANGELIOS Y COMENTARIOS` (term_id=**28**, slug=`evangelios-y-comentarios`).
- URL local: https://farmer.taild3aaf6.ts.net/fea/category/evangelios-y-comentarios/
- URL prod: http://feadulta.org/category/evangelios-y-comentarios/
## Shortcode `[fea_citas_evangelio libro="Jn"]`
Definido en `fea-homepage.php` (~línea 1406). Busca posts con meta `_cita_evangelio` que empiece por la abreviatura del libro (ej. `"Jn "`) en categoría 1647 ("Comentarios al evangelio").
### Mapa libro → post_id (hardcodeado en el código)
```php
$book_post_id = [
'Mt' => 43908,
'Mc' => 43909,
'Lc' => 43907,
'Jn' => 43906,
];
```
## Metas migrados (2026-03-15)
Estos metas estaban completos en local pero faltaban en producción. Migrados con `mysqldump` + `mysql` directo (sin `wp db query` porque `proc_open` está desactivado en prod).
| Meta key | Registros | Descripción |
|---|---|---|
| `_cita_evangelio` | 4.290 | Referencia bíblica del post (ej. `"Jn 3, 16"`) |
| `_carta_id` | 3.528 | ID del post-carta al que pertenece el artículo |
Comando que se ejecutó:
```bash
# Local
docker exec wordpress-mysql mysqldump -uwordpress_user -pwordpress_pass wordpress_db wp_postmeta \
--where="meta_key IN ('_cita_evangelio','_carta_id')" \
--no-create-info --complete-insert --skip-extended-insert > /tmp/metas_sin_id.sql
# (+ sed para eliminar meta_id y evitar conflictos)
# Prod (vía paramiko + scp)
mysql -h127.0.0.1 -umyfeadultaa5 -pKjyGU29h 278025353wordpress20260112013937 < /tmp/metas_evangelio.sql
```
## Bug encontrado: wp-cli y categorías numéricas
```bash
wp post term set <ID> category 28 # ❌ asigna término con NOMBRE "28" (term_id=3299)
```
`wp post term set ID category 28` interpreta `28` como **nombre** de término, no como term_id. Crea un término nuevo llamado "28" si no existe.
```bash
wp post term set <ID> category evangelios-y-comentarios # ✅ usa slug
```
**Regla:** siempre usar slug en `wp post term set`.
## Lección aprendida: IDs sincronizados local↔prod
- **NO** usar `get_page_by_path()` ni queries dinámicas para resolver IDs de posts conocidos — propenso a errores en runtime y en migraciones.
- **NO** usar IDs distintos en local y prod — fuerza lógica condicional en el código.
- Cuando aparece divergencia, sincronizar los IDs con UPDATE directo (offset temporal +99000 para evitar conflictos). Procedimiento completo en [Sincronización local → producción](Sincronizacion-local-prod).
+47 -1
@@ -1 +1,47 @@
# feadulta - Wiki # feadulta.org — Wiki del proyecto
Portal cristiano español de renovación de la fe. Documentación operativa del proyecto de migración Joomla → WordPress y de su operación.
**Estado:** migración completa, web protegida con HTTP Basic, cutover DNS previsto ~junio 2026.
---
## Índice
### Operación
- [Credenciales y accesos](Credenciales-y-accesos)
- [Infraestructura](Infraestructura)
- [Limitaciones del servidor de producción](Limitaciones-servidor-prod)
- [Sincronización local → producción](Sincronizacion-local-prod)
### Arquitectura WordPress
- [Arquitectura WordPress](Arquitectura-WordPress)
- [Portada y shortcodes](Portada-y-shortcodes)
- [CSS y bugs del tema Astra](CSS-y-bugs-Astra)
- [Polylang (multiidioma)](Polylang-multiidioma)
- [Categorías y términos](Categorias-y-terminos)
### Áreas funcionales
- [Carta de la semana](Carta-de-la-semana)
- [Evangelios y comentarios](Evangelios-y-comentarios)
- [EFFA (Escuela)](EFFA-Escuela)
- [Autores](Autores)
- [Alta al boletín (Brevo)](Alta-boletin-Brevo)
### Migración
- [Resumen del proyecto](Resumen-del-proyecto)
- [Auditoría de migración](Auditoria-migracion)
- [Scripts de migración](Scripts-de-migracion)
- [Cutover DNS](Cutover-DNS)
### Planificación
- [Roadmap](Roadmap)
---
## Repo
- **Código:** http://localhost:3000/rafa/feadulta · https://farmer.taild3aaf6.ts.net/git/rafa/feadulta
- **Directorio local:** `/home/rafa/joomla-migration/`
- **Issues:** http://localhost:3000/rafa/feadulta/issues
- **Milestones:** `v1: Pre-cutover` · `v2: Post-launch`
+81
@@ -0,0 +1,81 @@
# Infraestructura
## Local (desarrollo)
Todo el entorno corre con Docker Compose en `/home/rafa/joomla-migration/`.
### Contenedores
| Container | Puerto | Rol |
|---|---|---|
| `wordpress-web` | 8081 | WordPress de trabajo (target de la migración) |
| `wordpress-mysql` | — (interno) | MySQL del WP local |
| `joomla-web` | 8080 | Joomla original restaurado (solo lectura, para consulta) |
| `joomla-db` | — (interno) | MySQL del Joomla legacy |
### URL local
WordPress se sirve detrás de Caddy + Tailscale:
```
https://farmer.taild3aaf6.ts.net/fea/
```
Login admin: ver [Credenciales y accesos](Credenciales-y-accesos).
### Mounts
```
/home/rafa/joomla-migration/wordpress/ → /var/www/html/ (container wordpress-web)
```
`mu-plugins/` está dentro de `wordpress/wp-content/mu-plugins/` → bind mount al container.
> **Aviso:** En sesiones pasadas, los mu-plugins se han **perdido del contenedor** tras `docker compose recreate` o restauraciones UpdraftPlus. La versión canónica siempre está en producción (`/web/wp-content/mu-plugins/`). Si algo desaparece del local, copiar desde prod.
### BD local
```
Host: 172.18.0.2
DB: wordpress_db
User: wordpress_user / wordpress_pass
```
### Comandos básicos
```bash
cd /home/rafa/joomla-migration
docker compose up -d # Levantar todo
docker compose down # Parar
docker logs wordpress-web # Ver logs WP
docker exec -it wordpress-web bash # Entrar al WP
docker exec wordpress-mysql mysql -uwordpress_user -pwordpress_pass wordpress_db
```
## Producción
Hosting Dreamhost compartido. Limitaciones importantes — ver [Limitaciones del servidor de producción](Limitaciones-servidor-prod).
| | |
|---|---|
| Host | `feadulta.org` |
| Usuario SSH | `feadultada` |
| Document root | `/web/` |
| PHP | 8.x, con `proc_open` desactivado |
| wp-cli | Instalado en `/web/`, invocar con `wp --path=/web/ <cmd>` |
| DB | MySQL local al server (127.0.0.1) |
## Git
- **Repo:** http://localhost:3000/rafa/feadulta (Gitea local, privado)
- **Working tree:** `/home/rafa/joomla-migration/`
- **.gitignore excluye:** WP core, plugins de terceros, temas, uploads, backups, joomla/, archivos sensibles de configuración
- **Trackeado:** mu-plugins, scripts/, docs, analisis-cartas, evangelios_html
## Tailscale
El PC está en la red Tailscale. Hostname: `farmer.taild3aaf6.ts.net`. Caddy lo expone:
- `/fea/` → WordPress local
- `/git/` → Gitea
- otros servicios bajo subpaths en el mismo Caddyfile
+72
@@ -0,0 +1,72 @@
# Limitaciones del servidor de producción
Hosting compartido (Dreamhost). Más restrictivo de lo habitual. **Leer antes** de plantear cualquier operación contra prod.
## Lo que NO hay instalado / disponible
| | Implicación |
|---|---|
| ❌ `sshpass` | No se puede automatizar `ssh` con password desde el server. Las conexiones se hacen desde el local con `paramiko`. |
| ❌ `sftp` | No se pueden subir ficheros por SFTP. Hay que usar **PHP + base64 + paramiko** (ver [Sincronización local → producción](Sincronizacion-local-prod)). |
| ❌ `python3` | Nada de scripts Python en el server. Los scripts viven en local y se conectan por SSH ejecutando PHP. |
| ❌ `base64` (cmdline) | No hay binario `base64` en `PATH`. Hay que usar PHP (`base64_decode`) para decodificar payloads. |
## PHP en prod
- Versión PHP: 8.x (compartido)
- **`proc_open` está desactivado** → cualquier librería que lo necesite falla:
- `wp-cli db query` → ❌ FALLA. Usar `mysql` directo.
- Cualquier wrapper que use `Symfony\Process` → riesgo de falla.
## wp-cli
✅ Está instalado en `/web/`. Se invoca con:
```bash
wp --path=/web/ <comando>
```
Funciona para la mayoría de operaciones (`post update`, `post create`, `term get`, `option get`, etc.). Solo falla lo que requiere `proc_open` (en la práctica: `wp db query`).
## MySQL
```
Host: 127.0.0.1 (NO está expuesto al exterior — solo accesible desde el server)
DB: 278025353wordpress20260112013937
User: myfeadultaa5
Pass: KjyGU29h
```
Comando directo (siempre vía SSH):
```bash
mysql -h127.0.0.1 -umyfeadultaa5 -pKjyGU29h 278025353wordpress20260112013937 -e "SELECT ..."
```
## Acceso SSH
```
ssh feadultada@feadulta.org # password: mzdY69rn0B2N-UIX
```
Tarda un poco en establecer la conexión (no usa key auth en este momento — sería bueno cambiarlo).
## Estrategia de operación
Por las limitaciones anteriores:
1. **Todo el código y los scripts viven en local.** El server solo ejecuta comandos sueltos.
2. **Para subir ficheros:** `paramiko` desde local + `php -r "file_put_contents(...)"` en server, payload codificado en base64.
3. **Para tocar BD:** `mysqldump` local + `mysql -e` en prod, transferidos por el mismo método de payload.
4. **Para automatizar:** scripts Python en local, NO en prod.
## Otros gotchas
- El **caché de Astra** puede mantener CSS viejo. Hay que limpiar manualmente desde wp-admin después de cambiar shortcodes/CSS.
- **UpdraftPlus** corre en prod. Si haces un restore puede sobreescribir mu-plugins recientes — ten cuidado tras un restore con verificar `fea-homepage.php` y `carta-semana-plugin.php`. Ya pasó al menos una vez con EFFA (2026-03-14).
- **HTTP Basic Auth** se desactiva en el cutover. Si haces tests automáticos contra prod ahora, hay que pasar las credenciales (`feadul316:X5nWjWrnPg7F`) o el server contesta 401.
## Pendientes / mejoras
- [ ] Cambiar a SSH key auth en lugar de password (sigue pendiente).
- [ ] Documentar en este repo el comando para limpiar caché (Astra + cualquier object cache).
+89
@@ -0,0 +1,89 @@
# Polylang (multiidioma)
5 idiomas: **ES, EN, FR, IT, PT** (heredados de Joomla). Polylang gestiona traducciones de posts, categorías, menús y páginas.
## Idiomas
| Idioma | term_taxonomy_id | URL prefix |
|---|---|---|
| ES | 1404 (`FEA_LANG_ES_TTID` en código) | sin prefijo (default) |
| EN | — | `/en/` |
| FR | — | `/fr/` |
| IT | — | `/it/` |
| PT | — | `/pt/` |
## 5 portadas
Cada idioma tiene su propia portada. Todas usan los shortcodes de `fea-homepage.php`.
| Idioma | Page ID |
|---|---|
| ES | 26542 |
| EN | 43889 |
| FR | 42756 |
| IT | 42757 |
| PT | 42758 |
`fea_is_front_page()` en `fea-homepage.php` detecta cualquiera de las 5 para inyectar el CSS de portada.
## Bug WP_Tax_Query con Polylang
Bug conocido en Polylang + WordPress core. La query:
```php
get_posts([
'lang' => $lang,
'category__in' => [$cat],
]);
```
genera SQL con `AND 0=1` cuando se combinan `lang` y `category__in` con `category__in` siendo numérico. La query devuelve vacío y, en contextos de archivo de categoría, **WordPress responde 404**.
### Fix
**NO** pasar `lang` explícito. Polylang ya filtra automáticamente por el idioma actual de la página/petición:
```php
get_posts([
'category__in' => [$cat], // sin 'lang'
]);
```
### Dónde afecta
- `/category/cartasemana/` en EN/FR/IT/PT → 404. Issue [#2](http://localhost:3000/rafa/feadulta/issues/2).
- Cualquier código que combine filtro por idioma y filtro por categoría numérica.
## Bug `get_term_by('slug', ...)` en idiomas no-ES
`get_term_by('slug', $slug, 'category')` **devuelve `false`** cuando el idioma actual no es ES, incluso si el término existe en la traducción.
### Fix
Saltar la API y usar `$wpdb` directo:
```php
$tt_id = $wpdb->get_var($wpdb->prepare(
"SELECT t.term_id FROM {$wpdb->terms} t
JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
WHERE t.slug = %s AND tt.taxonomy = %s",
$slug, 'category'
));
```
## Menú multiidioma
El selector de idioma en el header se renderiza con **JavaScript inyectado en `wp_footer`**, no con el widget de Polylang. Razones:
- Permite construir un mapa idioma → URL traducida de la página actual con `$wpdb` directo, bypaseando el filtro de Polylang.
- Resultado robusto incluso cuando el sitio está en un idioma minoritario.
## Comportamiento por defecto
- Idioma por defecto: **ES**.
- URLs sin prefijo → ES.
- Cookie + browser detection para redirigir a `/lang/` la primera visita (configurado en Polylang settings).
## Polylang admin
`wp-admin → Idiomas → Configuración`. La gestión de traducciones de post/categorías se hace desde cada editor individual con el widget "Idioma" en la sidebar.
+77
@@ -0,0 +1,77 @@
# Portada y shortcodes
Todo el render de la portada vive en el mu-plugin `fea-homepage.php`.
- **Local:** `wordpress/wp-content/mu-plugins/fea-homepage.php` (trackeado en git)
- **En el container:** `/var/www/html/wp-content/mu-plugins/fea-homepage.php`
- **Producción:** `/web/wp-content/mu-plugins/fea-homepage.php`
## Shortcodes de portada
| Shortcode | Qué muestra |
|---|---|
| `[fea_carta_semana_hero]` | Hero principal con la carta de la semana actual |
| `[fea_articulos_semana]` | Grid de artículos vinculados a la carta de la semana |
| `[fea_evangelio]` | Bloque de evangelio del domingo |
| `[fea_eucaristia]` | Bloque de eucaristía |
| `[fea_multimedia]` | Bloque de multimedia reciente |
| `[fea_noticia_centro]` | Bloque de "Noticias de alcance" del footer (template part ID 42370) |
Todos los artículos de portada se renderizan con la helper `fea_card()` para que el card sea consistente (avatar + autor + título + extracto).
## Helpers
### `fea_card($post_id, $opts = [])`
Renderiza una tarjeta con imagen, título, autor y excerpt. Es la unidad visual común de la portada.
### `fea_title($str)`
Convierte títulos de MAYÚSCULAS de la BD a *sentence case* (primera letra mayúscula, resto minúsculas). **Comportamiento intencional**: los títulos legacy de Joomla/K2 estaban en TODO CAPS y se normalizan al renderizar.
### `fea_is_front_page()`
Detecta si estamos en la portada (cualquiera de los 5 idiomas). Usado para inyectar el CSS de portada solo donde toca.
## CSS de portada
Inyectado en `wp_head` solo cuando `fea_is_front_page()` es true. No es CSS estático ni en el tema — vive en `fea-homepage.php`.
### Clases relevantes
| Clase | Uso |
|---|---|
| `.fea-section-title` | Etiqueta uppercase pequeña con borde izquierdo carmesí `#8b1a2e` (color marca) |
| `.n2-ss-slide--focus` | `visually-hidden` para evitar que los nombres de fichero del Smart Slider 3 aparezcan como texto leído por pantallas |
| `.fea-card-*` | Variantes de tarjeta |
### Regla crítica
El tema Astra **ignora CSS externo** para ciertas propiedades de imagen y radios. Usar **inline styles** para:
- `border-radius`
- `list-style`
- `height: auto` en imágenes
Detalle completo en [CSS y bugs del tema Astra](CSS-y-bugs-Astra).
## 5 portadas (multiidioma)
Cada idioma tiene su propia página de portada en Polylang. El mismo shortcode `fea_is_front_page()` las reconoce todas.
| Idioma | Page ID |
|---|---|
| ES | 26542 |
| EN | 43889 |
| FR | 42756 |
| IT | 42757 |
| PT | 42758 |
## Otros shortcodes definidos en `fea-homepage.php`
| Shortcode | Sección | Documentado en |
|---|---|---|
| `[effa_proyecto]` | Hub EFFA | [EFFA (Escuela)](EFFA-Escuela) |
| `[effa_seccion cat="..."]` | Subpáginas EFFA | [EFFA (Escuela)](EFFA-Escuela) |
| `[fea_autores_habituales]` | Listado autores | [Autores](Autores) |
| `[fea_autores_completo]` | Listado autores | [Autores](Autores) |
| `[fea_citas_evangelio libro="..."]` | Posts evangelios | [Evangelios y comentarios](Evangelios-y-comentarios) |
+50
@@ -0,0 +1,50 @@
# Resumen del proyecto
## Qué es feadulta
Portal cristiano español de renovación de la fe (*fe adulta*). Sitio editorial con casi 20 años de contenido — cartas semanales, comentarios al evangelio, multimedia y escuela de formación.
**Volumen:**
- ~24.778 posts en WordPress
- 1.182 usuarios
- Multiidioma ES / EN / FR / IT / PT (Polylang)
## Origen → destino
| | Joomla (origen) | WordPress (destino) |
|---|---|---|
| Plataforma | Joomla + K2 (legacy) | WordPress 6 + Astra + FSE Twenty Twenty-Five |
| Posts editoriales | 15.764 K2 items publicados | 24.631 posts (publish) + 1.922 drafts |
| Multiidioma | Joomla nativo | Polylang |
| Hosting | Dreamhost compartido | Mismo servidor (manteniendo dominio) |
El plugin usado para la importación fue **FG Joomla to WordPress Premium + módulo K2**.
## Estado actual (2026-05-20)
- Migración inicial **completada el 2026-03-09**: 15.978 K2 items → 24.778 posts WP.
- Delta de mayo importado: 169 K2 nuevos + 8 cartas + 58 misceláneas + 8 noticias de alcance (IDs 4391444159 en WP).
- Sección EFFA, Evangelios y Comentarios, Cartas Semanales, Autores y Brevo: completados.
- Portada renovada (slider, separadores, tipografía, autor consistente).
- Web protegida por **HTTP Basic Auth** hasta el cutover.
- **Cutover DNS previsto: ~junio 2026.**
## Cronología
| Fecha | Hito |
|---|---|
| 2026-03-04 | Auditoría inicial: 93.030 enlaces internos corregidos, 17.853 redirects 301 creados |
| 2026-03-06 | Sección EFFA: hub + 6 subpáginas |
| 2026-03-09 | Cierre migración K2 grande (15.978 items) |
| 2026-03-13 | Alta boletín Brevo |
| 2026-03-14 | Listado de autores, restauración shortcodes EFFA |
| 2026-03-15 | Evangelios y Comentarios completados, metas migradas a prod |
| 2026-05-03/04 | Delta de cartas, K2 nuevos y misceláneas |
| 2026-06 (prev.) | Cutover DNS |
## Pendientes principales
- v1 Pre-cutover: fix carta de la semana 404 en idiomas, script cutover, sync BD local→prod, auditoría visual (issues #18-#32), test completo
- v2 Post-launch: AdSense, TTS, Wordfence, buscador avanzado (Typesense)
Ver [Roadmap](Roadmap) y los issues del repo para el detalle.
+67
@@ -0,0 +1,67 @@
# Roadmap
Trabajo organizado en dos milestones temáticos. Ver el board completo en http://localhost:3000/rafa/feadulta/milestones.
## v1: Pre-cutover
Todo lo que debe estar resuelto **antes** del cutover DNS de ~junio 2026.
### Infraestructura
- [#3](http://localhost:3000/rafa/feadulta/issues/3) Preparar y revisar script de cutover DNS · `area:infra`
- [#4](http://localhost:3000/rafa/feadulta/issues/4) Sincronizar base de datos local → producción antes del cutover · `area:infra`
- [#1](http://localhost:3000/rafa/feadulta/issues/1) Test completo antes del cutover DNS · `improvement`
### Bugs
- [#2](http://localhost:3000/rafa/feadulta/issues/2) Fix carta de la semana 404 en idiomas EN/FR/IT/PT · `bug:important`
- [#18](http://localhost:3000/rafa/feadulta/issues/18) fix: imagen rota en bloque central pre-footer (Noticias de alcance)
### Auditoría visual reciente (#19-#32)
Resultados de la auditoría comparativa Joomla→WP. Issues a cerrar antes del cutover:
- [#19](http://localhost:3000/rafa/feadulta/issues/19) SEO: quitar `noindex/nofollow` antes de publicar
- [#20](http://localhost:3000/rafa/feadulta/issues/20) Idiomas: slugs y títulos cruzados en portugués y francés
- [#21](http://localhost:3000/rafa/feadulta/issues/21) Menú: enlace vacío en "Reseñas de libros"
- [#22](http://localhost:3000/rafa/feadulta/issues/22) Home: imagen `publi_2-1.gif` devuelve 404
- [#23](http://localhost:3000/rafa/feadulta/issues/23) Archivo anterior: enlace "Otros textos web anterior" devuelve 403
- [#24](http://localhost:3000/rafa/feadulta/issues/24) Estructura: portada y Escuela sin H1
- [#25](http://localhost:3000/rafa/feadulta/issues/25) Accesibilidad: muchas imágenes de la portada no tienen texto alt
- [#26](http://localhost:3000/rafa/feadulta/issues/26) Navegación: categorías principales redirigen a artículos concretos
- [#27](http://localhost:3000/rafa/feadulta/issues/27) Móvil: las tarjetas de Escuela quedan demasiado estrechas
- [#28](http://localhost:3000/rafa/feadulta/issues/28) Páginas estáticas: aparecen metadatos tipo autor/categoría bajo el título
- [#29](http://localhost:3000/rafa/feadulta/issues/29) Páginas interiores: exceso de espacio vertical antes del contenido
- [#30](http://localhost:3000/rafa/feadulta/issues/30) Buscador avanzado: falta equivalente funcional al formulario facetado de Joomla
- [#31](http://localhost:3000/rafa/feadulta/issues/31) Menú Ayuda: faltan accesos estructurales presentes en Joomla
- [#32](http://localhost:3000/rafa/feadulta/issues/32) Multimedia: el enlace del footer apunta a un archivo plano, no a la landing estructural
### Ya cerrados en v1 (9 issues)
[#9](http://localhost:3000/rafa/feadulta/issues/9) Joomla template infectado · [#10](http://localhost:3000/rafa/feadulta/issues/10) Smart Slider nombres de fichero · [#11](http://localhost:3000/rafa/feadulta/issues/11) h2 "Portada" visible · [#12](http://localhost:3000/rafa/feadulta/issues/12) Títulos en minúsculas · [#13](http://localhost:3000/rafa/feadulta/issues/13) Quitar emojis del menú · [#14](http://localhost:3000/rafa/feadulta/issues/14) Carta de la semana sin imagen ni excerpt · [#15](http://localhost:3000/rafa/feadulta/issues/15) Separadores visuales entre secciones · [#16](http://localhost:3000/rafa/feadulta/issues/16) Tipografía y jerarquía h2 · [#17](http://localhost:3000/rafa/feadulta/issues/17) Autor consistente en portada
## v2: Post-launch
Mejoras que requieren el dominio activo o son no-bloqueantes para el cutover.
- [#5](http://localhost:3000/rafa/feadulta/issues/5) Instalar y configurar **AdSense** · `improvement` `area:plugin` (requiere dominio activo)
- [#6](http://localhost:3000/rafa/feadulta/issues/6) Instalar **TTS** (text-to-speech) · `improvement` `area:plugin`
- [#7](http://localhost:3000/rafa/feadulta/issues/7) Instalar **Wordfence** · `improvement` `area:plugin`
- [#8](http://localhost:3000/rafa/feadulta/issues/8) Buscador avanzado con **Typesense** · `improvement`
- ~24.778 posts. Relevanssi descartado (570MB de índice).
## Convenciones
### Labels usados
- `bug:critical` (#b91c1c), `bug:important` (#ea580c)
- `improvement` (#16a34a), `inconsistency` (#ca8a04)
- `security` (#db2777)
- `area:aesthetic`, `area:content`, `area:infra`, `area:joomla`, `area:plugin`
Si añades issues nuevos, usa estos labels para que el filtrado siga funcionando.
### Cerrar issue desde commit
```
Fix carta semana 404 en idiomas
Closes #2
```
Gitea cierra automáticamente el issue cuando el commit llega a `main`.
+115
@@ -0,0 +1,115 @@
# Scripts de migración
Todos en `/home/rafa/joomla-migration/scripts/`. Trackeados en git.
## Scripts del delta de mayo 2026
### `import_new_k2_items.py`
Importa K2 items nuevos de Joomla (id > 17873) vía SSH + HEX. Resultado: 169 items → WP IDs **4391444082**.
### `fix_imported_k2_metas.py`
Asigna metas, categorías y Polylang a los K2 items ya creados. Usa offset `wp_id = k2_id + 26040` para mapear.
### `import_new_cartas.py`
Importa cartas (catid 27/40/41, id > 9043) y asigna `_carta_id` a los K2 items por *matching de fecha*. Resultado: 8 cartas nuevas → WP IDs **4408344090**.
### `import_new_content.py`
Importa ítems de `ew4r_content` que NO son cartas (multimedia, noticias, tablón). 58 items → WP IDs **4409144151**.
## Notas técnicas comunes a los scripts de delta
> Estos son los problemas reales que aparecieron cuando se escribieron los scripts. No los olvides al hacer otro delta.
### 1. `HEX()` para campos con HTML
Las queries por SSH usan `HEX(campo)` para evitar que el HTML con saltos de línea y caracteres especiales rompa el parsing TSV cuando el resultado vuelve por stdout.
```sql
SELECT id, HEX(content) FROM ew4r_k2_items WHERE id > ...
```
Luego en Python: `bytes.fromhex(row[1]).decode('utf-8')`.
### 2. Query por stdin
Pasar la query SQL **por stdin** (`input=query` en `subprocess.run`) para evitar problemas con backticks, comillas y caracteres de shell.
```python
res = subprocess.run(['ssh', host, 'mysql -uX -pY db'],
input=query, capture_output=True, text=True)
```
### 3. `MAX(ID)` en vez de `LAST_INSERT_ID()`
```sql
SELECT MAX(ID) FROM wp_posts; -- ✅
SELECT LAST_INSERT_ID(); -- ❌ cada subprocess abre sesión MySQL nueva
```
Cada llamada SSH abre una sesión MySQL distinta, así que `LAST_INSERT_ID()` no ve el INSERT del subprocess anterior.
### 4. MySQL strict mode
Los `INSERT wp_posts` deben incluir todos los campos obligatorios incluso si están vacíos:
```sql
to_ping = ''
pinged = ''
post_content_filtered = ''
```
Si no, MySQL rechaza el INSERT con strict mode.
## Otros scripts en `scripts/`
### Importación inicial (ya ejecutados)
| Script | Estado |
|---|---|
| `assign_author_photos.php` | ✅ ejecutado |
| `assign_polylang_languages.php` | ✅ ejecutado |
| `assign_polylang_prod.php` | ✅ ejecutado en prod |
| `export_translations.py` / `export_cat_translations.py` | Exportadores de traducciones |
| `audit_translations.py` | Auditor de cobertura de traducciones |
| `retranslate_*.py` (5 ficheros) | Re-traducción por chunks/idiomas/fallos |
| `fix_joomla_links.php` | ✅ 2026-03-04: 93.030 links reemplazados en 3.400 posts |
| `fix_numeric_categories.php` | ✅ 2026-03-04: 100 categorías numéricas → autores correctos |
| `fix_remaining_titles.py`, `fix_titles.py` | Normalización de títulos |
| `generate_k2_redirects.php` | ✅ 2026-03-04: 17.853 redirects 301 en `wp_fg_redirect` |
| `translate_cartas.py` | Traducción de cartas semanales |
| `setup-wordpress.sh` | Setup inicial del WP local |
| `test_5articles.py` | Test smoke de 5 artículos |
| `cutover_feadulta_com.sh` | **Cutover DNS** — ver [Cutover DNS](Cutover-DNS) |
### Templates / mu-plugins
| Fichero | Notas |
|---|---|
| `fea-homepage.php` | Versión de trabajo de portada/shortcodes (también en `wp-content/mu-plugins/`) |
| `fea-homepage-template.php` | Template suelto |
| `carta-semana-plugin.php` | Versión de trabajo del plugin de carta de la semana |
## Bug conocido `wp-cli` + categorías numéricas
```bash
wp post term set <ID> category 28 # ❌ crea término "28"
wp post term set <ID> category evangelios-y-comentarios # ✅
```
`wp post term set ID category N` interpreta `N` como **nombre**, no como term_id.
**Regla:** siempre usar slug. Ver [Evangelios y comentarios](Evangelios-y-comentarios).
## Bug conocido `wp-cli` en prod
```bash
wp --path=/web/ db query "SELECT ..." # ❌ FALLA (proc_open desactivado)
```
`proc_open` está desactivado en el server. `wp db query` y todo lo que invoque `mysql` por dentro falla. **Usar `mysql` directo** vía SSH para queries.
Ver [Limitaciones del servidor de producción](Limitaciones-servidor-prod).
+134
@@ -0,0 +1,134 @@
# Sincronización local → producción
Regla general: cualquier cambio en local debe replicarse en producción **del mismo modo**, porque los IDs y las versiones de código viven en paralelo.
Antes de tocar nada, lee [Limitaciones del servidor de producción](Limitaciones-servidor-prod) — sin esas restricciones, lo que sigue no tiene sentido.
## Tipos de cambio y método
| Cambio | Cómo replicar a producción |
|---|---|
| **mu-plugins** (`fea-homepage.php`, `carta-semana-plugin.php`) | PHP + base64 + `paramiko` (ver más abajo) |
| **Posts / páginas nuevas** | `wp --path=/web/ post create` vía SSH (paramiko) |
| **Metas de BD masivas** | `mysqldump` local + `mysql` directo en prod |
| **Cambios puntuales en BD** | `mysql -h127.0.0.1 -u... -p... DB -e "SQL"` vía SSH |
| **Files (imágenes, JS, CSS)** | Igual que mu-plugins: PHP + base64 + paramiko |
## Método canónico: PHP + base64 + paramiko
Funciona porque NO requiere `sftp`, `sshpass` ni `base64` en el server — solo PHP y SSH.
```python
import paramiko, base64
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect('feadulta.org',
username='feadultada',
password='mzdY69rn0B2N-UIX')
with open('/local/path/file.php', 'rb') as f:
content = f.read()
# Importante: vaciar el destino antes de empezar (file_put_contents con FILE_APPEND)
client.exec_command("php -r \"file_put_contents('/ruta/destino', '');\"")
chunk_size = 50_000 # 50 KB
for i in range(0, len(content), chunk_size):
chunk = content[i:i + chunk_size]
b64 = base64.b64encode(chunk).decode()
cmd = f"php -r \"file_put_contents('/ruta/destino', base64_decode('{b64}'), FILE_APPEND);\""
client.exec_command(cmd)
```
> El chunk de 50KB evita que el shell rechace la línea por longitud. PHP `base64_decode` lo reconstruye binariamente en destino.
## Importar SQL en prod
Después de generar `/tmp/algo.sql` (en local o subido a prod por el método anterior):
```bash
mysql -h127.0.0.1 -umyfeadultaa5 -pKjyGU29h \
278025353wordpress20260112013937 < /tmp/algo.sql
```
Vía paramiko:
```python
client.exec_command(
"mysql -h127.0.0.1 -umyfeadultaa5 -pKjyGU29h "
"278025353wordpress20260112013937 < /tmp/algo.sql"
)
```
## Export de metas/posts desde local
```bash
docker exec wordpress-mysql mysqldump \
-uwordpress_user -pwordpress_pass wordpress_db wp_postmeta \
--where="meta_key IN ('_cita_evangelio','_carta_id')" \
--no-create-info --complete-insert --skip-extended-insert \
> /tmp/metas.sql
# Quitar el meta_id para evitar conflictos de PK en prod
sed -i 's/INSERT INTO `wp_postmeta` (`meta_id`, /INSERT INTO `wp_postmeta` (/g; s/(NULL, /(/g' /tmp/metas.sql
```
## Sincronizar IDs entre local y prod
Cuando local y prod crean el mismo post con IDs distintos (porque WP autoincrementa por separado), arreglar con UPDATEs directos en prod **usando offset temporal** para evitar conflictos de PK.
```sql
-- 1) Mover a IDs temporales (offset +99000)
UPDATE wp_posts SET ID = ID + 99000
WHERE ID IN (43905, 43906, 43907, 43908);
UPDATE wp_postmeta SET post_id = post_id + 99000
WHERE post_id IN (43905, 43906, 43907, 43908);
UPDATE wp_term_relationships SET object_id = object_id + 99000
WHERE object_id IN (43905, 43906, 43907, 43908);
-- 2) Mover a los IDs finales (los de local)
UPDATE wp_posts SET ID = 43906 WHERE ID = 143904;
UPDATE wp_postmeta SET post_id = 43906 WHERE post_id = 143904;
UPDATE wp_term_relationships SET object_id = 43906 WHERE object_id = 143904;
-- (... repetir para cada post a sincronizar)
```
### Lección clave
> **No uses IDs hardcodeados distintos por entorno** ni queries dinámicas tipo `get_page_by_path()` para resolver IDs de posts conocidos. Sincroniza los IDs entre local y prod desde el momento en que crees el post, y hardcodea con seguridad.
Esta lección viene del trabajo con [Evangelios y comentarios](Evangelios-y-comentarios).
## wp-cli en producción
```bash
wp --path=/web/ <comando>
```
`proc_open` está desactivado → `wp db query` y similares **NO funcionan**. Usar `mysql` directo.
## Flujo típico cuando cambias un shortcode
1. Editas `fea-homepage.php` en `/home/rafa/joomla-migration/wordpress/wp-content/mu-plugins/`.
2. Verificas en local (https://farmer.taild3aaf6.ts.net/fea/).
3. Subes a prod con paramiko + base64.
4. Vacías cualquier caché en prod (Astra / object cache si aplica).
5. Commit + push al repo Gitea.
## Backup antes de tocar prod
Antes de un UPDATE masivo o de tocar un fichero PHP en prod:
```bash
# Backup del mu-plugin
ssh feadultada@feadulta.org "cp /web/wp-content/mu-plugins/fea-homepage.php \
/web/wp-content/mu-plugins/fea-homepage.php.bak.$(date +%Y%m%d-%H%M)"
# Backup parcial de BD
ssh feadultada@feadulta.org "mysqldump -h127.0.0.1 -umyfeadultaa5 -pKjyGU29h \
278025353wordpress20260112013937 wp_postmeta \
--where=\"meta_key='_carta_id'\" > /tmp/backup_carta_id.sql"
```
UpdraftPlus también está corriendo en prod, pero el backup manual es más rápido para cambios puntuales.