Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5704d7b4a |
@@ -17,10 +17,6 @@ joomla-php83/
|
|||||||
backups/*
|
backups/*
|
||||||
!backups/README.md
|
!backups/README.md
|
||||||
|
|
||||||
# Backups pesados (GBs) bajo docs/ — mismo motivo que backups/*, ruta distinta
|
|
||||||
docs/backups/*
|
|
||||||
!docs/backups/README.md
|
|
||||||
|
|
||||||
# Capturas de pantalla
|
# Capturas de pantalla
|
||||||
capturas/
|
capturas/
|
||||||
|
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
# Release dry-run — Enrique Martínez Lozano / carta «Hacia el corazón»
|
|
||||||
|
|
||||||
**Estado:** preparado localmente; **no ejecutado en producción**.
|
|
||||||
|
|
||||||
## Validación local completada
|
|
||||||
|
|
||||||
- Comentario ES: `#55041` — `El tesoro está ya en nosotros`
|
|
||||||
- Categorías: `Comentarios al evangelio` + `Feadulta`.
|
|
||||||
- Audio: `tts/55041.mp3`, 2,221,101 bytes, voz `NicoFeadulta2026`.
|
|
||||||
- QA Haiku: aprobado tras correcciones objetivas EN/FR/IT.
|
|
||||||
- Traducciones del comentario: EN `#55047`, FR `#55048`, IT `#55049`, PT `#55050`.
|
|
||||||
- Carta local ES: `#55042` — `Hacia el corazón` (slug técnico de preview conservado).
|
|
||||||
- Traducciones: EN `#55051`, FR `#55052`, IT `#55053`, PT `#55054`.
|
|
||||||
- QA Haiku: contenido aprobado tras correcciones; 24 enlaces con destino traducido se repuntaron. Los 12 enlaces externos que quedan son fallback ES: no existe post destino traducido en local (verificado por `post_title` exacto y Polylang).
|
|
||||||
- Render comprobado en Tailscale:
|
|
||||||
- Comentario ES: título correcto, categorías correctas y reproductor HTML5 visible.
|
|
||||||
- Carta FR: título correcto; Enrique aparece tras Fray Marcos y antes de Pagola.
|
|
||||||
|
|
||||||
## Evidencia de producción (sólo lectura server-side)
|
|
||||||
|
|
||||||
- Carta real: `#54914`, título `Hacia el corazón`, estado `publish`.
|
|
||||||
- Hash actual de contenido prod: `5e7eb1b880bfc6311edb78ac8b6e81639f5b4502282a1b182c610b1d01308b69`.
|
|
||||||
- Hash candidato local: `6fdb4929fcb5b49b7f2dc1deb7c6f5be70983af866d44c5d5c27d2866c09418c`.
|
|
||||||
- La carta candidata lleva `fea_phase_a_source_prod_id=54914`; por tanto debe **actualizar #54914**, no crearse una carta nueva.
|
|
||||||
|
|
||||||
## Dry-runs que pasaron
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Crea solamente el grupo nuevo del comentario (ES + EN/FR/IT/PT): 5 posts draft
|
|
||||||
python3 scripts/sync_translations_to_prod.py --ids 55041 --dry-run
|
|
||||||
|
|
||||||
# Sube y asocia solamente el MP3 de Enrique
|
|
||||||
python3 scripts/sync_audio_to_prod.py --ids 55041 --dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
Ambos devolvieron `ok/error=1/0` (audio) y un grupo Polylang de cinco posts (comentario).
|
|
||||||
|
|
||||||
## Guardarraíl crítico
|
|
||||||
|
|
||||||
**NO ejecutar:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/sync_translations_to_prod.py --ids 55041,55042
|
|
||||||
```
|
|
||||||
|
|
||||||
El dry-run demostró que clonaría `#55042` como una segunda carta ES. No actualiza la carta real `#54914`.
|
|
||||||
|
|
||||||
## Release que debe ejecutar Rafa (producción)
|
|
||||||
|
|
||||||
1. Backup de producción y snapshot server-side de `#54914`.
|
|
||||||
2. Crear el grupo nuevo del comentario desde `#55041` (ES + EN/FR/IT/PT), inicialmente en `draft`; anotar los IDs reales de producción.
|
|
||||||
3. Subir `tts/55041.mp3` y asociarlo al ID ES real creado en producción.
|
|
||||||
4. Actualizar **solamente** título/contenido de la carta real `#54914` con el candidato local `#55042`; mantener título `Hacia el corazón`, autor, fecha y estado existentes.
|
|
||||||
5. Crear EN/FR/IT/PT de la carta contra el origen real `#54914` y enlazarlas en Polylang. No clonar otra ES.
|
|
||||||
6. Releer por servidor los IDs creados y `#54914`; comprobar:
|
|
||||||
- Enrique sólo está en `Evangelio y comentarios al Evangelio`, tras Fray Marcos y antes de Pagola.
|
|
||||||
- No aparece en `Artículos seleccionados`.
|
|
||||||
- Cada carta enlaza a Enrique en su mismo idioma.
|
|
||||||
- El reproductor del ES carga `tts/55041.mp3`.
|
|
||||||
7. Sólo tras esas lecturas, promover los drafts de producción a `publish`.
|
|
||||||
|
|
||||||
## Rollback
|
|
||||||
|
|
||||||
- Restaurar el backup de producción previo.
|
|
||||||
- Alternativamente, restaurar el contenido/hash previo de `#54914` y despublicar el nuevo grupo + audio de Enrique.
|
|
||||||
|
|
||||||
## Backups locales relacionados
|
|
||||||
|
|
||||||
- `docs/backups/enrique-translations-20260725T004921Z/`
|
|
||||||
- `docs/backups/enrique-carta-fr-final-20260725T012649Z/`
|
|
||||||
- `docs/backups/enrique-pre-tts-20260725T013021Z/`
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Issue #62 — Reapunta foto_perfil de cada autor a su avatar nuevo.
|
|
||||||
* Crea un attachment por autor y guarda el foto_perfil antiguo en _foto_perfil_pre62 (revertible).
|
|
||||||
* Uso: wp eval-file import_avatars_62.php [--apply]
|
|
||||||
* sin --apply => dry run (no escribe nada).
|
|
||||||
*/
|
|
||||||
require_once ABSPATH . 'wp-admin/includes/image.php';
|
|
||||||
|
|
||||||
$apply = getenv('APPLY') === '1';
|
|
||||||
$mfile = getenv('MANIFEST') ?: 'manifest_62.json';
|
|
||||||
$updir = wp_get_upload_dir();
|
|
||||||
$manifest = json_decode(file_get_contents($updir['basedir'] . '/avatares/' . $mfile), true);
|
|
||||||
|
|
||||||
$done = $skip = $err = 0;
|
|
||||||
foreach ($manifest as $row) {
|
|
||||||
list($uid, $name, $orig, $kind, $old_attach) = $row;
|
|
||||||
if (!in_array($kind, ['initials','trim','logo','qs'], true)) { $skip++; continue; }
|
|
||||||
|
|
||||||
$rel = "avatares/autores/autor-{$uid}.png";
|
|
||||||
$abs = $updir['basedir'] . '/' . $rel;
|
|
||||||
if (!file_exists($abs)) { echo "MISSING file uid=$uid\n"; $err++; continue; }
|
|
||||||
|
|
||||||
// si foto_perfil ya apunta a este fichero, solo se ha sobrescrito el PNG: no crear attachment nuevo
|
|
||||||
$cur = (int) get_user_meta($uid, 'foto_perfil', true);
|
|
||||||
if ($cur && get_post_meta($cur, '_wp_attached_file', true) === $rel) {
|
|
||||||
if ($apply) wp_update_attachment_metadata($cur, wp_generate_attachment_metadata($cur, $abs));
|
|
||||||
$skip++; continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$apply) { $done++; continue; }
|
|
||||||
|
|
||||||
// guardar foto_perfil antiguo una sola vez (idempotente)
|
|
||||||
if (get_user_meta($uid, '_foto_perfil_pre62', true) === '') {
|
|
||||||
update_user_meta($uid, '_foto_perfil_pre62', $old_attach);
|
|
||||||
}
|
|
||||||
|
|
||||||
$attach = [
|
|
||||||
'post_mime_type' => 'image/png',
|
|
||||||
'post_title' => "Avatar {$name}",
|
|
||||||
'post_status' => 'inherit',
|
|
||||||
'guid' => $updir['baseurl'] . '/' . $rel,
|
|
||||||
];
|
|
||||||
$aid = wp_insert_attachment($attach, $abs, 0, true);
|
|
||||||
if (is_wp_error($aid)) { echo "ERR insert uid=$uid: ".$aid->get_error_message()."\n"; $err++; continue; }
|
|
||||||
wp_update_attachment_metadata($aid, wp_generate_attachment_metadata($aid, $abs));
|
|
||||||
update_user_meta($uid, 'foto_perfil', $aid);
|
|
||||||
$done++;
|
|
||||||
}
|
|
||||||
echo ($apply ? "APLICADO" : "DRY-RUN") . ": procesados=$done omitidos=$skip errores=$err\n";
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -13,21 +13,6 @@ server {
|
|||||||
# application/octet-stream y el navegador se los descarga en vez de mostrarlos.
|
# application/octet-stream y el navegador se los descarga en vez de mostrarlos.
|
||||||
default_type text/html;
|
default_type text/html;
|
||||||
|
|
||||||
# URLs históricas de catálogo: el catálogo vigente vive en Ediciones Fe Adulta.
|
|
||||||
# 302 primero: evita cachear un destino externo de forma irreversible durante el soak.
|
|
||||||
location ~ ^/(?:es/)?catalogo-de-libros-feadulta(?:/|\.html)?$ {
|
|
||||||
return 302 https://edicionesfeadulta.com/;
|
|
||||||
}
|
|
||||||
|
|
||||||
# La vista imprimible Joomla de esta página EFFA no existe en el archivo estático.
|
|
||||||
# Conserva la página normal del archivo; sólo la variante ?print=1 va al contenido WP vigente.
|
|
||||||
location = /es/effa/95-secc5cat/3304-seccion5col00.html {
|
|
||||||
if ($arg_print = 1) {
|
|
||||||
return 302 https://www.feadulta.com/seccion5col00-2/;
|
|
||||||
}
|
|
||||||
try_files $uri $uri.html $uri/index.html =404;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri.html $uri/index.html =404;
|
try_files $uri $uri.html $uri/index.html =404;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
portfolio-tracker
|
|
||||||
yt-api-bridge
|
|
||||||
wordpress-web
|
|
||||||
joomla-web-php83
|
|
||||||
jellyfin
|
|
||||||
triptyk-local-wordpress-1
|
|
||||||
triptyk-local-db-1
|
|
||||||
yt-summaries
|
|
||||||
joomla-web
|
|
||||||
wordpress-mysql
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Retira exclusivamente el esquema vacío fewp1 de CDMON.
|
|
||||||
# Por defecto solo hace dry-run. La eliminación real exige --apply y el token exacto.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
mode="dry-run"
|
|
||||||
if [[ "${1:-}" == "--apply" ]]; then
|
|
||||||
mode="apply"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$mode" == "apply" && "${CONFIRM_DROP_FEWP1:-}" != "DROP_FEWP1" ]]; then
|
|
||||||
echo "Refusing apply: export CONFIRM_DROP_FEWP1=DROP_FEWP1 first." >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
set -a
|
|
||||||
source "$HOME/.hermes/profiles/feadulta/.env"
|
|
||||||
set +a
|
|
||||||
|
|
||||||
php='global $wpdb;
|
|
||||||
$count=(int)$wpdb->get_var("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=\"fewp1\"");
|
|
||||||
if ($count !== 0) { fwrite(STDERR, "ABORT: fewp1 has ".$count." tables; it is not empty.\n"); exit(3); }
|
|
||||||
echo "PASS: fewp1 exists and has 0 tables.\n";'
|
|
||||||
|
|
||||||
if [[ "$mode" == "apply" ]]; then
|
|
||||||
php+=' $ok=$wpdb->query("DROP DATABASE `fewp1`");
|
|
||||||
if ($ok === false) { fwrite(STDERR, "ABORT: DROP DATABASE failed.\n"); exit(4); }
|
|
||||||
$remaining=(int)$wpdb->get_var("SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name=\"fewp1\"");
|
|
||||||
if ($remaining !== 0) { fwrite(STDERR, "ABORT: fewp1 remains after DROP.\n"); exit(5); }
|
|
||||||
echo "APPLIED: fewp1 removed; verified absent.\n";'
|
|
||||||
else
|
|
||||||
php+=' echo "DRY-RUN: would execute DROP DATABASE `fewp1`; no production change made.\n";'
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf -v quoted_php '%q' "$php"
|
|
||||||
sshpass -p "$FEA_PROD_SSH_PASS" ssh -o StrictHostKeyChecking=accept-new "$FEA_PROD_SSH_HOST" \
|
|
||||||
"cd /web && wp eval $quoted_php"
|
|
||||||
@@ -262,47 +262,6 @@ switch ($cmd) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'clone_new': {
|
|
||||||
// Clona en un ID local NUEVO. Se usa al importar desde prod cuando el
|
|
||||||
// mismo ID ya está ocupado por contenido local distinto.
|
|
||||||
$lang = (string) ($argv[2] ?? '');
|
|
||||||
$status = (string) ($argv[3] ?? '');
|
|
||||||
if ($lang === '') {
|
|
||||||
fwrite(STDERR, "uso: clone_new <lang> <status>\n"); exit(6);
|
|
||||||
}
|
|
||||||
$payload = json_decode(file_get_contents('php://stdin'), true);
|
|
||||||
if (!is_array($payload) || empty($payload['title'])) {
|
|
||||||
fwrite(STDERR, "payload inválido por stdin\n"); exit(4);
|
|
||||||
}
|
|
||||||
$postarr = [
|
|
||||||
'post_title' => wp_slash($payload['title']),
|
|
||||||
'post_content' => wp_slash($payload['content'] ?? ''),
|
|
||||||
'post_excerpt' => wp_slash($payload['excerpt'] ?? ''),
|
|
||||||
'post_status' => $status ?: ($payload['status'] ?? 'draft'),
|
|
||||||
'post_type' => $payload['type'] ?? 'post',
|
|
||||||
'post_author' => (int) ($payload['author'] ?? 1),
|
|
||||||
'post_date' => $payload['date'] ?? current_time('mysql'),
|
|
||||||
'post_date_gmt'=> $payload['date_gmt'] ?? current_time('mysql', true),
|
|
||||||
'post_name' => $payload['slug'] ?? '',
|
|
||||||
'to_ping' => '',
|
|
||||||
'pinged' => '',
|
|
||||||
];
|
|
||||||
$new_id = wp_insert_post($postarr, true);
|
|
||||||
if (is_wp_error($new_id)) { fwrite(STDERR, $new_id->get_error_message() . "\n"); exit(5); }
|
|
||||||
pll_set_post_language($new_id, $lang);
|
|
||||||
$cats = [];
|
|
||||||
foreach ((array) ($payload['cat_slugs'] ?? []) as $slug) {
|
|
||||||
$term = get_term_by('slug', (string) $slug, 'category');
|
|
||||||
if ($term && !is_wp_error($term)) $cats[] = (int) $term->term_id;
|
|
||||||
}
|
|
||||||
if (!$cats) $cats = array_values(array_unique(array_map('intval', (array) ($payload['cats'] ?? []))));
|
|
||||||
wp_set_post_categories($new_id, $cats);
|
|
||||||
set_meta_payload($new_id, normalize_meta_input($payload));
|
|
||||||
clean_post_cache($new_id);
|
|
||||||
echo $new_id;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'carta_sections': {
|
case 'carta_sections': {
|
||||||
// Descubre el cluster de una carta parseando sus propios enlaces internos
|
// Descubre el cluster de una carta parseando sus propios enlaces internos
|
||||||
// (misma lógica que pinta la portada, fea-carta-portada.php). No depende
|
// (misma lógica que pinta la portada, fea-carta-portada.php). No depende
|
||||||
|
|||||||
Executable → Regular
+29
-52
@@ -39,21 +39,18 @@ AUTORES = {
|
|||||||
386: ("Arregi", "ArregiFeadulta2026"),
|
386: ("Arregi", "ArregiFeadulta2026"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Autor y rango que está locutando el cron ahora mismo. Debe seguir el crontab:
|
# Autor y rango que está locutando el cron ahora mismo (los valores por defecto
|
||||||
# solo afecta a qué línea se marca como «en curso» en el informe diario.
|
# de tts_backlog_cron.sh). Solo afecta a qué línea se marca como "en curso".
|
||||||
ACTIVO = (774, 2000, 2026)
|
ACTIVO = (382, 2025, 2026)
|
||||||
|
|
||||||
|
|
||||||
def php(*args: str) -> str:
|
def php(*args: str) -> str:
|
||||||
"""Llama al helper WP sin convertir un fallo de lectura en una cola vacía."""
|
"""fea_post_io.php dentro del contenedor. stderr fuera: WP escupe warnings."""
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["docker", "exec", CONTAINER, "php", "/tmp/fea_post_io.php", *args],
|
["docker", "exec", CONTAINER, "php", "/tmp/fea_post_io.php", *args],
|
||||||
capture_output=True, text=True,
|
capture_output=True, text=True,
|
||||||
)
|
)
|
||||||
if r.returncode != 0:
|
return r.stdout if r.returncode == 0 else ""
|
||||||
detail = (r.stderr or r.stdout).strip().replace("\n", " ")[:300]
|
|
||||||
raise RuntimeError(f"consulta WP {' '.join(args)} falló (rc={r.returncode}): {detail}")
|
|
||||||
return r.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def pendientes(autor: int, desde: int, hasta: int, voz: str) -> int:
|
def pendientes(autor: int, desde: int, hasta: int, voz: str) -> int:
|
||||||
@@ -146,62 +143,41 @@ def _campo_cron(campo: str, valores: range) -> set[int]:
|
|||||||
|
|
||||||
|
|
||||||
def previstas_24h(ahora: datetime | None = None) -> int | None:
|
def previstas_24h(ahora: datetime | None = None) -> int | None:
|
||||||
"""Cuántas ventanas tenían que haber corrido en las últimas 24 horas.
|
"""Cuántas ventanas TENÍA que haber corrido el cron en las últimas 24 h.
|
||||||
|
|
||||||
El backlog usa varias líneas de crontab (ritmo normal + sábado + domingo).
|
Sin esto, los martes/miércoles/jueves (días de carta, sin cron) el informe
|
||||||
Se calcula la unión de sus instantes previstos, sin contar dos veces una
|
daba la alarma de 'ninguna ventana dejó rastro' estando todo correcto. La
|
||||||
coincidencia entre líneas.
|
verdad está en el crontab, no aquí: si Rafa cambia los días, esto le sigue.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
lineas = [
|
linea = next(
|
||||||
l for l in subprocess.run(["crontab", "-l"], text=True, capture_output=True,
|
l for l in subprocess.run(["crontab", "-l"], text=True, capture_output=True,
|
||||||
check=True).stdout.splitlines()
|
check=True).stdout.splitlines()
|
||||||
if "tts_backlog_cron.sh" in l and not l.lstrip().startswith("#")
|
if "tts_backlog_cron.sh" in l and not l.lstrip().startswith("#"))
|
||||||
]
|
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
return None
|
return None
|
||||||
if not lineas:
|
campos = linea.split(None, 5)
|
||||||
|
if len(campos) < 5:
|
||||||
return None
|
return None
|
||||||
|
minutos = _campo_cron(campos[0], range(60))
|
||||||
|
horas = _campo_cron(campos[1], range(24))
|
||||||
|
dows = {d % 7 for d in _campo_cron(campos[4], range(7))} # cron: 0 y 7 = domingo
|
||||||
|
|
||||||
ahora = ahora or datetime.now()
|
ahora = ahora or datetime.now()
|
||||||
inicio = ahora - timedelta(hours=24)
|
n = 0
|
||||||
previstos: set[datetime] = set()
|
for h in range(25):
|
||||||
for linea in lineas:
|
t = (ahora - timedelta(hours=h)).replace(second=0, microsecond=0)
|
||||||
campos = linea.split(None, 5)
|
for m in minutos:
|
||||||
if len(campos) < 5:
|
cand = t.replace(minute=m)
|
||||||
continue
|
if not (ahora - timedelta(hours=24) < cand <= ahora):
|
||||||
try:
|
continue
|
||||||
minutos = _campo_cron(campos[0], range(60))
|
if cand.hour in horas and (cand.weekday() + 1) % 7 in dows:
|
||||||
horas = _campo_cron(campos[1], range(24))
|
n += 1
|
||||||
dows = {d % 7 for d in _campo_cron(campos[4], range(7))}
|
return n
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
for h in range(25):
|
|
||||||
t = (ahora - timedelta(hours=h)).replace(second=0, microsecond=0)
|
|
||||||
for m in minutos:
|
|
||||||
cand = t.replace(minute=m)
|
|
||||||
if inicio < cand <= ahora and cand.hour in horas and (cand.weekday() + 1) % 7 in dows:
|
|
||||||
previstos.add(cand)
|
|
||||||
return len(previstos)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
# Nunca informar «0 pendientes» cuando la lectura de WordPress ha fallado.
|
hechos = hechos_24h()
|
||||||
# El cron no-agent entrega stdout/errores tal cual: un aviso explícito permite
|
|
||||||
# arreglar Docker/helper sin que parezca que el backlog está terminado.
|
|
||||||
try:
|
|
||||||
hechos = hechos_24h()
|
|
||||||
faltantes = {
|
|
||||||
aid: (pendientes(aid, 0, 9999, voz),
|
|
||||||
pendientes(aid, ACTIVO[1], ACTIVO[2], voz) if aid == ACTIVO[0] else None)
|
|
||||||
for aid, (_nombre, voz) in AUTORES.items()
|
|
||||||
}
|
|
||||||
except RuntimeError as exc:
|
|
||||||
print("⚠️ Fe Adulta — informe TTS inválido: no se pudo leer WordPress.")
|
|
||||||
print(f" {exc}")
|
|
||||||
print(" Los ceros no son datos reales; revisar wordpress-web y /tmp/fea_post_io.php.")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
total = sum(len(v) for v in hechos.values())
|
total = sum(len(v) for v in hechos.values())
|
||||||
corridas, saltadas, fallos = logs_24h()
|
corridas, saltadas, fallos = logs_24h()
|
||||||
p5, pw = cuota()
|
p5, pw = cuota()
|
||||||
@@ -218,9 +194,10 @@ def main() -> int:
|
|||||||
lineas.append("")
|
lineas.append("")
|
||||||
lineas.append("Pendientes:")
|
lineas.append("Pendientes:")
|
||||||
for aid, (nombre, voz) in AUTORES.items():
|
for aid, (nombre, voz) in AUTORES.items():
|
||||||
falta_todo, falta_lote = faltantes[aid]
|
falta_todo = pendientes(aid, 0, 9999, voz)
|
||||||
marca = ""
|
marca = ""
|
||||||
if aid == ACTIVO[0]:
|
if aid == ACTIVO[0]:
|
||||||
|
falta_lote = pendientes(aid, ACTIVO[1], ACTIVO[2], voz)
|
||||||
marca = f" ← en curso, {falta_lote} del lote {ACTIVO[1]}-{ACTIVO[2]}"
|
marca = f" ← en curso, {falta_lote} del lote {ACTIVO[1]}-{ACTIVO[2]}"
|
||||||
lineas.append(f" {nombre}: {falta_todo}{marca}")
|
lineas.append(f" {nombre}: {falta_todo}{marca}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Importa las traducciones editoriales humanas adjuntas a un issue de carta.
|
|
||||||
|
|
||||||
Por defecto solo valida y muestra el plan. --apply-local crea borradores en el
|
|
||||||
WordPress Docker local; nunca publica ni toca producción.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import html
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import zipfile
|
|
||||||
from pathlib import Path
|
|
||||||
from xml.etree import ElementTree as ET
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent
|
|
||||||
HELPER = ROOT / "fea_translate_helper.php"
|
|
||||||
CONTAINER = "wordpress-web"
|
|
||||||
SOURCE_ID = 55465 # Pagola, Carta 738
|
|
||||||
LANG_BY_NAME = {"2_eng": "en", "3_fr": "fr", "4_it": "it", "5_pt": "pt"}
|
|
||||||
TRANSLATOR_LABELS = ("Translator:", "Traducteur:", "Traduzzione:", "Tradutor:")
|
|
||||||
|
|
||||||
|
|
||||||
def docx_lines(path: Path) -> list[str]:
|
|
||||||
ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
|
|
||||||
with zipfile.ZipFile(path) as zf:
|
|
||||||
root = ET.fromstring(zf.read("word/document.xml"))
|
|
||||||
return [
|
|
||||||
"".join(t.text or "" for t in p.findall(".//w:t", ns)).strip()
|
|
||||||
for p in root.findall(".//w:p", ns)
|
|
||||||
if "".join(t.text or "" for t in p.findall(".//w:t", ns)).strip()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def value_after(lines: list[str], label: str) -> str:
|
|
||||||
try:
|
|
||||||
return lines[lines.index(label) + 1]
|
|
||||||
except (ValueError, IndexError) as exc:
|
|
||||||
raise ValueError(f"Falta {label!r}") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def parse_doc(path: Path) -> dict:
|
|
||||||
lines = docx_lines(path)
|
|
||||||
title = value_after(lines, "Título:")
|
|
||||||
excerpt = value_after(lines, "Entradilla:")
|
|
||||||
author = value_after(lines, "Autor:")
|
|
||||||
start = lines.index("Cuerpo:") + 1
|
|
||||||
end = next((i for i in range(start, len(lines))
|
|
||||||
if any(lines[i].startswith(label) for label in TRANSLATOR_LABELS)), len(lines))
|
|
||||||
body = lines[start:end]
|
|
||||||
if not body:
|
|
||||||
raise ValueError("Cuerpo vacío")
|
|
||||||
translator = ""
|
|
||||||
if end < len(lines):
|
|
||||||
label = next(label for label in TRANSLATOR_LABELS if lines[end].startswith(label))
|
|
||||||
translator = lines[end][len(label):].strip()
|
|
||||||
if not translator and end + 1 < len(lines):
|
|
||||||
translator = lines[end + 1]
|
|
||||||
# La fuente española incluye el crédito/origen como último párrafo.
|
|
||||||
body.append('Publicado en: <a href="https://www.gruposdejesus.com">https://www.gruposdejesus.com</a>')
|
|
||||||
content = "\n".join(
|
|
||||||
f"<p>{line if line.startswith('Publicado en: <a ') else html.escape(line)}</p>" for line in body
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"title": title,
|
|
||||||
"excerpt": excerpt,
|
|
||||||
"author": author,
|
|
||||||
"translator": translator,
|
|
||||||
"content": content,
|
|
||||||
"source_sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def run(cmd: list[str], stdin: str | None = None) -> str:
|
|
||||||
result = subprocess.run(cmd, input=stdin, text=True, capture_output=True)
|
|
||||||
if result.returncode:
|
|
||||||
raise RuntimeError(f"rc={result.returncode}: {result.stderr.strip()} {result.stdout.strip()}")
|
|
||||||
return result.stdout.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def helper(*args: str, stdin: str | None = None) -> str:
|
|
||||||
return run(["docker", "exec", "-i", CONTAINER, "php", "/tmp/fea_translate_helper.php", *args], stdin)
|
|
||||||
|
|
||||||
|
|
||||||
def lang_for(path: Path) -> str:
|
|
||||||
for marker, lang in LANG_BY_NAME.items():
|
|
||||||
if marker in path.name:
|
|
||||||
return lang
|
|
||||||
raise ValueError(f"No reconozco idioma en {path.name}")
|
|
||||||
|
|
||||||
|
|
||||||
def apply_meta(post_id: int, doc: Path, parsed: dict) -> None:
|
|
||||||
payload = json.dumps({"id": post_id, "doc": doc.name, "sha": parsed["source_sha256"],
|
|
||||||
"translator": parsed["translator"]}, ensure_ascii=False)
|
|
||||||
php = r'''$p=json_decode(base64_decode(getenv('PAYLOAD')),true); update_post_meta($p['id'],'traduccion_automatica','0'); update_post_meta($p['id'],'traduccion_modelo','editorial-humana'); update_post_meta($p['id'],'traduccion_fuente_doc',$p['doc']); update_post_meta($p['id'],'traduccion_fuente_sha256',$p['sha']); update_post_meta($p['id'],'traduccion_editor',$p['translator']); echo 'ok';'''
|
|
||||||
import base64
|
|
||||||
encoded = base64.b64encode(payload.encode()).decode()
|
|
||||||
run(["docker", "exec", "-i", "-e", f"PAYLOAD={encoded}", CONTAINER,
|
|
||||||
"wp", "--allow-root", "eval", php])
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--dir", type=Path, required=True)
|
|
||||||
ap.add_argument("--apply-local", action="store_true")
|
|
||||||
args = ap.parse_args()
|
|
||||||
docs = sorted(args.dir.glob("*.docx"))
|
|
||||||
if len(docs) != 4:
|
|
||||||
raise SystemExit(f"Se esperaban 4 DOCX, encontrados {len(docs)}")
|
|
||||||
run(["docker", "cp", str(HELPER), f"{CONTAINER}:/tmp/fea_translate_helper.php"])
|
|
||||||
for doc in docs:
|
|
||||||
lang, parsed = lang_for(doc), parse_doc(doc)
|
|
||||||
existing = helper("exists", str(SOURCE_ID), lang)
|
|
||||||
plan = {"lang": lang, "doc": doc.name, "existing": int(existing or "0"),
|
|
||||||
"title": parsed["title"], "excerpt_chars": len(parsed["excerpt"]),
|
|
||||||
"content_chars": len(parsed["content"]), "translator": parsed["translator"]}
|
|
||||||
print(json.dumps(plan, ensure_ascii=False))
|
|
||||||
if not args.apply_local:
|
|
||||||
continue
|
|
||||||
if int(existing or "0"):
|
|
||||||
raise RuntimeError(f"{lang} ya existe como #{existing}; no sobrescribo")
|
|
||||||
payload = json.dumps({"title": parsed["title"], "excerpt": parsed["excerpt"],
|
|
||||||
"content": parsed["content"], "model": "editorial-humana"}, ensure_ascii=False)
|
|
||||||
post_id = int(helper("create", str(SOURCE_ID), lang, "draft", stdin=payload))
|
|
||||||
apply_meta(post_id, doc, parsed)
|
|
||||||
print(json.dumps({"created": post_id, "lang": lang, "status": "draft"}))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -361,12 +361,12 @@ def _split_for_tts(text, limit=CHAR_LIMIT):
|
|||||||
return chunks
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
def _synth_chunk(text, voice_id, model, speed=1.0):
|
def _synth_chunk(text, voice_id, model):
|
||||||
"""Una petición t2a. Devuelve (audio_bytes|None, rc, usage_chars)."""
|
"""Una petición t2a. Devuelve (audio_bytes|None, rc, usage_chars)."""
|
||||||
body = {
|
body = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"text": text,
|
"text": text,
|
||||||
"voice_setting": {"voice_id": voice_id, "speed": speed, "vol": 1.0, "pitch": 0},
|
"voice_setting": {"voice_id": voice_id, "speed": 1.0, "vol": 1.0, "pitch": 0},
|
||||||
"audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "mp3", "channel": 1},
|
"audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "mp3", "channel": 1},
|
||||||
"language_boost": "Spanish",
|
"language_boost": "Spanish",
|
||||||
}
|
}
|
||||||
@@ -381,15 +381,13 @@ def _synth_chunk(text, voice_id, model, speed=1.0):
|
|||||||
return bytes.fromhex(audio_hex), 0, usage
|
return bytes.fromhex(audio_hex), 0, usage
|
||||||
|
|
||||||
|
|
||||||
def t2a(text, voice_id, model, name, speed=1.0):
|
def t2a(text, voice_id, model, name):
|
||||||
if not 0.5 <= float(speed) <= 2.0:
|
|
||||||
raise ValueError(f"speed fuera de rango: {speed} (permitido 0.5–2.0)")
|
|
||||||
chunks = _split_for_tts(text)
|
chunks = _split_for_tts(text)
|
||||||
print(f"Sintetizando {len(text)} car con {model} / {voice_id} a {float(speed):.2f}× "
|
print(f"Sintetizando {len(text)} car con {model} / {voice_id} "
|
||||||
f"({len(chunks)} petición/es)…", flush=True)
|
f"({len(chunks)} petición/es)…", flush=True)
|
||||||
raw = OUT / f"{name}.raw.mp3"
|
raw = OUT / f"{name}.raw.mp3"
|
||||||
if len(chunks) == 1:
|
if len(chunks) == 1:
|
||||||
audio, rc, _ = _synth_chunk(chunks[0], voice_id, model, speed)
|
audio, rc, _ = _synth_chunk(chunks[0], voice_id, model)
|
||||||
if audio is None:
|
if audio is None:
|
||||||
return rc
|
return rc
|
||||||
raw.write_bytes(audio)
|
raw.write_bytes(audio)
|
||||||
@@ -400,7 +398,7 @@ def t2a(text, voice_id, model, name, speed=1.0):
|
|||||||
import os as _os, time as _t
|
import os as _os, time as _t
|
||||||
_t.sleep(int(_os.environ.get("FEA_CHUNK_PAUSE", "35"))) # respetar TPM de MiniMax
|
_t.sleep(int(_os.environ.get("FEA_CHUNK_PAUSE", "35"))) # respetar TPM de MiniMax
|
||||||
print(f" trozo {k + 1}/{len(chunks)} ({len(ch)} car)…", flush=True)
|
print(f" trozo {k + 1}/{len(chunks)} ({len(ch)} car)…", flush=True)
|
||||||
audio, rc, _ = _synth_chunk(ch, voice_id, model, speed)
|
audio, rc, _ = _synth_chunk(ch, voice_id, model)
|
||||||
if audio is None:
|
if audio is None:
|
||||||
for p in parts:
|
for p in parts:
|
||||||
p.unlink(missing_ok=True)
|
p.unlink(missing_ok=True)
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Fase A local: borrador de Enrique + copia draft de la carta para revisión.
|
|
||||||
|
|
||||||
Por defecto no escribe WordPress. --apply-local sólo modifica el WordPress Docker local.
|
|
||||||
Nunca publica ni toca producción.
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import html
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import zipfile
|
|
||||||
from pathlib import Path
|
|
||||||
from xml.etree import ElementTree as ET
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
DEFAULT_DOC = Path("/home/rafa/.hermes/cache/documents/doc_7909db597c74_7r_Enrique_Mart_nez_Lozano_tesoro_esta_ya_en_nosotros.docx")
|
|
||||||
CARTA_PROD_ID = 54914
|
|
||||||
AUTHOR_ID = 384 # Enrique Martínez Lozano, verificado localmente.
|
|
||||||
CATEGORIES = [1650, 71] # Artículos + Feadulta
|
|
||||||
SLUG = "el-tesoro-esta-ya-en-nosotros"
|
|
||||||
|
|
||||||
|
|
||||||
def docx_text(path: Path) -> list[str]:
|
|
||||||
ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
|
|
||||||
with zipfile.ZipFile(path) as zf:
|
|
||||||
root = ET.fromstring(zf.read("word/document.xml"))
|
|
||||||
out = []
|
|
||||||
for p in root.findall(".//w:p", ns):
|
|
||||||
text = "".join(t.text or "" for t in p.findall(".//w:t", ns)).strip()
|
|
||||||
if text:
|
|
||||||
out.append(text)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def parse_source(path: Path) -> dict:
|
|
||||||
lines = docx_text(path)
|
|
||||||
def after(label: str) -> str:
|
|
||||||
return lines[lines.index(label) + 1]
|
|
||||||
title = after("Título:")
|
|
||||||
author = after("Autor:")
|
|
||||||
assert title == "EL TESORO ESTÁ YA EN NOSOTROS", title
|
|
||||||
assert author == "ENRIQUE MARTÍNEZ LOZANO", author
|
|
||||||
body_start = lines.index("Cuerpo:") + 1
|
|
||||||
body = lines[body_start:]
|
|
||||||
# Última firma del boletín no es contenido del post: el autor va en WP.
|
|
||||||
if body[-1].startswith("ENRIQUE MARTÍNEZ LOZANO"):
|
|
||||||
body = body[:-1]
|
|
||||||
date_line, bible, *paragraphs = body
|
|
||||||
intro = lines[lines.index("Entradilla:") + 1]
|
|
||||||
content = "\n".join(
|
|
||||||
[f"<p><strong>{html.escape(date_line)}</strong></p>",
|
|
||||||
f"<p><strong>{html.escape(bible)}</strong></p>"]
|
|
||||||
+ [f"<p>{html.escape(p)}</p>" for p in paragraphs]
|
|
||||||
)
|
|
||||||
return {"title": title.lower().capitalize(), "author": author.title(), "intro": intro,
|
|
||||||
"content": content, "source_sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
|
|
||||||
|
|
||||||
|
|
||||||
def prod_card() -> dict:
|
|
||||||
sys.path.insert(0, str(ROOT / "scripts"))
|
|
||||||
import sync_translations_to_prod as sync
|
|
||||||
return json.loads(sync.prod_helper("read_full", str(CARTA_PROD_ID)))
|
|
||||||
|
|
||||||
|
|
||||||
def carta_candidate(card: dict, anchor: str) -> str:
|
|
||||||
marker = '<p> </p>\n<p><span style="color: #ff0000;"><strong>Para unas eucaristías más participativas y actuales</strong></span></p>'
|
|
||||||
assert card["content"].count(marker) == 1, "Marcador de sección no único/no encontrado"
|
|
||||||
return card["content"].replace(marker, anchor + "\n" + marker, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def run_wp(payload: dict, apply: bool) -> dict:
|
|
||||||
encoded = base64.b64encode(json.dumps(payload, ensure_ascii=False).encode()).decode()
|
|
||||||
php = r'''
|
|
||||||
$p=json_decode(base64_decode(getenv('FEA_PAYLOAD')), true);
|
|
||||||
$apply=getenv('FEA_APPLY') === '1';
|
|
||||||
$existing=get_page_by_path($p['slug'], OBJECT, 'post');
|
|
||||||
$author=get_user_by('id',(int)$p['author_id']);
|
|
||||||
$cats=array_map('intval',$p['categories']);
|
|
||||||
$cat_ok=count(array_filter($cats, fn($id)=>term_exists($id,'category')))===count($cats);
|
|
||||||
$preview=get_posts(['post_type'=>'post','post_status'=>'any','meta_key'=>'fea_phase_a_source_hash','meta_value'=>$p['source_sha256'],'numberposts'=>1]);
|
|
||||||
$out=['dry_run'=>!$apply,'existing_slug'=>$existing?['id'=>$existing->ID,'status'=>$existing->post_status]:null,
|
|
||||||
'author'=>$author?['id'=>$author->ID,'name'=>$author->display_name]:null,'categories_ok'=>$cat_ok,
|
|
||||||
'preview_existing'=>$preview?['id'=>$preview[0]->ID,'status'=>$preview[0]->post_status]:null,
|
|
||||||
'candidate_anchor'=>$p['anchor']];
|
|
||||||
if (!$author || !$cat_ok) { $out['error']='Autor o categorías no válidos'; echo wp_json_encode($out,JSON_UNESCAPED_UNICODE); exit(3); }
|
|
||||||
if ($existing || $preview) { $out['error']='Ya existe un borrador/slug para esta Fase A; no se duplica'; echo wp_json_encode($out,JSON_UNESCAPED_UNICODE); exit(4); }
|
|
||||||
if (!$apply) { echo wp_json_encode($out,JSON_UNESCAPED_UNICODE); exit; }
|
|
||||||
$article_id=wp_insert_post(['post_type'=>'post','post_status'=>'draft','post_author'=>(int)$p['author_id'],
|
|
||||||
'post_title'=>$p['article_title'],'post_name'=>$p['slug'],'post_excerpt'=>$p['intro'],
|
|
||||||
'post_content'=>$p['article_content'],'post_category'=>$cats],true);
|
|
||||||
if (is_wp_error($article_id)) { $out['error']=$article_id->get_error_message(); echo wp_json_encode($out,JSON_UNESCAPED_UNICODE); exit(5); }
|
|
||||||
if (function_exists('pll_set_post_language')) pll_set_post_language($article_id,'es');
|
|
||||||
update_post_meta($article_id,'fea_phase_a_source_hash',$p['source_sha256']);
|
|
||||||
update_post_meta($article_id,'fea_phase_a_source_doc','7r_Enrique Martínez Lozano_tesoro_esta_ya_en_nosotros.docx');
|
|
||||||
update_post_meta($article_id,'fea_phase_a_status','preview-only');
|
|
||||||
$preview_id=wp_insert_post(['post_type'=>'post','post_status'=>'draft','post_author'=>(int)$p['card_author_id'],
|
|
||||||
'post_title'=>'PREVIEW — Hacia el corazón (+ Enrique Martínez Lozano)','post_excerpt'=>'Copia local de validación; no publicar.',
|
|
||||||
'post_content'=>$p['candidate_card_content']],true);
|
|
||||||
if (is_wp_error($preview_id)) { wp_delete_post($article_id,true); $out['error']=$preview_id->get_error_message(); echo wp_json_encode($out,JSON_UNESCAPED_UNICODE); exit(6); }
|
|
||||||
if (function_exists('pll_set_post_language')) pll_set_post_language($preview_id,'es');
|
|
||||||
update_post_meta($preview_id,'fea_phase_a_source_hash',$p['source_sha256']);
|
|
||||||
update_post_meta($preview_id,'fea_phase_a_source_prod_id',(int)$p['carta_prod_id']);
|
|
||||||
update_post_meta($preview_id,'fea_phase_a_preview_article_id',$article_id);
|
|
||||||
update_post_meta($preview_id,'fea_phase_a_status','preview-only');
|
|
||||||
$out['article']=['id'=>$article_id,'status'=>'draft','slug'=>get_post_field('post_name',$article_id),'author'=>get_the_author_meta('display_name',(int)$p['author_id'])];
|
|
||||||
$out['preview_card']=['id'=>$preview_id,'status'=>'draft','source_prod_id'=>(int)$p['carta_prod_id']];
|
|
||||||
echo wp_json_encode($out,JSON_UNESCAPED_UNICODE);
|
|
||||||
'''
|
|
||||||
cmd = [
|
|
||||||
"docker", "exec", "-i", "-e", f"FEA_PAYLOAD={encoded}",
|
|
||||||
"-e", f"FEA_APPLY={'1' if apply else '0'}", "wordpress-web",
|
|
||||||
"wp", "--allow-root", "eval", php,
|
|
||||||
]
|
|
||||||
result = subprocess.run(cmd, text=True, capture_output=True, check=False)
|
|
||||||
if result.returncode:
|
|
||||||
raise RuntimeError(f"WP local rc={result.returncode}: {result.stderr}\n{result.stdout}")
|
|
||||||
return json.loads(result.stdout)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--doc", type=Path, default=DEFAULT_DOC)
|
|
||||||
ap.add_argument("--apply-local", action="store_true")
|
|
||||||
ap.add_argument("--output", type=Path, required=True)
|
|
||||||
args = ap.parse_args()
|
|
||||||
source = parse_source(args.doc)
|
|
||||||
card = prod_card() # lectura server-side únicamente.
|
|
||||||
article_url = "http://localhost:8080/" + SLUG + "/"
|
|
||||||
anchor = (f'<p><strong><a href="{article_url}">Enrique Martínez Lozano: '
|
|
||||||
f'{html.escape(source["title"])}.</a></strong> {html.escape(source["intro"])}</p>')
|
|
||||||
payload = {
|
|
||||||
"source_sha256": source["source_sha256"], "article_title": source["title"],
|
|
||||||
"article_content": source["content"], "intro": source["intro"], "slug": SLUG,
|
|
||||||
"author_id": AUTHOR_ID, "categories": CATEGORIES, "anchor": anchor,
|
|
||||||
"candidate_card_content": carta_candidate(card, anchor), "card_author_id": card["author"],
|
|
||||||
"carta_prod_id": CARTA_PROD_ID,
|
|
||||||
}
|
|
||||||
result = run_wp(payload, args.apply_local)
|
|
||||||
report = {"source": source, "prod_card": {"id": card["id"], "title": card["title"], "status": card["status"]},
|
|
||||||
"result": result, "mode": "apply-local" if args.apply_local else "dry-run"}
|
|
||||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
||||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Release Fase 1+2, issue #181: 68 traducciones publicadas + 16 MP3 TTS.
|
|
||||||
# Por defecto es sólo dry-run. --apply requiere confirmación explícita de Rafa.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
cd "$ROOT"
|
|
||||||
|
|
||||||
MODE="dry-run"
|
|
||||||
if [[ "${1:-}" == "--apply" ]]; then
|
|
||||||
MODE="apply"
|
|
||||||
elif [[ "${1:-}" == "--verify" ]]; then
|
|
||||||
MODE="verify"
|
|
||||||
elif [[ "${1:-}" != "" && "${1:-}" != "--dry-run" ]]; then
|
|
||||||
echo "Uso: $0 [--dry-run|--verify|--apply]" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# shellcheck disable=SC1091
|
|
||||||
source ~/.hermes/profiles/feadulta/.env
|
|
||||||
: "${FEA_PROD_WPLOAD:=/web/wp-load.php}"
|
|
||||||
if [[ "$FEA_PROD_WPLOAD" != "/web/wp-load.php" ]]; then
|
|
||||||
echo "ABORT: FEA_PROD_WPLOAD debe ser /web/wp-load.php (recibido: $FEA_PROD_WPLOAD)" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
RELEASE_DIR="logs/release-issue-181"
|
|
||||||
mkdir -p "$RELEASE_DIR/backups"
|
|
||||||
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
|
||||||
ORIGINS=(54875 54902 54903 54883 54884 54885 54886 54866 54867 54868 54869 54870 54871 54872 54873 54880 54914)
|
|
||||||
AUDIO_IDS=(54875 54902 54903 54883 54884 54885 54886 54866 54867 54868 54869 54870 54871 54872 54873 54880)
|
|
||||||
|
|
||||||
if [[ "$MODE" == "apply" && "${FEA_RELEASE_CONFIRM:-}" != "PUBLISH_ISSUE_181" ]]; then
|
|
||||||
echo "ABORT: para escribir en producción usa:" >&2
|
|
||||||
echo " FEA_RELEASE_CONFIRM=PUBLISH_ISSUE_181 $0 --apply" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
preflight() {
|
|
||||||
python3 - "${ORIGINS[@]}" <<'PY'
|
|
||||||
import json, sys
|
|
||||||
sys.path.insert(0, 'scripts')
|
|
||||||
import sync_translations_to_prod as sync
|
|
||||||
ids = [int(x) for x in sys.argv[1:]]
|
|
||||||
rows = []
|
|
||||||
for pid in ids:
|
|
||||||
d = json.loads(sync.prod_helper('read_full', str(pid)))
|
|
||||||
rows.append({'id': pid, 'status': d['status'], 'lang': d['lang'], 'translations': d.get('translations', {})})
|
|
||||||
assert len(rows) == 17
|
|
||||||
assert all(r['status'] == 'publish' and r['lang'] == 'es' for r in rows), rows
|
|
||||||
# Los 16 artículos no deben tener traducciones aún; la carta tampoco antes del primer release.
|
|
||||||
assert all(r['translations'] == {'es': r['id']} for r in rows), rows
|
|
||||||
print(json.dumps(rows, ensure_ascii=False, indent=2))
|
|
||||||
print('preflight=PASS sources=17')
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
backup_prod_state() {
|
|
||||||
python3 - "$RELEASE_DIR/backups/prod-before-$STAMP.json" "${ORIGINS[@]}" -- "${AUDIO_IDS[@]}" <<'PY'
|
|
||||||
import json, sys
|
|
||||||
out = sys.argv[1]
|
|
||||||
sep = sys.argv.index('--')
|
|
||||||
origins = [int(x) for x in sys.argv[2:sep]]
|
|
||||||
audio_ids = [int(x) for x in sys.argv[sep+1:]]
|
|
||||||
sys.path.insert(0, 'scripts')
|
|
||||||
import sync_translations_to_prod as translations
|
|
||||||
import sync_audio_to_prod as audio
|
|
||||||
payload = {
|
|
||||||
'origins': {str(pid): json.loads(translations.prod_helper('read_full', str(pid))) for pid in origins},
|
|
||||||
'audio_before': {
|
|
||||||
str(pid): {
|
|
||||||
'url': audio.prod_helper('getmeta', str(pid), 'fea_audio_url').strip(),
|
|
||||||
'voice': audio.prod_helper('getmeta', str(pid), 'fea_audio_voice').strip(),
|
|
||||||
'done': audio.prod_helper('getmeta', str(pid), 'fea_audio_done').strip(),
|
|
||||||
} for pid in audio_ids
|
|
||||||
},
|
|
||||||
}
|
|
||||||
with open(out, 'w', encoding='utf-8') as fh:
|
|
||||||
json.dump(payload, fh, ensure_ascii=False, indent=2)
|
|
||||||
print(out)
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
verify_release() {
|
|
||||||
python3 - "${ORIGINS[@]}" -- "${AUDIO_IDS[@]}" <<'PY'
|
|
||||||
import json, sys, time
|
|
||||||
sep = sys.argv.index('--')
|
|
||||||
origins = [int(x) for x in sys.argv[1:sep]]
|
|
||||||
audio_ids = [int(x) for x in sys.argv[sep+1:]]
|
|
||||||
sys.path.insert(0, 'scripts')
|
|
||||||
import sync_translations_to_prod as translations
|
|
||||||
import sync_audio_to_prod as audio
|
|
||||||
|
|
||||||
def read_post(pid):
|
|
||||||
"""Lectura server-side resiliente ante una respuesta SSH/PHP vacía transitoria."""
|
|
||||||
last = ''
|
|
||||||
for attempt in range(1, 4):
|
|
||||||
raw = translations.prod_helper('read_full', str(pid)).strip()
|
|
||||||
if raw:
|
|
||||||
try:
|
|
||||||
return json.loads(raw)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
last = f'JSON inválido intento {attempt}: {exc}; prefijo={raw[:160]!r}'
|
|
||||||
else:
|
|
||||||
last = f'respuesta vacía intento {attempt}'
|
|
||||||
time.sleep(attempt)
|
|
||||||
raise AssertionError(f'No se pudo leer post #{pid}: {last}')
|
|
||||||
|
|
||||||
for pid in origins:
|
|
||||||
d = read_post(pid)
|
|
||||||
group = d.get('translations', {})
|
|
||||||
assert set(group) == {'es', 'en', 'fr', 'it', 'pt'}, (pid, group)
|
|
||||||
for lang, tid in group.items():
|
|
||||||
td = read_post(tid)
|
|
||||||
assert td['lang'] == lang and td['status'] == 'publish', (pid, lang, tid, td['lang'], td['status'])
|
|
||||||
for pid in audio_ids:
|
|
||||||
url = audio.prod_helper('getmeta', str(pid), 'fea_audio_url').strip()
|
|
||||||
done = audio.prod_helper('getmeta', str(pid), 'fea_audio_done').strip()
|
|
||||||
assert url.endswith(f'/wp-content/uploads/tts/{pid}.mp3') and done == '1', (pid, url, done)
|
|
||||||
print('verification=PASS groups=17 translations=68 audio=16')
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ "$MODE" == "verify" ]]; then
|
|
||||||
echo "== Verificación server-side posterior (solo lectura) =="
|
|
||||||
verify_release | tee "$RELEASE_DIR/verification-$STAMP.txt"
|
|
||||||
echo "VERIFICACIÓN ISSUE #181 COMPLETADA"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "== Preflight server-side ($MODE): 17 fuentes ES =="
|
|
||||||
preflight | tee "$RELEASE_DIR/preflight-$MODE-$STAMP.json"
|
|
||||||
|
|
||||||
if [[ "$MODE" == "dry-run" ]]; then
|
|
||||||
: > "$RELEASE_DIR/translations-dry-run-$STAMP.log"
|
|
||||||
for origin in "${ORIGINS[@]}"; do
|
|
||||||
FEA_SYNC_STATUS=publish \
|
|
||||||
FEA_SYNC_LOG="$RELEASE_DIR/translations-dry-run-$STAMP.log" \
|
|
||||||
FEA_SYNC_STATE="$RELEASE_DIR/translations-$origin-state.json" \
|
|
||||||
python3 scripts/sync_translations_to_prod.py --origin "$origin" --dry-run
|
|
||||||
done
|
|
||||||
FEA_AUDIO_SYNC_LOG="$RELEASE_DIR/audio-dry-run-$STAMP.log" \
|
|
||||||
FEA_AUDIO_SYNC_STATE="$RELEASE_DIR/audio-state.json" \
|
|
||||||
python3 scripts/sync_audio_to_prod.py --ids "$(IFS=,; echo "${AUDIO_IDS[*]}")" --dry-run | tee "$RELEASE_DIR/audio-dry-run-$STAMP.log"
|
|
||||||
echo "DRY-RUN terminado: 68 traducciones publicables + 16 audios planificados."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "== Backup server-side previo =="
|
|
||||||
backup_prod_state | tee "$RELEASE_DIR/backup-path-$STAMP.txt"
|
|
||||||
|
|
||||||
echo "== Subiendo 68 traducciones como publish =="
|
|
||||||
for origin in "${ORIGINS[@]}"; do
|
|
||||||
FEA_SYNC_STATUS=publish \
|
|
||||||
FEA_SYNC_LOG="$RELEASE_DIR/translations-apply-$STAMP.log" \
|
|
||||||
FEA_SYNC_STATE="$RELEASE_DIR/translations-$origin-state.json" \
|
|
||||||
python3 scripts/sync_translations_to_prod.py --origin "$origin"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "== Subiendo 16 audios =="
|
|
||||||
FEA_AUDIO_SYNC_LOG="$RELEASE_DIR/audio-apply-$STAMP.log" \
|
|
||||||
FEA_AUDIO_SYNC_STATE="$RELEASE_DIR/audio-state.json" \
|
|
||||||
python3 scripts/sync_audio_to_prod.py --ids "$(IFS=,; echo "${AUDIO_IDS[*]}")"
|
|
||||||
|
|
||||||
echo "== Verificación server-side posterior =="
|
|
||||||
verify_release | tee "$RELEASE_DIR/verification-$STAMP.txt"
|
|
||||||
echo "RELEASE ISSUE #181 COMPLETADO"
|
|
||||||
+42
-104
@@ -3,10 +3,9 @@
|
|||||||
sync_audio_to_prod.py — Sube a PROD los mp3 de TTS ya generados/enlazados en
|
sync_audio_to_prod.py — Sube a PROD los mp3 de TTS ya generados/enlazados en
|
||||||
local (fea_audio_done=1) y fija el meta fea_audio_url en prod.
|
local (fea_audio_done=1) y fija el meta fea_audio_url en prod.
|
||||||
|
|
||||||
Prod vive en Hetzner (Coolify/Docker) desde el cutover de agosto 2026. Subida
|
Prod (134.0.10.170) tiene glibc rota: scp/sftp NO funcionan (connection closed).
|
||||||
del binario por stdin de ssh ("cat > ruta" dentro del contenedor vía
|
Workaround: subir el binario por stdin de ssh ("cat > ruta"), igual que el resto
|
||||||
`docker exec -i`), sin depender de scp/sftp — mismo patrón que
|
de scripts que tocan ese servidor (ver feadulta-server-glibc-rota.md).
|
||||||
sync_translations_to_prod.py y sync_carta_from_prod.py.
|
|
||||||
|
|
||||||
Uso:
|
Uso:
|
||||||
python3 sync_audio_to_prod.py --carta 54254 # sincroniza toda la cola de la carta
|
python3 sync_audio_to_prod.py --carta 54254 # sincroniza toda la cola de la carta
|
||||||
@@ -20,10 +19,8 @@ Rollback (despublica en prod lo que este script publicó):
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import base64
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shlex
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -34,20 +31,13 @@ DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
|
|||||||
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
|
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
|
||||||
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
||||||
|
|
||||||
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
|
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
|
||||||
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
|
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
|
||||||
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
|
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
|
||||||
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
|
PROD_HELPER = "/tmp/fea_post_io.php"
|
||||||
# define, el helper y la subida de mp3 se ejecutan dentro del contenedor.
|
PROD_UPLOADS_TTS = "/web/wp-content/uploads/tts"
|
||||||
# Espejo de sync_translations_to_prod.py / sync_carta_from_prod.py.
|
|
||||||
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
|
|
||||||
PROD_UPLOADS_TTS = os.environ.get("FEA_PROD_UPLOADS_TTS", "/var/www/html/wp-content/uploads/tts")
|
|
||||||
|
|
||||||
HELPER_SRC = Path(__file__).resolve().parent / "fea_post_io.php"
|
HELPER_SRC = Path(__file__).resolve().parent / "fea_post_io.php"
|
||||||
# Se evalúa en memoria con `php -r`: no se copia un helper temporal a prod.
|
|
||||||
HELPER_EVAL_CODE = "eval(base64_decode(" + repr(
|
|
||||||
base64.b64encode(HELPER_SRC.read_text(encoding="utf-8").removeprefix("<?php").encode("utf-8")).decode("ascii")
|
|
||||||
) + "));"
|
|
||||||
LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
|
LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
|
||||||
|
|
||||||
LOG_FILE = Path(os.environ.get(
|
LOG_FILE = Path(os.environ.get(
|
||||||
@@ -84,17 +74,6 @@ def local_meta(post_id: int, key: str) -> str:
|
|||||||
return r.stdout.strip()
|
return r.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
def prod_id_for_local(local_id: int) -> int:
|
|
||||||
"""Resolve a remapped local post to its original production ID.
|
|
||||||
|
|
||||||
``sync_carta_from_prod.py --remap-conflicts`` keeps this mapping in
|
|
||||||
``fea_prod_source_id``. Audio files remain named with the local ID, but
|
|
||||||
remote paths and production post meta must use the source ID.
|
|
||||||
"""
|
|
||||||
source_id = local_meta(local_id, "fea_prod_source_id")
|
|
||||||
return int(source_id) if source_id.isdigit() else local_id
|
|
||||||
|
|
||||||
|
|
||||||
def carta_article_ids(carta_id: int) -> list[int]:
|
def carta_article_ids(carta_id: int) -> list[int]:
|
||||||
q = ("SELECT post_id FROM wp_postmeta "
|
q = ("SELECT post_id FROM wp_postmeta "
|
||||||
f"WHERE meta_key='_carta_id' AND meta_value='{carta_id}' ORDER BY post_id;")
|
f"WHERE meta_key='_carta_id' AND meta_value='{carta_id}' ORDER BY post_id;")
|
||||||
@@ -106,80 +85,47 @@ def carta_article_ids(carta_id: int) -> list[int]:
|
|||||||
return [int(x) for x in r.stdout.split() if x.isdigit()]
|
return [int(x) for x in r.stdout.split() if x.isdigit()]
|
||||||
|
|
||||||
|
|
||||||
# ── Prod (Hetzner/Docker: todo por ssh, mp3 por stdin de "cat") ────────────────
|
# ── Prod (glibc rota: nada de scp/sftp, todo por ssh + cat) ────────────────────
|
||||||
def _ssh_text(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
def _ssh_text(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
||||||
# El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
|
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
# solo se antepone si hay contraseña configurada (servidor viejo CDMON).
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
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)
|
r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}…\n{r.stderr.strip()[:400]}")
|
raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}…\n{r.stderr.strip()[:400]}")
|
||||||
return r.stdout
|
return r.stdout
|
||||||
|
|
||||||
|
|
||||||
def _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.
|
|
||||||
|
|
||||||
Importante: el wrapping (incluidas redirecciones como `< ruta`) debe quedar
|
|
||||||
DENTRO de la shell del contenedor (`sh -c '...'`), porque `docker exec
|
|
||||||
CONTENEDOR cmd < ruta` resuelve esa redirección en el filesystem del HOST,
|
|
||||||
no dentro del contenedor.
|
|
||||||
"""
|
|
||||||
if PROD_DOCKER_CONTAINER:
|
|
||||||
return f"docker exec -i {shlex.quote(PROD_DOCKER_CONTAINER)} sh -c {shlex.quote(inner_cmd)}"
|
|
||||||
return inner_cmd
|
|
||||||
|
|
||||||
|
|
||||||
def _ssh_upload_bytes(data: bytes, remote_path: str, *, timeout: int = 180) -> None:
|
def _ssh_upload_bytes(data: bytes, remote_path: str, *, timeout: int = 180) -> None:
|
||||||
remote_cmd = _remote_wrap(f"cat > {remote_path}")
|
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
if PROD_PASS:
|
"-o", "ConnectTimeout=20", PROD_HOST, f"cat > {remote_path}"]
|
||||||
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)
|
sh(cmd, input_bytes=data, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
_prod_helper_ready = False
|
||||||
|
|
||||||
|
|
||||||
def prod_helper(subcmd: str, *args: str) -> str:
|
def prod_helper(subcmd: str, *args: str) -> str:
|
||||||
"""Ejecuta el helper PHP en memoria; no deja ficheros temporales en prod."""
|
global _prod_helper_ready
|
||||||
argv = " ".join(shlex.quote(x) for x in (subcmd, *args))
|
if not _prod_helper_ready:
|
||||||
inner = (
|
_ssh_upload_bytes(HELPER_SRC.read_bytes(), PROD_HELPER)
|
||||||
f"FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
|
_prod_helper_ready = True
|
||||||
f"php -r {shlex.quote(HELPER_EVAL_CODE)} {argv}"
|
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
||||||
)
|
return _ssh_text(inner, timeout=60)
|
||||||
if PROD_DOCKER_CONTAINER:
|
|
||||||
remote_cmd = (
|
|
||||||
f"docker exec -i -e FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
|
|
||||||
f"{shlex.quote(PROD_DOCKER_CONTAINER)} php -r {shlex.quote(HELPER_EVAL_CODE)} {argv}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
remote_cmd = inner
|
|
||||||
return _ssh_text(remote_cmd, timeout=60)
|
|
||||||
|
|
||||||
|
|
||||||
def prod_upload_mp3(local_id: int, prod_id: int) -> None:
|
def prod_upload_mp3(post_id: int) -> None:
|
||||||
src = LOCAL_TTS_DIR / f"{local_id}.mp3"
|
src = LOCAL_TTS_DIR / f"{post_id}.mp3"
|
||||||
data = src.read_bytes()
|
data = src.read_bytes()
|
||||||
remote_path = f"{PROD_UPLOADS_TTS}/{prod_id}.mp3"
|
remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3"
|
||||||
_ssh_upload_bytes(data, remote_path)
|
_ssh_upload_bytes(data, remote_path)
|
||||||
# Verificación de tamaño: no fiarse ciegamente del rc=0 de ssh. `wc -c` debe
|
# Verificación de tamaño (glibc rota => sin fiarse ciegamente del rc=0 de ssh)
|
||||||
# correr (y resolver la redirección) DENTRO del contenedor — ver _remote_wrap.
|
remote_size = int(_ssh_text(f"wc -c < {remote_path}").strip())
|
||||||
remote_size = int(_ssh_text(_remote_wrap(f"wc -c < {remote_path}")).strip())
|
|
||||||
if remote_size != len(data):
|
if remote_size != len(data):
|
||||||
raise RuntimeError(
|
raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}")
|
||||||
f"tamaño no coincide tras subir prod#{prod_id}: local={len(data)} remoto={remote_size}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def prod_remove_mp3(post_id: int) -> None:
|
def prod_remove_mp3(post_id: int) -> None:
|
||||||
_ssh_text(_remote_wrap(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3"))
|
_ssh_text(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3")
|
||||||
|
|
||||||
|
|
||||||
# ── Estado ───────────────────────────────────────────────────────────────────
|
# ── Estado ───────────────────────────────────────────────────────────────────
|
||||||
@@ -203,27 +149,25 @@ def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
|
|||||||
return "sin-audio-local"
|
return "sin-audio-local"
|
||||||
if not (LOCAL_TTS_DIR / f"{post_id}.mp3").exists():
|
if not (LOCAL_TTS_DIR / f"{post_id}.mp3").exists():
|
||||||
return "mp3-local-ausente"
|
return "mp3-local-ausente"
|
||||||
prod_id = prod_id_for_local(post_id)
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
return f"PLAN: local#{post_id}.mp3 → prod#{prod_id}.mp3 + setaudio"
|
return "PLAN: subiría mp3 + setaudio"
|
||||||
|
|
||||||
voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
|
voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
|
||||||
prod_upload_mp3(post_id, prod_id)
|
prod_upload_mp3(post_id)
|
||||||
prod_helper("setaudio", str(prod_id), f"/wp-content/uploads/tts/{prod_id}.mp3", voice)
|
prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice)
|
||||||
if prod_id not in state["synced"]:
|
if post_id not in state["synced"]:
|
||||||
state["synced"].append(prod_id)
|
state["synced"].append(post_id)
|
||||||
save_state(state)
|
save_state(state)
|
||||||
return f"ok prod#{prod_id}"
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
def rollback_one(post_id: int, state: dict) -> str:
|
def rollback_one(post_id: int, state: dict) -> str:
|
||||||
prod_id = prod_id_for_local(post_id)
|
prod_helper("unsetaudio", str(post_id))
|
||||||
prod_helper("unsetaudio", str(prod_id))
|
prod_remove_mp3(post_id)
|
||||||
prod_remove_mp3(prod_id)
|
if post_id in state["synced"]:
|
||||||
if prod_id in state["synced"]:
|
state["synced"].remove(post_id)
|
||||||
state["synced"].remove(prod_id)
|
|
||||||
save_state(state)
|
save_state(state)
|
||||||
return f"ok prod#{prod_id}"
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
@@ -250,7 +194,7 @@ def main() -> int:
|
|||||||
for pid in ids:
|
for pid in ids:
|
||||||
try:
|
try:
|
||||||
if args.rollback:
|
if args.rollback:
|
||||||
if prod_id_for_local(pid) not in state["synced"] and not args.ids:
|
if pid not in state["synced"] and not args.ids:
|
||||||
res = "no-estaba-sincronizado"
|
res = "no-estaba-sincronizado"
|
||||||
skip += 1
|
skip += 1
|
||||||
else:
|
else:
|
||||||
@@ -258,13 +202,7 @@ def main() -> int:
|
|||||||
ok += 1
|
ok += 1
|
||||||
else:
|
else:
|
||||||
res = sync_one(pid, state, dry_run=args.dry_run)
|
res = sync_one(pid, state, dry_run=args.dry_run)
|
||||||
# `startswith`, no `==`: sync_one devuelve "ok prod#<id>" desde que
|
if res == "ok" or res.startswith("PLAN"):
|
||||||
# el id de prod puede diferir del local. Con la igualdad exacta, una
|
|
||||||
# subida perfecta se contaba entera como saltada — el 8-ago-2026 el
|
|
||||||
# pie del log dijo "ok=0 skip=90" tras subir los 90 audios de Fray
|
|
||||||
# Marcos sin un solo fallo. Peor que el susto: así un fallo real se
|
|
||||||
# confunde con este ruido y pasa desapercibido.
|
|
||||||
if res.startswith(("ok", "PLAN")):
|
|
||||||
ok += 1
|
ok += 1
|
||||||
else:
|
else:
|
||||||
skip += 1
|
skip += 1
|
||||||
|
|||||||
+37
-104
@@ -23,8 +23,6 @@ el mismo fea_translate_helper.php sin tocar su lógica, solo invertido
|
|||||||
Uso:
|
Uso:
|
||||||
python3 sync_carta_from_prod.py --carta 54495 --dry-run
|
python3 sync_carta_from_prod.py --carta 54495 --dry-run
|
||||||
python3 sync_carta_from_prod.py --carta 54495
|
python3 sync_carta_from_prod.py --carta 54495
|
||||||
python3 sync_carta_from_prod.py --ids 54875,54902,54903 --dry-run
|
|
||||||
python3 sync_carta_from_prod.py --ids 54875,54902,54903
|
|
||||||
|
|
||||||
Tras esto, el resto del ciclo ya existente no cambia:
|
Tras esto, el resto del ciclo ya existente no cambia:
|
||||||
translate_post.py --carta 54495 --langs en,fr,it,pt --status draft
|
translate_post.py --carta 54495 --langs en,fr,it,pt --status draft
|
||||||
@@ -36,10 +34,8 @@ Tras esto, el resto del ciclo ya existente no cambia:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import base64
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shlex
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -48,11 +44,7 @@ WP_CONTAINER = os.environ.get("FEA_WP_CONTAINER", "wordpress-web")
|
|||||||
|
|
||||||
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
|
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
|
||||||
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
|
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
|
||||||
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
|
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
|
||||||
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
|
|
||||||
# define, el helper se ejecuta en memoria dentro del contenedor; no se escribe
|
|
||||||
# ningún fichero temporal en prod.
|
|
||||||
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
|
|
||||||
PROD_HELPER = "/tmp/fea_translate_helper.php"
|
PROD_HELPER = "/tmp/fea_translate_helper.php"
|
||||||
|
|
||||||
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
|
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
|
||||||
@@ -82,35 +74,22 @@ def sh(cmd: list[str], *, stdin: str | None = None, timeout: int = 120) -> str:
|
|||||||
|
|
||||||
|
|
||||||
# ── Prod (origen, solo lectura) ─────────────────────────────────────────────
|
# ── Prod (origen, solo lectura) ─────────────────────────────────────────────
|
||||||
|
_prod_ready = False
|
||||||
|
|
||||||
|
|
||||||
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
||||||
# El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
|
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
# solo se antepone si hay contraseña configurada (servidor viejo CDMON).
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
if PROD_PASS:
|
|
||||||
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
|
||||||
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
|
||||||
else:
|
|
||||||
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
|
|
||||||
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
|
||||||
return sh(cmd, stdin=stdin, timeout=timeout)
|
return sh(cmd, stdin=stdin, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
def prod_helper(subcmd: str, *args: str) -> str:
|
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
|
||||||
"""Run the helper in prod memory; never upload a temporary file to prod."""
|
global _prod_ready
|
||||||
helper_php = HELPER_SRC.read_text(encoding="utf-8").replace("<?php", "", 1)
|
if not _prod_ready:
|
||||||
encoded = base64.b64encode(helper_php.encode("utf-8")).decode("ascii")
|
_ssh(f"cat > {PROD_HELPER}", stdin=HELPER_SRC.read_text(encoding="utf-8"))
|
||||||
code = f"eval(base64_decode('{encoded}'));"
|
_prod_ready = True
|
||||||
if PROD_DOCKER_CONTAINER:
|
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
||||||
remote = (
|
return _ssh(inner, stdin=stdin, timeout=180)
|
||||||
f"docker exec -i -e FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
|
|
||||||
f"{shlex.quote(PROD_DOCKER_CONTAINER)} php -r {shlex.quote(code)} -- "
|
|
||||||
+ " ".join(shlex.quote(part) for part in (subcmd, *args))
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
remote = (
|
|
||||||
f"FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} php -r {shlex.quote(code)} -- "
|
|
||||||
+ " ".join(shlex.quote(part) for part in (subcmd, *args))
|
|
||||||
)
|
|
||||||
return _ssh(remote, timeout=180)
|
|
||||||
|
|
||||||
|
|
||||||
def prod_read_full(post_id: int) -> dict:
|
def prod_read_full(post_id: int) -> dict:
|
||||||
@@ -131,17 +110,6 @@ def seed_ids_from_carta(carta_id: int) -> list[int]:
|
|||||||
return sorted(ids)
|
return sorted(ids)
|
||||||
|
|
||||||
|
|
||||||
def seed_ids_from_csv(raw_ids: str) -> list[int]:
|
|
||||||
"""Parse a deliberate prod→local subset for Mixbot --parte1 (#181)."""
|
|
||||||
try:
|
|
||||||
ids = sorted({int(part.strip()) for part in raw_ids.split(",") if part.strip()})
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValueError("--ids debe ser una lista CSV de IDs numéricos") from exc
|
|
||||||
if not ids or any(pid <= 0 for pid in ids):
|
|
||||||
raise ValueError("--ids debe contener al menos un ID positivo")
|
|
||||||
return ids
|
|
||||||
|
|
||||||
|
|
||||||
def collect_related_posts(seed_ids: list[int]) -> tuple[dict[int, dict], list[dict[str, int]]]:
|
def collect_related_posts(seed_ids: list[int]) -> tuple[dict[int, dict], list[dict[str, int]]]:
|
||||||
posts: dict[int, dict] = {}
|
posts: dict[int, dict] = {}
|
||||||
groups: dict[tuple[tuple[str, int], ...], dict[str, int]] = {}
|
groups: dict[tuple[tuple[str, int], ...], dict[str, int]] = {}
|
||||||
@@ -185,11 +153,7 @@ def local_read_safe(post_id: int) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def local_clone(post: dict, *, preserve_id: bool = True) -> int:
|
def local_clone(post: dict) -> int:
|
||||||
# Conserva el ID remoto como trazabilidad incluso cuando haya que crear un
|
|
||||||
# ID local nuevo por una colisión entre entornos.
|
|
||||||
meta = dict(post.get("meta", {}))
|
|
||||||
meta["fea_prod_source_id"] = [str(post["id"])]
|
|
||||||
payload = {
|
payload = {
|
||||||
"title": post["title"],
|
"title": post["title"],
|
||||||
"content": post.get("content", ""),
|
"content": post.get("content", ""),
|
||||||
@@ -202,14 +166,10 @@ def local_clone(post: dict, *, preserve_id: bool = True) -> int:
|
|||||||
"status": post.get("status"),
|
"status": post.get("status"),
|
||||||
"cats": post.get("cats", []),
|
"cats": post.get("cats", []),
|
||||||
"cat_slugs": post.get("cat_slugs", []),
|
"cat_slugs": post.get("cat_slugs", []),
|
||||||
"meta": meta,
|
"meta": post.get("meta", {}),
|
||||||
}
|
}
|
||||||
if preserve_id:
|
out = local_helper("clone", str(post["id"]), post.get("lang") or "es", STATUS,
|
||||||
out = local_helper("clone", str(post["id"]), post.get("lang") or "es", STATUS,
|
stdin=json.dumps(payload)).strip()
|
||||||
stdin=json.dumps(payload)).strip()
|
|
||||||
else:
|
|
||||||
out = local_helper("clone_new", post.get("lang") or "es", STATUS,
|
|
||||||
stdin=json.dumps(payload)).strip()
|
|
||||||
return int(out)
|
return int(out)
|
||||||
|
|
||||||
|
|
||||||
@@ -219,9 +179,13 @@ def local_save_group(group: dict[str, int]) -> dict[str, int]:
|
|||||||
|
|
||||||
|
|
||||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||||
def sync_posts(seed_ids: list[int], *, dry_run: bool, source_label: str,
|
def sync_carta(carta_id: int, *, dry_run: bool) -> int:
|
||||||
remap_conflicts: bool = False) -> int:
|
seed_ids = seed_ids_from_carta(carta_id)
|
||||||
log(f"Lote {source_label}: {len(seed_ids)} post(s) -> {seed_ids}")
|
log(f"Cluster descubierto desde fea_parse_carta_sections(prod#{carta_id}): "
|
||||||
|
f"{len(seed_ids)} post(s) -> {seed_ids}")
|
||||||
|
if not seed_ids or seed_ids == [carta_id]:
|
||||||
|
log(" ⚠️ El parser no resolvió ningún artículo enlazado (¿carta sin publicar aún, "
|
||||||
|
"o secciones sin encabezados reconocibles?). Revisa antes de continuar.")
|
||||||
|
|
||||||
posts, groups = collect_related_posts(seed_ids)
|
posts, groups = collect_related_posts(seed_ids)
|
||||||
|
|
||||||
@@ -232,82 +196,51 @@ def sync_posts(seed_ids: list[int], *, dry_run: bool, source_label: str,
|
|||||||
conflicts.append((pid, existing.get("title", ""), p.get("title", "")))
|
conflicts.append((pid, existing.get("title", ""), p.get("title", "")))
|
||||||
|
|
||||||
if conflicts:
|
if conflicts:
|
||||||
log(f" ⚠️ {len(conflicts)} CONFLICTO(S): el ID ya existe en local con OTRO contenido:")
|
log(f" ⚠️ {len(conflicts)} CONFLICTO(S): el ID ya existe en local con OTRO contenido "
|
||||||
|
f"y va a ser SOBRESCRITO:")
|
||||||
for pid, old_title, new_title in conflicts:
|
for pid, old_title, new_title in conflicts:
|
||||||
log(f" #{pid}: local actual «{old_title[:50]}» | prod «{new_title[:50]}»")
|
log(f" #{pid}: local actual «{old_title[:50]}» -> prod «{new_title[:50]}»")
|
||||||
if not dry_run and not remap_conflicts:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Importación cancelada: usar --remap-conflicts para crear IDs locales nuevos; "
|
|
||||||
"nunca se sobrescriben posts locales por un choque de IDs entre entornos."
|
|
||||||
)
|
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
for pid, p in posts.items():
|
for pid, p in posts.items():
|
||||||
action = "REMAPPING to new local ID" if pid in {c[0] for c in conflicts} else "PULL"
|
log(f" PULL prod#{pid} [{p.get('lang','?')}] status={p.get('status')} "
|
||||||
log(f" {action} prod#{pid} [{p.get('lang','?')}] status={p.get('status')} "
|
|
||||||
f"slug={p.get('slug','')} «{p.get('title','')[:50]}»")
|
f"slug={p.get('slug','')} «{p.get('title','')[:50]}»")
|
||||||
for group in groups:
|
for group in groups:
|
||||||
log(f" GROUP {group}")
|
log(f" GROUP {group}")
|
||||||
log("DRY-RUN: nada escrito en local.")
|
log("DRY-RUN: nada escrito en local.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
conflict_ids = {pid for pid, _, _ in conflicts}
|
|
||||||
id_map: dict[int, int] = {}
|
|
||||||
for pid, p in posts.items():
|
for pid, p in posts.items():
|
||||||
new_id = local_clone(p, preserve_id=pid not in conflict_ids)
|
new_id = local_clone(p)
|
||||||
id_map[pid] = new_id
|
|
||||||
if new_id != pid:
|
if new_id != pid:
|
||||||
log(f" remap prod#{pid} -> local#{new_id} [{p.get('lang','?')}] «{p['title'][:45]}»")
|
log(f" ⚠️ prod#{pid} se clonó como local#{new_id} — ID NO preservado, revisar a mano.")
|
||||||
else:
|
else:
|
||||||
log(f" clone prod#{pid} -> local#{new_id} [{p.get('lang','?')}] «{p['title'][:45]}»")
|
log(f" clone prod#{pid} -> local#{new_id} [{p.get('lang','?')}] «{p['title'][:45]}»")
|
||||||
|
|
||||||
for group in groups:
|
for group in groups:
|
||||||
local_group = {lang: id_map.get(pid, pid) for lang, pid in group.items()}
|
if len(group) < 2:
|
||||||
if len(local_group) < 2:
|
|
||||||
continue
|
continue
|
||||||
saved = local_save_group(local_group)
|
saved = local_save_group(group)
|
||||||
log(f" group enlazado en local {saved}")
|
log(f" group enlazado en local {saved}")
|
||||||
|
|
||||||
log(f"FIN sync prod->local. fuente={source_label} posts={len(posts)} conflictos_previos={len(conflicts)}")
|
log(f"FIN sync prod->local. carta={carta_id} posts={len(posts)} conflictos_previos={len(conflicts)}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def sync_carta(carta_id: int, *, dry_run: bool, remap_conflicts: bool = False) -> int:
|
|
||||||
seed_ids = seed_ids_from_carta(carta_id)
|
|
||||||
if not seed_ids or seed_ids == [carta_id]:
|
|
||||||
log(" ⚠️ El parser no resolvió ningún artículo enlazado (¿carta sin publicar aún, "
|
|
||||||
"o secciones sin encabezados reconocibles?). Revisa antes de continuar.")
|
|
||||||
return sync_posts(seed_ids, dry_run=dry_run, source_label=f"carta prod#{carta_id}",
|
|
||||||
remap_conflicts=remap_conflicts)
|
|
||||||
|
|
||||||
|
|
||||||
def sync_ids(raw_ids: str, *, dry_run: bool, remap_conflicts: bool = False) -> int:
|
|
||||||
return sync_posts(seed_ids_from_csv(raw_ids), dry_run=dry_run,
|
|
||||||
source_label="IDs explícitos de Mixbot --parte1",
|
|
||||||
remap_conflicts=remap_conflicts)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
ap = argparse.ArgumentParser(
|
ap = argparse.ArgumentParser(
|
||||||
description="Copia una carta (y su cluster de artículos) de PROD a LOCAL preservando IDs.")
|
description="Copia una carta (y su cluster de artículos) de PROD a LOCAL preservando IDs.")
|
||||||
group = ap.add_mutually_exclusive_group(required=True)
|
ap.add_argument("--carta", type=int, required=True, help="ID del post ES de la carta en PROD.")
|
||||||
group.add_argument("--carta", type=int, help="ID del post ES de la carta en PROD.")
|
|
||||||
group.add_argument("--ids", help="Lista CSV explícita de posts ES para Mixbot --parte1 (#181).")
|
|
||||||
ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no escribe en local.")
|
ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no escribe en local.")
|
||||||
ap.add_argument("--remap-conflicts", action="store_true",
|
|
||||||
help="Ante IDs ocupados localmente, crea IDs nuevos y guarda fea_prod_source_id; nunca sobrescribe.")
|
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
if not PROD_HOST:
|
if not PROD_HOST or not PROD_PASS:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"Falta FEA_PROD_SSH_HOST en el entorno (FEA_PROD_SSH_PASS es opcional: "
|
"Faltan FEA_PROD_SSH_HOST / FEA_PROD_SSH_PASS en el entorno.\n"
|
||||||
"el Hetzner nuevo usa auth por clave).\n"
|
|
||||||
"Antes de ejecutar: source ~/.hermes/profiles/feadulta/.env"
|
"Antes de ejecutar: source ~/.hermes/profiles/feadulta/.env"
|
||||||
)
|
)
|
||||||
|
|
||||||
if args.carta:
|
return sync_carta(args.carta, dry_run=args.dry_run)
|
||||||
return sync_carta(args.carta, dry_run=args.dry_run, remap_conflicts=args.remap_conflicts)
|
|
||||||
return sync_ids(args.ids, dry_run=args.dry_run, remap_conflicts=args.remap_conflicts)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -16,10 +16,8 @@ coincidencia local↔prod cuando prod va por detrás.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import base64
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shlex
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -31,20 +29,12 @@ DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
|
|||||||
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
|
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
|
||||||
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
||||||
|
|
||||||
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
|
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
|
||||||
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
|
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
|
||||||
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
|
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
|
||||||
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
|
PROD_HELPER = "/tmp/fea_translate_helper.php"
|
||||||
# define, el helper se ejecuta en memoria dentro del contenedor; no se escribe
|
|
||||||
# ningún fichero temporal en prod. Espejo de sync_carta_from_prod.py.
|
|
||||||
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
|
|
||||||
|
|
||||||
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
|
HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php"
|
||||||
# El helper se ejecuta en memoria con `php -r` en prod. Así no se copia ningún
|
|
||||||
# fichero a /tmp remoto y stdin queda disponible para el payload JSON.
|
|
||||||
HELPER_EVAL_CODE = "eval(base64_decode(" + repr(
|
|
||||||
base64.b64encode(HELPER_SRC.read_text(encoding="utf-8").removeprefix("<?php").encode("utf-8")).decode("ascii")
|
|
||||||
) + "));"
|
|
||||||
LOCAL_HELPER_DST = "/tmp/fea_translate_helper.php"
|
LOCAL_HELPER_DST = "/tmp/fea_translate_helper.php"
|
||||||
STATE_FILE = Path(os.environ.get("FEA_SYNC_STATE", "/tmp/feadulta-sync-state.json"))
|
STATE_FILE = Path(os.environ.get("FEA_SYNC_STATE", "/tmp/feadulta-sync-state.json"))
|
||||||
LOG_FILE = Path(os.environ.get("FEA_SYNC_LOG", "/tmp/feadulta-sync.log"))
|
LOG_FILE = Path(os.environ.get("FEA_SYNC_LOG", "/tmp/feadulta-sync.log"))
|
||||||
@@ -117,20 +107,8 @@ def local_read_full(post_id: int) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def local_translation_pairs() -> list[tuple[int, int]]:
|
def local_translation_pairs() -> list[tuple[int, int]]:
|
||||||
"""Devuelve (traducción_local, origen_ES_en_prod).
|
q = ("SELECT post_id, meta_value FROM wp_postmeta "
|
||||||
|
"WHERE meta_key='traduccion_origen' ORDER BY CAST(meta_value AS UNSIGNED), post_id;")
|
||||||
Normalmente ambos entornos compartían IDs. Para importaciones remapeadas,
|
|
||||||
la carta ES local guarda `fea_prod_source_id`; esa referencia remota tiene
|
|
||||||
prioridad y evita enlazar una traducción con un post distinto de prod.
|
|
||||||
"""
|
|
||||||
q = (
|
|
||||||
"SELECT tr.post_id, COALESCE(NULLIF(src.meta_value,''), tr.meta_value) "
|
|
||||||
"FROM wp_postmeta tr "
|
|
||||||
"LEFT JOIN wp_postmeta src ON src.post_id=CAST(tr.meta_value AS UNSIGNED) "
|
|
||||||
"AND src.meta_key='fea_prod_source_id' "
|
|
||||||
"WHERE tr.meta_key='traduccion_origen' "
|
|
||||||
"ORDER BY CAST(COALESCE(NULLIF(src.meta_value,''), tr.meta_value) AS UNSIGNED), tr.post_id;"
|
|
||||||
)
|
|
||||||
out = sh(["docker", "exec", DB_CONTAINER, "mysql", f"-u{DB_USER}", f"-p{DB_PASS}",
|
out = sh(["docker", "exec", DB_CONTAINER, "mysql", f"-u{DB_USER}", f"-p{DB_PASS}",
|
||||||
DB_NAME, "-N", "-e", q])
|
DB_NAME, "-N", "-e", q])
|
||||||
pairs = []
|
pairs = []
|
||||||
@@ -177,31 +155,21 @@ def collect_related_posts(seed_ids: list[int]) -> tuple[dict[int, dict], list[di
|
|||||||
|
|
||||||
|
|
||||||
# ── Prod ─────────────────────────────────────────────────────────────────────
|
# ── Prod ─────────────────────────────────────────────────────────────────────
|
||||||
|
_prod_ready = False
|
||||||
|
|
||||||
|
|
||||||
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
||||||
# El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
|
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||||
# solo se antepone si hay contraseña configurada (servidor viejo CDMON).
|
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||||
if PROD_PASS:
|
|
||||||
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
|
||||||
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
|
||||||
else:
|
|
||||||
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
|
|
||||||
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
|
||||||
return sh(cmd, stdin=stdin, timeout=timeout)
|
return sh(cmd, stdin=stdin, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
|
def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str:
|
||||||
"""Ejecuta el helper PHP en memoria; no crea ficheros temporales en prod."""
|
global _prod_ready
|
||||||
argv = " ".join(shlex.quote(x) for x in (subcmd, *args))
|
if not _prod_ready:
|
||||||
if PROD_DOCKER_CONTAINER:
|
_ssh(f"cat > {PROD_HELPER}", stdin=HELPER_SRC.read_text(encoding="utf-8"))
|
||||||
inner = (
|
_prod_ready = True
|
||||||
f"docker exec -i -e FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
|
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
||||||
f"{shlex.quote(PROD_DOCKER_CONTAINER)} php -r {shlex.quote(HELPER_EVAL_CODE)} {argv}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
inner = (
|
|
||||||
f"FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
|
|
||||||
f"php -r {shlex.quote(HELPER_EVAL_CODE)} {argv}"
|
|
||||||
)
|
|
||||||
return _ssh(inner, stdin=stdin, timeout=180)
|
return _ssh(inner, stdin=stdin, timeout=180)
|
||||||
|
|
||||||
|
|
||||||
@@ -288,12 +256,7 @@ def deploy_fixed_ids(seed_ids: list[int], *, keep_existing: set[int], dry_run: b
|
|||||||
|
|
||||||
|
|
||||||
# ── Main legado ──────────────────────────────────────────────────────────────
|
# ── Main legado ──────────────────────────────────────────────────────────────
|
||||||
def legacy_sync(limit: int, origin: int, *, dry_run: bool = False) -> int:
|
def legacy_sync(limit: int, origin: int) -> int:
|
||||||
"""Sincroniza por origen ES+idioma, sin reutilizar IDs locales en prod.
|
|
||||||
|
|
||||||
Este es el modo seguro si producción ha avanzado y sus IDs ya pueden
|
|
||||||
colisionar con traducciones creadas en el entorno local.
|
|
||||||
"""
|
|
||||||
state = load_state()
|
state = load_state()
|
||||||
pairs = local_translation_pairs()
|
pairs = local_translation_pairs()
|
||||||
if origin:
|
if origin:
|
||||||
@@ -317,10 +280,6 @@ def legacy_sync(limit: int, origin: int, *, dry_run: bool = False) -> int:
|
|||||||
if key in state["done"]:
|
if key in state["done"]:
|
||||||
n_skip += 1
|
n_skip += 1
|
||||||
continue
|
continue
|
||||||
if dry_run:
|
|
||||||
log(f" PLAN {key}: crearía traducción [{lang}] «{t['title'][:45]}»")
|
|
||||||
n_ok += 1
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
new_id = prod_create(src_origin, lang, t["title"], t["content"])
|
new_id = prod_create(src_origin, lang, t["title"], t["content"])
|
||||||
state["done"][key] = new_id
|
state["done"][key] = new_id
|
||||||
@@ -333,10 +292,8 @@ def legacy_sync(limit: int, origin: int, *, dry_run: bool = False) -> int:
|
|||||||
n_err += 1
|
n_err += 1
|
||||||
log(f" {key} ERROR: {exc}")
|
log(f" {key} ERROR: {exc}")
|
||||||
|
|
||||||
if not dry_run:
|
save_state(state)
|
||||||
save_state(state)
|
log(f"FIN sync legado. nuevos={n_ok} saltados={n_skip} errores={n_err}. Estado: {STATE_FILE}")
|
||||||
mode = "DRY-RUN" if dry_run else "SYNC"
|
|
||||||
log(f"FIN sync legado {mode}. nuevos/plan={n_ok} saltados={n_skip} errores={n_err}. Estado: {STATE_FILE}")
|
|
||||||
log("Recuerda en prod: ejecutar remap_translation_cats.php si alguna quedó sin categoría traducida.")
|
log("Recuerda en prod: ejecutar remap_translation_cats.php si alguna quedó sin categoría traducida.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -361,7 +318,7 @@ def main() -> int:
|
|||||||
keep_existing = set(parse_csv_ints(args.keep_existing))
|
keep_existing = set(parse_csv_ints(args.keep_existing))
|
||||||
return deploy_fixed_ids(seed_ids, keep_existing=keep_existing, dry_run=args.dry_run)
|
return deploy_fixed_ids(seed_ids, keep_existing=keep_existing, dry_run=args.dry_run)
|
||||||
|
|
||||||
return legacy_sync(args.limit, args.origin, dry_run=args.dry_run)
|
return legacy_sync(args.limit, args.origin)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -271,8 +271,7 @@ def save_state(state: dict) -> None:
|
|||||||
|
|
||||||
|
|
||||||
# ── Orquestación ─────────────────────────────────────────────────────────────
|
# ── Orquestación ─────────────────────────────────────────────────────────────
|
||||||
def process_post(post_id: int, langs: list[str], status: str, force: bool, state: dict,
|
def process_post(post_id: int, langs: list[str], status: str, force: bool, state: dict) -> None:
|
||||||
*, dry_run: bool = False) -> None:
|
|
||||||
src = read_post(post_id)
|
src = read_post(post_id)
|
||||||
if src.get("lang") and src["lang"] != "es":
|
if src.get("lang") and src["lang"] != "es":
|
||||||
log(f"#{post_id} no es ES (lang={src['lang']}) — saltado")
|
log(f"#{post_id} no es ES (lang={src['lang']}) — saltado")
|
||||||
@@ -287,14 +286,8 @@ def process_post(post_id: int, langs: list[str], status: str, force: bool, state
|
|||||||
state["done"][key] = existing
|
state["done"][key] = existing
|
||||||
continue
|
continue
|
||||||
if existing and force:
|
if existing and force:
|
||||||
if dry_run:
|
|
||||||
log(f" {lang}: PLAN regenerar traducción previa #{existing}")
|
|
||||||
continue
|
|
||||||
php_helper("unlink", str(post_id), lang)
|
php_helper("unlink", str(post_id), lang)
|
||||||
log(f" {lang}: --force, eliminada traducción previa #{existing}")
|
log(f" {lang}: --force, eliminada traducción previa #{existing}")
|
||||||
if dry_run:
|
|
||||||
log(f" {lang}: PLAN traducir con {ENGINE} y crear como {status}")
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
title = translate_text(src["title"], lang, is_title=True)
|
title = translate_text(src["title"], lang, is_title=True)
|
||||||
@@ -319,7 +312,6 @@ def main() -> int:
|
|||||||
ap.add_argument("--langs", default="en,fr,it,pt", help="Idiomas destino separados por coma.")
|
ap.add_argument("--langs", default="en,fr,it,pt", help="Idiomas destino separados por coma.")
|
||||||
ap.add_argument("--status", default="draft", choices=["draft", "publish"], help="Estado de la traducción.")
|
ap.add_argument("--status", default="draft", choices=["draft", "publish"], help="Estado de la traducción.")
|
||||||
ap.add_argument("--force", action="store_true", help="Regenera aunque ya exista la traducción.")
|
ap.add_argument("--force", action="store_true", help="Regenera aunque ya exista la traducción.")
|
||||||
ap.add_argument("--dry-run", action="store_true", help="Muestra el plan sin llamar al LLM ni escribir en WordPress/estado.")
|
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
langs = [l.strip() for l in args.langs.split(",") if l.strip() in LANG_NAMES]
|
langs = [l.strip() for l in args.langs.split(",") if l.strip() in LANG_NAMES]
|
||||||
@@ -337,11 +329,9 @@ def main() -> int:
|
|||||||
|
|
||||||
state = load_state()
|
state = load_state()
|
||||||
for pid in ids:
|
for pid in ids:
|
||||||
process_post(pid, langs, args.status, args.force, state, dry_run=args.dry_run)
|
process_post(pid, langs, args.status, args.force, state)
|
||||||
if not args.dry_run:
|
save_state(state)
|
||||||
save_state(state)
|
log(f"FIN. {len(state['done'])} traducciones registradas, "
|
||||||
mode = "DRY-RUN" if args.dry_run else "FIN"
|
|
||||||
log(f"{mode}. {len(state['done'])} traducciones registradas, "
|
|
||||||
f"{len(state.get('errors', {}))} errores. Estado: {STATE_FILE}")
|
f"{len(state.get('errors', {}))} errores. Estado: {STATE_FILE}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,7 @@ set -uo pipefail
|
|||||||
export PATH="/home/rafa/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
export PATH="/home/rafa/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||||
export HOME="/home/rafa"
|
export HOME="/home/rafa"
|
||||||
|
|
||||||
# El repo NO se fija a mano: se deduce de dónde vive este script. El checkout
|
REPO="/home/rafa/joomla-migration"
|
||||||
# principal cambia de rama a menudo, y en las ramas que no llevan estos scripts
|
|
||||||
# el cron se quedaba llamando a un fichero inexistente y fallaba en silencio
|
|
||||||
# (pasó del 5 al 8 de agosto de 2026: 7 ventanas perdidas). Por eso el cron
|
|
||||||
# apunta al worktree fijo ~/worktrees/fea-tts-backlog, y desde aquí se
|
|
||||||
# autodetecta: donde esté el script, ahí está su repo.
|
|
||||||
REPO="${FEA_TTS_REPO:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
|
||||||
PY="/home/rafa/tts-local/xtts-venv/bin/python"
|
PY="/home/rafa/tts-local/xtts-venv/bin/python"
|
||||||
QUOTA="/home/rafa/ytsummaries/scripts/quota.py"
|
QUOTA="/home/rafa/ytsummaries/scripts/quota.py"
|
||||||
WORK="/tmp/fea-tts-backlog"
|
WORK="/tmp/fea-tts-backlog"
|
||||||
|
|||||||
+2
-12
@@ -29,7 +29,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|||||||
import minimax_tts as mm # get_post_text, add_pauses, t2a, OUT
|
import minimax_tts as mm # get_post_text, add_pauses, t2a, OUT
|
||||||
import translate_post as tp # carta_article_ids
|
import translate_post as tp # carta_article_ids
|
||||||
|
|
||||||
VOICE = os.environ.get("FEA_TTS_VOICE", "NicoFeadulta2026")
|
VOICE = "NicoFeadulta2026"
|
||||||
MODEL = "speech-2.8-hd"
|
MODEL = "speech-2.8-hd"
|
||||||
CONTAINER = "wordpress-web"
|
CONTAINER = "wordpress-web"
|
||||||
PROD = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
|
PROD = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
|
||||||
@@ -103,8 +103,6 @@ def main():
|
|||||||
help="para tras N audios OK en esta ejecución (0 = sin tope)")
|
help="para tras N audios OK en esta ejecución (0 = sin tope)")
|
||||||
parser.add_argument("--dry-run", action="store_true",
|
parser.add_argument("--dry-run", action="store_true",
|
||||||
help="imprime la cola y sale, sin sintetizar ni gastar cuota")
|
help="imprime la cola y sale, sin sintetizar ni gastar cuota")
|
||||||
parser.add_argument("--allow-default-voice", action="store_true",
|
|
||||||
help="permite Nico para autores sin voz digitalizada (desactivado por defecto)")
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if args.ids:
|
if args.ids:
|
||||||
os.environ["FEA_TTS_IDS"] = args.ids
|
os.environ["FEA_TTS_IDS"] = args.ids
|
||||||
@@ -151,15 +149,7 @@ def main():
|
|||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
cloned_voice = mm.voice_for_author(author, "")
|
voice = mm.voice_for_author(author, VOICE)
|
||||||
if cloned_voice:
|
|
||||||
voice = cloned_voice
|
|
||||||
elif args.allow_default_voice:
|
|
||||||
voice = VOICE
|
|
||||||
else:
|
|
||||||
log(f"#{pid}: autor sin voz digitalizada (author_id={author}); omitido")
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
rc = mm.t2a(mm.add_pauses(text), voice, MODEL, f"prod-{pid}")
|
rc = mm.t2a(mm.add_pauses(text), voice, MODEL, f"prod-{pid}")
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
src = mm.OUT / f"prod-{pid}.mp3"
|
src = mm.OUT / f"prod-{pid}.mp3"
|
||||||
|
|||||||
@@ -151,23 +151,28 @@ function fea_beta_labels(): array {
|
|||||||
'es' => ['region'=>'Aviso Beta','intro'=>'Estamos en','help'=>'¿Nos ayudas a mejorar FeAdulta?',
|
'es' => ['region'=>'Aviso Beta','intro'=>'Estamos en','help'=>'¿Nos ayudas a mejorar FeAdulta?',
|
||||||
'opinion'=>'Dar mi opinión','collab'=>'Colaborar','dismiss'=>'Cerrar aviso','fbregion'=>'Feedback de la página',
|
'opinion'=>'Dar mi opinión','collab'=>'Colaborar','dismiss'=>'Cerrar aviso','fbregion'=>'Feedback de la página',
|
||||||
'close'=>'Cerrar','q'=>'¿Se ve bien esta página?','up'=>'Sí, se ve bien','down'=>'No, hay algo mal',
|
'close'=>'Cerrar','q'=>'¿Se ve bien esta página?','up'=>'Sí, se ve bien','down'=>'No, hay algo mal',
|
||||||
'ph'=>'¿Algo falla o se ve mal? Cuéntanoslo (opcional)','send'=>'Enviar','thanks'=>'¡Gracias por ayudar! 🙏'],
|
'ph'=>'¿Algo falla o se ve mal? Cuéntanoslo (opcional)','send'=>'Enviar','thanks'=>'¡Gracias por ayudar! 🙏',
|
||||||
|
'error'=>'No se pudo enviar. Comprueba tu conexión e inténtalo de nuevo.'],
|
||||||
'en' => ['region'=>'Beta notice','intro'=>'We are in','help'=>'Will you help us improve FeAdulta?',
|
'en' => ['region'=>'Beta notice','intro'=>'We are in','help'=>'Will you help us improve FeAdulta?',
|
||||||
'opinion'=>'Give feedback','collab'=>'Collaborate','dismiss'=>'Close notice','fbregion'=>'Page feedback',
|
'opinion'=>'Give feedback','collab'=>'Collaborate','dismiss'=>'Close notice','fbregion'=>'Page feedback',
|
||||||
'close'=>'Close','q'=>'Does this page look right?','up'=>'Yes, looks good','down'=>'No, something is wrong',
|
'close'=>'Close','q'=>'Does this page look right?','up'=>'Yes, looks good','down'=>'No, something is wrong',
|
||||||
'ph'=>'Something broken or off? Tell us (optional)','send'=>'Send','thanks'=>'Thanks for helping! 🙏'],
|
'ph'=>'Something broken or off? Tell us (optional)','send'=>'Send','thanks'=>'Thanks for helping! 🙏',
|
||||||
|
'error'=>'Could not send. Check your connection and try again.'],
|
||||||
'fr' => ['region'=>'Avis Bêta','intro'=>'Nous sommes en','help'=>'Voulez-vous nous aider à améliorer FeAdulta ?',
|
'fr' => ['region'=>'Avis Bêta','intro'=>'Nous sommes en','help'=>'Voulez-vous nous aider à améliorer FeAdulta ?',
|
||||||
'opinion'=>'Donner mon avis','collab'=>'Collaborer','dismiss'=>'Fermer l’avis','fbregion'=>'Retour sur la page',
|
'opinion'=>'Donner mon avis','collab'=>'Collaborer','dismiss'=>'Fermer l’avis','fbregion'=>'Retour sur la page',
|
||||||
'close'=>'Fermer','q'=>'Cette page s’affiche-t-elle bien ?','up'=>'Oui, c’est bien','down'=>'Non, il y a un problème',
|
'close'=>'Fermer','q'=>'Cette page s’affiche-t-elle bien ?','up'=>'Oui, c’est bien','down'=>'Non, il y a un problème',
|
||||||
'ph'=>'Un souci ou un affichage incorrect ? Dites-le-nous (facultatif)','send'=>'Envoyer','thanks'=>'Merci de votre aide ! 🙏'],
|
'ph'=>'Un souci ou un affichage incorrect ? Dites-le-nous (facultatif)','send'=>'Envoyer','thanks'=>'Merci de votre aide ! 🙏',
|
||||||
|
'error'=>'Échec de l’envoi. Vérifiez votre connexion et réessayez.'],
|
||||||
'it' => ['region'=>'Avviso Beta','intro'=>'Siamo in','help'=>'Ci aiuti a migliorare FeAdulta?',
|
'it' => ['region'=>'Avviso Beta','intro'=>'Siamo in','help'=>'Ci aiuti a migliorare FeAdulta?',
|
||||||
'opinion'=>'Dai la tua opinione','collab'=>'Collabora','dismiss'=>'Chiudi avviso','fbregion'=>'Feedback della pagina',
|
'opinion'=>'Dai la tua opinione','collab'=>'Collabora','dismiss'=>'Chiudi avviso','fbregion'=>'Feedback della pagina',
|
||||||
'close'=>'Chiudi','q'=>'Questa pagina si vede bene?','up'=>'Sì, si vede bene','down'=>'No, c’è qualcosa che non va',
|
'close'=>'Chiudi','q'=>'Questa pagina si vede bene?','up'=>'Sì, si vede bene','down'=>'No, c’è qualcosa che non va',
|
||||||
'ph'=>'Qualcosa non va o si vede male? Faccelo sapere (facoltativo)','send'=>'Invia','thanks'=>'Grazie per l’aiuto! 🙏'],
|
'ph'=>'Qualcosa non va o si vede male? Faccelo sapere (facoltativo)','send'=>'Invia','thanks'=>'Grazie per l’aiuto! 🙏',
|
||||||
|
'error'=>'Invio non riuscito. Controlla la connessione e riprova.'],
|
||||||
'pt' => ['region'=>'Aviso Beta','intro'=>'Estamos em','help'=>'Ajuda-nos a melhorar a FeAdulta?',
|
'pt' => ['region'=>'Aviso Beta','intro'=>'Estamos em','help'=>'Ajuda-nos a melhorar a FeAdulta?',
|
||||||
'opinion'=>'Dar a minha opinião','collab'=>'Colaborar','dismiss'=>'Fechar aviso','fbregion'=>'Feedback da página',
|
'opinion'=>'Dar a minha opinião','collab'=>'Colaborar','dismiss'=>'Fechar aviso','fbregion'=>'Feedback da página',
|
||||||
'close'=>'Fechar','q'=>'Esta página vê-se bem?','up'=>'Sim, vê-se bem','down'=>'Não, há algo errado',
|
'close'=>'Fechar','q'=>'Esta página vê-se bem?','up'=>'Sim, vê-se bem','down'=>'Não, há algo errado',
|
||||||
'ph'=>'Algo falha ou vê-se mal? Conta-nos (opcional)','send'=>'Enviar','thanks'=>'Obrigado por ajudar! 🙏'],
|
'ph'=>'Algo falha ou vê-se mal? Conta-nos (opcional)','send'=>'Enviar','thanks'=>'Obrigado por ajudar! 🙏',
|
||||||
|
'error'=>'Não foi possível enviar. Verifica a ligação e tenta novamente.'],
|
||||||
];
|
];
|
||||||
$lang = function_exists('pll_current_language') ? (string) pll_current_language() : 'es';
|
$lang = function_exists('pll_current_language') ? (string) pll_current_language() : 'es';
|
||||||
return $all[$lang] ?? $all['es'];
|
return $all[$lang] ?? $all['es'];
|
||||||
@@ -211,6 +216,8 @@ add_action('wp_footer', function () {
|
|||||||
font:inherit; font-size:.85rem; resize:vertical; min-height:58px; box-sizing:border-box; }
|
font:inherit; font-size:.85rem; resize:vertical; min-height:58px; box-sizing:border-box; }
|
||||||
#fea-fb .fea-fb-send { background:#8b1a2e; color:#fff; border:1px solid #8b1a2e; border-radius:8px;
|
#fea-fb .fea-fb-send { background:#8b1a2e; color:#fff; border:1px solid #8b1a2e; border-radius:8px;
|
||||||
padding:6px 12px; font-size:.85rem; width:100%; cursor:pointer; }
|
padding:6px 12px; font-size:.85rem; width:100%; cursor:pointer; }
|
||||||
|
#fea-fb .fea-fb-send:disabled { opacity:.6; cursor:default; }
|
||||||
|
#fea-fb .fea-fb-error { color:#8b1a2e; font-size:.82rem; margin:0 0 8px; }
|
||||||
#fea-fb .fea-fb-hp { position:absolute; left:-9999px; }
|
#fea-fb .fea-fb-hp { position:absolute; left:-9999px; }
|
||||||
#fea-fb .fea-fb-close { position:absolute; top:4px; right:8px; border:0; background:none; font-size:1rem; cursor:pointer; padding:2px 4px; line-height:1; }
|
#fea-fb .fea-fb-close { position:absolute; top:4px; right:8px; border:0; background:none; font-size:1rem; cursor:pointer; padding:2px 4px; line-height:1; }
|
||||||
@media (max-width:600px){ #fea-fb{ right:10px; left:10px; max-width:none; } #fea-beta-bar{ font-size:.8rem; } }
|
@media (max-width:600px){ #fea-fb{ right:10px; left:10px; max-width:none; } #fea-beta-bar{ font-size:.8rem; } }
|
||||||
@@ -234,6 +241,7 @@ add_action('wp_footer', function () {
|
|||||||
<div class="fea-fb-more" hidden>
|
<div class="fea-fb-more" hidden>
|
||||||
<input type="text" class="fea-fb-hp" tabindex="-1" autocomplete="off" aria-hidden="true" placeholder="No rellenar">
|
<input type="text" class="fea-fb-hp" tabindex="-1" autocomplete="off" aria-hidden="true" placeholder="No rellenar">
|
||||||
<textarea placeholder="<?php echo esc_attr($t['ph']); ?>"></textarea>
|
<textarea placeholder="<?php echo esc_attr($t['ph']); ?>"></textarea>
|
||||||
|
<p class="fea-fb-error" hidden role="alert"><?php echo esc_html($t['error']); ?></p>
|
||||||
<button type="button" class="fea-fb-send"><?php echo esc_html($t['send']); ?></button>
|
<button type="button" class="fea-fb-send"><?php echo esc_html($t['send']); ?></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="fea-fb-thanks" hidden><?php echo esc_html($t['thanks']); ?></div>
|
<div class="fea-fb-thanks" hidden><?php echo esc_html($t['thanks']); ?></div>
|
||||||
@@ -252,6 +260,8 @@ add_action('wp_footer', function () {
|
|||||||
var moreEl = box.querySelector('.fea-fb-more');
|
var moreEl = box.querySelector('.fea-fb-more');
|
||||||
var votes = box.querySelectorAll('.fea-fb-vote');
|
var votes = box.querySelectorAll('.fea-fb-vote');
|
||||||
var thanks = box.querySelector('.fea-fb-thanks');
|
var thanks = box.querySelector('.fea-fb-thanks');
|
||||||
|
var errorEl = box.querySelector('.fea-fb-error');
|
||||||
|
var sendBtn = box.querySelector('.fea-fb-send');
|
||||||
|
|
||||||
// Mostrar la barra salvo que el usuario la haya descartado antes.
|
// Mostrar la barra salvo que el usuario la haya descartado antes.
|
||||||
try { if (!localStorage.getItem('fea_beta_bar_off')) bar.classList.remove('hidden'); }
|
try { if (!localStorage.getItem('fea_beta_bar_off')) bar.classList.remove('hidden'); }
|
||||||
@@ -273,17 +283,31 @@ add_action('wp_footer', function () {
|
|||||||
moreEl.hidden = false;
|
moreEl.hidden = false;
|
||||||
});});
|
});});
|
||||||
|
|
||||||
box.querySelector('.fea-fb-send').addEventListener('click', function(){
|
sendBtn.addEventListener('click', function(){
|
||||||
if(!chosen) return;
|
if(!chosen) return;
|
||||||
var hp = box.querySelector('.fea-fb-hp').value;
|
var hp = box.querySelector('.fea-fb-hp').value;
|
||||||
var comment = box.querySelector('textarea').value;
|
var comment = box.querySelector('textarea').value;
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
errorEl.hidden = true;
|
||||||
fetch(REST, { method:'POST', headers:{'Content-Type':'application/json'},
|
fetch(REST, { method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({ vote:chosen, comment:comment, url:location.href, post_id:pid,
|
body: JSON.stringify({ vote:chosen, comment:comment, url:location.href, post_id:pid,
|
||||||
lang:lang, title:document.title, website:hp }) }).catch(function(){});
|
lang:lang, title:document.title, website:hp }) })
|
||||||
box.querySelector('.fea-fb-btns').hidden = true;
|
.then(function(res){
|
||||||
box.querySelector('.fea-fb-q').hidden = true;
|
if(!res.ok) throw new Error('http_' + res.status);
|
||||||
moreEl.hidden = true; thanks.hidden = false;
|
return res.json();
|
||||||
setTimeout(closeCard, 2200);
|
})
|
||||||
|
.then(function(data){
|
||||||
|
if(!data || data.ok !== true) throw new Error('bad_response');
|
||||||
|
box.querySelector('.fea-fb-btns').hidden = true;
|
||||||
|
box.querySelector('.fea-fb-q').hidden = true;
|
||||||
|
moreEl.hidden = true; thanks.hidden = false;
|
||||||
|
setTimeout(closeCard, 2200);
|
||||||
|
})
|
||||||
|
.catch(function(err){
|
||||||
|
if (window.console && console.warn) console.warn('fea-fb submit failed:', err && err.message);
|
||||||
|
errorEl.hidden = false;
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user