diff --git a/scripts/fea_translate_helper.php b/scripts/fea_translate_helper.php new file mode 100644 index 0000000..dd35ec3 --- /dev/null +++ b/scripts/fea_translate_helper.php @@ -0,0 +1,301 @@ + ...`): + * read → JSON {id,title,content,excerpt,lang,status,author,date,cats} + * read_full → JSON con slug, metas, categorías y grupo Polylang + * exists → imprime el ID de la traducción en (0 si no hay) + * create (lee {title,content} por stdin) + * → crea el post traducido, lo enlaza con Polylang y mete metas; + * imprime el nuevo ID. + * clone (lee payload JSON por stdin) + * → inserta/actualiza un post con ID explícito, categorías y metas. + * save_translations → guarda un grupo Polylang exacto leído por stdin. + * + * Ver issue rafa/feadulta#75. + */ + +// Bootstrap portable. Si WP no está cargado (modo standalone), cargar wp-load. +// Local (docker): /var/www/html/wp-load.php (por defecto). +// Prod: export FEA_WP_LOAD=/web/wp-load.php +// (Si se ejecuta vía `wp eval-file`, ABSPATH ya está definido y no se recarga.) +if (!defined('ABSPATH')) { + $_SERVER['REQUEST_URI'] = $_SERVER['REQUEST_URI'] ?? '/'; + $_SERVER['HTTP_HOST'] = $_SERVER['HTTP_HOST'] ?? 'localhost'; + require_once (getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php'); +} + +if (!function_exists('pll_set_post_language')) { + fwrite(STDERR, "Polylang no disponible\n"); + exit(2); +} + +$cmd = $argv[1] ?? ''; + +function out_json($data): void { echo wp_json_encode($data); } + +function meta_payload(int $id): array { + $raw = get_post_meta($id); + $out = []; + foreach ($raw as $key => $values) { + if (in_array($key, ['_edit_lock', '_edit_last'], true)) { + continue; + } + $out[$key] = array_map('maybe_unserialize', (array) $values); + } + return $out; +} + +function normalize_meta_input(array $payload): array { + $meta = $payload['meta'] ?? []; + if (!is_array($meta)) { + return []; + } + $out = []; + foreach ($meta as $key => $values) { + if (!is_string($key) || $key === '') { + continue; + } + if (!is_array($values)) { + $values = [$values]; + } + $out[$key] = $values; + } + return $out; +} + +function set_meta_payload(int $id, array $meta): void { + foreach ($meta as $key => $values) { + delete_post_meta($id, $key); + foreach ($values as $value) { + add_post_meta($id, $key, maybe_serialize($value)); + } + } +} + +switch ($cmd) { + case 'read': { + $id = (int) ($argv[2] ?? 0); + $p = get_post($id); + if (!$p) { fwrite(STDERR, "post $id no existe\n"); exit(3); } + out_json([ + 'id' => $p->ID, + 'title' => $p->post_title, + 'content' => $p->post_content, + 'excerpt' => $p->post_excerpt, + 'lang' => function_exists('pll_get_post_language') ? pll_get_post_language($id) : '', + 'status' => $p->post_status, + 'author' => (int) $p->post_author, + 'date' => $p->post_date, + 'cats' => wp_get_post_categories($id), + ]); + break; + } + + case 'read_full': { + $id = (int) ($argv[2] ?? 0); + $p = get_post($id); + if (!$p) { fwrite(STDERR, "post $id no existe\n"); exit(3); } + out_json([ + 'id' => $p->ID, + 'title' => $p->post_title, + 'content' => $p->post_content, + 'excerpt' => $p->post_excerpt, + 'slug' => $p->post_name, + 'lang' => function_exists('pll_get_post_language') ? pll_get_post_language($id) : '', + 'status' => $p->post_status, + 'author' => (int) $p->post_author, + 'date' => $p->post_date, + 'date_gmt' => $p->post_date_gmt, + 'type' => $p->post_type, + 'cats' => wp_get_post_categories($id), + 'cat_slugs' => array_values(array_map(static fn($t) => $t->slug, get_the_terms($id, 'category') ?: [])), + 'meta' => meta_payload($id), + 'translations' => function_exists('pll_get_post_translations') ? pll_get_post_translations($id) : [], + ]); + break; + } + + case 'exists': { + $es = (int) ($argv[2] ?? 0); + $lang = (string) ($argv[3] ?? ''); + $t = (int) pll_get_post($es, $lang); + if ($t && !get_post($t)) $t = 0; // enlace colgado a un post borrado + echo $t; + break; + } + + case 'unlink': { + // Borra la traducción en y la saca del grupo (para --force / limpieza). + $es = (int) ($argv[2] ?? 0); + $lang = (string) ($argv[3] ?? ''); + $t = (int) pll_get_post($es, $lang); + if ($t && get_post($t)) wp_delete_post($t, true); + $tr = function_exists('pll_get_post_translations') ? pll_get_post_translations($es) : ['es' => $es]; + unset($tr[$lang]); + if ($tr) pll_save_post_translations($tr); + echo $t; + break; + } + + case 'create': { + $es = (int) ($argv[2] ?? 0); + $lang = (string) ($argv[3] ?? ''); + $status = (string) ($argv[4] ?? 'draft'); + $src = get_post($es); + if (!$src) { fwrite(STDERR, "post fuente $es no existe\n"); exit(3); } + + $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); + } + + // ¿ya existe (y vivo)? idempotencia dura. + $existing = (int) pll_get_post($es, $lang); + if ($existing && !get_post($existing)) $existing = 0; + if ($existing) { echo $existing; break; } + + $new_id = wp_insert_post([ + 'post_title' => wp_slash($payload['title']), + 'post_content' => wp_slash($payload['content'] ?? ''), + 'post_excerpt' => wp_slash($payload['excerpt'] ?? ''), + 'post_name' => sanitize_title($payload['title']), + 'post_status' => $status, + 'post_type' => 'post', + 'post_author' => (int) $src->post_author, + 'post_date' => $src->post_date, + 'to_ping' => '', + 'pinged' => '', + ], true); + + if (is_wp_error($new_id)) { fwrite(STDERR, $new_id->get_error_message() . "\n"); exit(5); } + + // Idioma primero, para que las categorías traducidas casen con el idioma del post. + pll_set_post_language($new_id, $lang); + + // Categorías: mapea cada categoría ES a su traducción en el idioma destino + // (las categorías de carta ya están traducidas: cartasemana 6→en 3077, fr 3083…). + $cats = wp_get_post_categories($es); + $mapped = []; + foreach ($cats as $c) { + $tc = function_exists('pll_get_term') ? (int) pll_get_term($c, $lang) : 0; + $mapped[] = $tc ?: $c; // traducida si existe; si no, la ES (fallback) + } + if ($mapped) wp_set_post_categories($new_id, array_values(array_unique($mapped))); + + // Enlace de traducción (preservando el grupo existente). + $tr = function_exists('pll_get_post_translations') ? pll_get_post_translations($es) : ['es' => $es]; + if (!$tr) $tr = ['es' => $es]; + $tr[$lang] = $new_id; + pll_save_post_translations($tr); + + // Metas de trazabilidad. + update_post_meta($new_id, 'traduccion_automatica', '1'); + update_post_meta($new_id, 'traduccion_origen', $es); + update_post_meta($new_id, 'traduccion_modelo', $payload['model'] ?? ''); + update_post_meta($new_id, 'traduccion_fecha', gmdate('c')); + + echo $new_id; + break; + } + + case 'clone': { + $target = (int) ($argv[2] ?? 0); + $lang = (string) ($argv[3] ?? ''); + $status = (string) ($argv[4] ?? 'draft'); + if ($target <= 0 || $lang === '') { + fwrite(STDERR, "uso: clone \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' => '', + ]; + + $existing = get_post($target); + if ($existing) { + $postarr['ID'] = $target; + $new_id = wp_update_post($postarr, true); + } else { + $postarr['import_id'] = $target; + $new_id = wp_insert_post($postarr, true); + } + + if (is_wp_error($new_id)) { fwrite(STDERR, $new_id->get_error_message() . "\n"); exit(5); } + if ((int) $new_id !== $target) { + fwrite(STDERR, "ID preservado falló: esperado $target, creado $new_id\n"); exit(7); + } + + 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': { + // Descubre el cluster de una carta parseando sus propios enlaces internos + // (misma lógica que pinta la portada, fea-carta-portada.php). No depende + // de _carta_id -> sirve aunque ese meta todavía no se haya puesto. + $id = (int) ($argv[2] ?? 0); + if (!$id || !function_exists('fea_parse_carta_sections')) { + fwrite(STDERR, "fea_parse_carta_sections no disponible o id inválido\n"); exit(9); + } + out_json(fea_parse_carta_sections($id)); + break; + } + + case 'save_translations': { + $payload = json_decode(file_get_contents('php://stdin'), true); + if (!is_array($payload) || empty($payload['translations']) || !is_array($payload['translations'])) { + fwrite(STDERR, "payload inválido por stdin\n"); exit(4); + } + $tr = []; + foreach ($payload['translations'] as $lang => $id) { + $id = (int) $id; + if (!is_string($lang) || $lang === '' || $id <= 0 || !get_post($id)) { + continue; + } + $tr[$lang] = $id; + } + if (count($tr) < 2) { + fwrite(STDERR, "grupo insuficiente\n"); exit(8); + } + pll_save_post_translations($tr); + out_json($tr); + break; + } + + default: + fwrite(STDERR, "subcomando desconocido: '$cmd'\n"); + exit(1); +} diff --git a/scripts/sync_carta_from_prod.py b/scripts/sync_carta_from_prod.py new file mode 100644 index 0000000..6b814e7 --- /dev/null +++ b/scripts/sync_carta_from_prod.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +""" +sync_carta_from_prod.py — Copia una carta semanal (y su cluster de artículos) +de PROD a LOCAL preservando IDs, para poder traducir/locutar sobre un entorno +local alineado con prod. + +Por qué existe: desde que Inma/Mixbot componen y publican la carta ES +directamente en WordPress prod (issue #165, #174), el WP local de Rafa deja +de recibir ese contenido — solo lo tenía porque antes todo pasaba por el +delta Joomla->local->prod. Sin este script, local queda desalineado (visto +con la carta 735 / ID 54495: ese ID en local correspondía a OTRO post) y +traducir/locutar ahí es peligroso (contenido cruzado). Ver +gitea.feadulta.com/rafa/feadulta#174. + +Descubre el cluster parseando los enlaces internos de la propia carta +(fea_parse_carta_sections, la misma función que pinta la portada) en vez de +depender de _carta_id, que puede no estar puesto todavía. + +Es el espejo de sync_translations_to_prod.py (modo IDs preservados): reutiliza +el mismo fea_translate_helper.php sin tocar su lógica, solo invertido +(lee de prod por SSH, escribe en local por docker). + +Uso: + python3 sync_carta_from_prod.py --carta 54495 --dry-run + python3 sync_carta_from_prod.py --carta 54495 + +Tras esto, el resto del ciclo ya existente no cambia: + translate_post.py --carta 54495 --langs en,fr,it,pt --status draft + publish_carta.php + rotate_cartas.php CARTA=54495 APPLY=1 (en PROD, por idioma ya publicado) + tts_produce.py + sync_audio_to_prod.py +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import time +from pathlib import Path + +WP_CONTAINER = os.environ.get("FEA_WP_CONTAINER", "wordpress-web") + +PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "") +PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "") +PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php") +PROD_HELPER = "/tmp/fea_translate_helper.php" + +HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php" +LOCAL_HELPER_DST = "/tmp/fea_translate_helper.php" + +# Por defecto NO se fuerza status: cada post se clona con el status que tiene +# en prod (publish/draft). Ver fea_translate_helper.php:clone (status vacío -> +# usa payload['status']). +STATUS = os.environ.get("FEA_SYNC_DOWN_STATUS", "") +LOG_FILE = Path(os.environ.get("FEA_SYNC_DOWN_LOG", "/tmp/feadulta-sync-down.log")) + + +def log(msg: str) -> None: + line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}" + print(line, flush=True) + try: + LOG_FILE.open("a", encoding="utf-8").write(line + "\n") + except OSError: + pass + + +def sh(cmd: list[str], *, stdin: str | None = None, timeout: int = 120) -> str: + r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout) + if r.returncode != 0: + raise RuntimeError(f"cmd falló ({r.returncode}): {' '.join(cmd[:3])}…\n{r.stderr.strip()[:400]}") + return r.stdout + + +# ── Prod (origen, solo lectura) ───────────────────────────────────────────── +_prod_ready = False + + +def _ssh(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str: + cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new", + "-o", "ConnectTimeout=20", PROD_HOST, remote_cmd] + return sh(cmd, stdin=stdin, timeout=timeout) + + +def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str: + global _prod_ready + if not _prod_ready: + _ssh(f"cat > {PROD_HELPER}", stdin=HELPER_SRC.read_text(encoding="utf-8")) + _prod_ready = True + inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args) + return _ssh(inner, stdin=stdin, timeout=180) + + +def prod_read_full(post_id: int) -> dict: + return json.loads(prod_helper("read_full", str(post_id))) + + +def prod_carta_sections(carta_id: int) -> dict: + raw = prod_helper("carta_sections", str(carta_id)).strip() + return json.loads(raw) if raw else {} + + +def seed_ids_from_carta(carta_id: int) -> list[int]: + sections = prod_carta_sections(carta_id) + ids = {carta_id} + for arr in sections.values(): + for pid in arr: + ids.add(int(pid)) + return sorted(ids) + + +def collect_related_posts(seed_ids: list[int]) -> tuple[dict[int, dict], list[dict[str, int]]]: + posts: dict[int, dict] = {} + groups: dict[tuple[tuple[str, int], ...], dict[str, int]] = {} + + for seed in seed_ids: + info = prod_read_full(seed) + posts[seed] = info + raw_group = info.get("translations") or {} + group = {lang: int(pid) for lang, pid in raw_group.items() if str(pid).isdigit()} + if not group: + lang = info.get("lang") or "es" + group = {lang: seed} + sig = tuple(sorted(group.items())) + groups[sig] = group + + all_ids = sorted({pid for group in groups.values() for pid in group.values()}) + for pid in all_ids: + if pid not in posts: + posts[pid] = prod_read_full(pid) + + return posts, list(groups.values()) + + +# ── Local (destino) ────────────────────────────────────────────────────────── +_local_ready = False + + +def local_helper(subcmd: str, *args: str, stdin: str | None = None) -> str: + global _local_ready + if not _local_ready: + sh(["docker", "cp", str(HELPER_SRC), f"{WP_CONTAINER}:{LOCAL_HELPER_DST}"]) + _local_ready = True + return sh(["docker", "exec", "-i", WP_CONTAINER, "php", LOCAL_HELPER_DST, subcmd, *args], + stdin=stdin, timeout=180) + + +def local_read_safe(post_id: int) -> dict | None: + try: + return json.loads(local_helper("read", str(post_id))) + except RuntimeError: + return None + + +def local_clone(post: dict) -> int: + payload = { + "title": post["title"], + "content": post.get("content", ""), + "excerpt": post.get("excerpt", ""), + "slug": post.get("slug", ""), + "type": post.get("type", "post"), + "author": post.get("author", 1), + "date": post.get("date"), + "date_gmt": post.get("date_gmt"), + "status": post.get("status"), + "cats": post.get("cats", []), + "cat_slugs": post.get("cat_slugs", []), + "meta": post.get("meta", {}), + } + out = local_helper("clone", str(post["id"]), post.get("lang") or "es", STATUS, + stdin=json.dumps(payload)).strip() + return int(out) + + +def local_save_group(group: dict[str, int]) -> dict[str, int]: + out = local_helper("save_translations", stdin=json.dumps({"translations": group})).strip() + return json.loads(out) + + +# ── Main ───────────────────────────────────────────────────────────────────── +def sync_carta(carta_id: int, *, dry_run: bool) -> int: + seed_ids = seed_ids_from_carta(carta_id) + 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) + + conflicts = [] + for pid, p in posts.items(): + existing = local_read_safe(pid) + if existing and existing.get("title") != p.get("title"): + conflicts.append((pid, existing.get("title", ""), p.get("title", ""))) + + if conflicts: + 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: + log(f" #{pid}: local actual «{old_title[:50]}» -> prod «{new_title[:50]}»") + + if dry_run: + for pid, p in posts.items(): + log(f" PULL prod#{pid} [{p.get('lang','?')}] status={p.get('status')} " + f"slug={p.get('slug','')} «{p.get('title','')[:50]}»") + for group in groups: + log(f" GROUP {group}") + log("DRY-RUN: nada escrito en local.") + return 0 + + for pid, p in posts.items(): + new_id = local_clone(p) + if new_id != pid: + log(f" ⚠️ prod#{pid} se clonó como local#{new_id} — ID NO preservado, revisar a mano.") + else: + log(f" clone prod#{pid} -> local#{new_id} [{p.get('lang','?')}] «{p['title'][:45]}»") + + for group in groups: + if len(group) < 2: + continue + saved = local_save_group(group) + log(f" group enlazado en local {saved}") + + log(f"FIN sync prod->local. carta={carta_id} posts={len(posts)} conflictos_previos={len(conflicts)}") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Copia una carta (y su cluster de artículos) de PROD a LOCAL preservando IDs.") + ap.add_argument("--carta", type=int, required=True, help="ID del post ES de la carta en PROD.") + ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no escribe en local.") + args = ap.parse_args() + + if not PROD_HOST or not PROD_PASS: + raise SystemExit( + "Faltan FEA_PROD_SSH_HOST / FEA_PROD_SSH_PASS en el entorno.\n" + "Antes de ejecutar: source ~/.hermes/profiles/feadulta/.env" + ) + + return sync_carta(args.carta, dry_run=args.dry_run) + + +if __name__ == "__main__": + raise SystemExit(main())