From 86257843e893ae36acf810b6c526cdea5eea4e84 Mon Sep 17 00:00:00 2001 From: rafa Date: Mon, 31 Aug 2026 07:45:19 -0400 Subject: [PATCH 1/2] feat: add REST endpoint to create Polylang translations --- mu-plugins/fea-crear-traduccion-api.php | 169 ++++++++++++++++++ .../integration/test_crear_traduccion_api.sh | 66 +++++++ 2 files changed, 235 insertions(+) create mode 100644 mu-plugins/fea-crear-traduccion-api.php create mode 100755 tests/integration/test_crear_traduccion_api.sh diff --git a/mu-plugins/fea-crear-traduccion-api.php b/mu-plugins/fea-crear-traduccion-api.php new file mode 100644 index 0000000..597dd1a --- /dev/null +++ b/mu-plugins/fea-crear-traduccion-api.php @@ -0,0 +1,169 @@ +, lang=, title/content/excerpt/status/model + * + * Ver issue gitea.feadulta.com/rafa/feadulta#222. + */ + +if (!defined('ABSPATH')) exit; + +const FEA_TRANSLATION_LANGUAGES = ['en', 'fr', 'it', 'pt']; + +add_action('rest_api_init', function () { + register_rest_route('fea/v1', '/crear-traduccion', [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => 'fea_crear_traduccion_handle', + 'permission_callback' => 'fea_crear_traduccion_can_call', + // Se valida después de autorizar para que las llamadas anónimas + // obtengan 401 aunque omitan parámetros. + 'args' => [ + 'es_id' => [ + 'sanitize_callback' => 'absint', + ], + 'lang' => [ + 'sanitize_callback' => 'sanitize_key', + ], + 'status' => [ + 'sanitize_callback' => 'sanitize_key', + ], + 'model' => [ + 'sanitize_callback' => 'sanitize_text_field', + ], + ], + ]); +}); + +/** Editor o superior: mismo nivel que los demás endpoints fea/v1 de escritura. */ +function fea_crear_traduccion_can_call(WP_REST_Request $request) { + if (!is_user_logged_in()) { + return new WP_Error( + 'fea_crear_traduccion_not_authenticated', + 'Debes autenticarte para crear una traducción.', + ['status' => 401] + ); + } + if (!current_user_can('edit_others_posts')) { + return new WP_Error( + 'fea_crear_traduccion_forbidden', + 'No tienes permiso para crear traducciones.', + ['status' => 403] + ); + } + return true; +} + +function fea_crear_traduccion_handle(WP_REST_Request $request) { + $es_id = absint($request->get_param('es_id')); + if (!$es_id) { + return new WP_Error( + 'fea_crear_traduccion_invalid_source', + 'es_id es obligatorio.', + ['status' => 400] + ); + } + + $lang = (string) $request->get_param('lang'); + if (!in_array($lang, FEA_TRANSLATION_LANGUAGES, true)) { + return new WP_Error( + 'fea_crear_traduccion_invalid_language', + 'lang debe ser en, fr, it o pt.', + ['status' => 400] + ); + } + + $status = (string) ($request->get_param('status') ?: 'draft'); + if (!in_array($status, ['draft', 'publish'], true)) { + return new WP_Error( + 'fea_crear_traduccion_invalid_status', + 'status debe ser draft o publish.', + ['status' => 400] + ); + } + + $source = get_post($es_id); + if (!$source) { + return new WP_Error( + 'fea_crear_traduccion_source_not_found', + 'No existe el post español indicado.', + ['status' => 404] + ); + } + if (!function_exists('pll_get_post') || !function_exists('pll_set_post_language') || !function_exists('pll_save_post_translations')) { + return new WP_Error( + 'fea_crear_traduccion_polylang_unavailable', + 'Polylang no está disponible.', + ['status' => 500] + ); + } + + // Idempotencia dura: se responde antes de interpretar un payload nuevo. + $existing_id = (int) pll_get_post($es_id, $lang); + if ($existing_id && !get_post($existing_id)) $existing_id = 0; + if ($existing_id) { + return new WP_REST_Response([ + 'es_id' => $es_id, + 'lang' => $lang, + 'translation_id' => $existing_id, + 'created' => false, + 'url' => get_permalink($existing_id), + ], 200); + } + + $title = (string) ($request->get_param('title') ?? ''); + $new_id = wp_insert_post([ + 'post_title' => wp_slash($title), + 'post_content' => wp_slash((string) ($request->get_param('content') ?? '')), + 'post_excerpt' => wp_slash((string) ($request->get_param('excerpt') ?? '')), + 'post_name' => sanitize_title($title), + 'post_status' => $status, + 'post_type' => 'post', + 'post_author' => (int) $source->post_author, + 'post_date' => $source->post_date, + 'to_ping' => '', + 'pinged' => '', + ], true); + if (is_wp_error($new_id)) { + return new WP_Error( + 'fea_crear_traduccion_insert_failed', + 'No se pudo crear la traducción: ' . $new_id->get_error_message(), + ['status' => 500] + ); + } + + // El idioma se asigna antes de mapear categorías para que Polylang admita + // los términos traducidos en el nuevo post. + pll_set_post_language($new_id, $lang); + + $categories = wp_get_post_categories($es_id); + $mapped_categories = []; + foreach ($categories as $category_id) { + $translated_category = function_exists('pll_get_term') ? (int) pll_get_term($category_id, $lang) : 0; + $mapped_categories[] = $translated_category ?: $category_id; + } + if ($mapped_categories) { + wp_set_post_categories($new_id, array_values(array_unique($mapped_categories))); + } + + $translations = function_exists('pll_get_post_translations') ? pll_get_post_translations($es_id) : ['es' => $es_id]; + if (!$translations) $translations = ['es' => $es_id]; + $translations[$lang] = $new_id; + pll_save_post_translations($translations); + + update_post_meta($new_id, 'traduccion_automatica', '1'); + update_post_meta($new_id, 'traduccion_origen', $es_id); + update_post_meta($new_id, 'traduccion_modelo', (string) ($request->get_param('model') ?? '')); + update_post_meta($new_id, 'traduccion_fecha', gmdate('c')); + + return new WP_REST_Response([ + 'es_id' => $es_id, + 'lang' => $lang, + 'translation_id' => (int) $new_id, + 'created' => true, + 'url' => get_permalink($new_id), + ], 201); +} diff --git a/tests/integration/test_crear_traduccion_api.sh b/tests/integration/test_crear_traduccion_api.sh new file mode 100755 index 0000000..5bd4767 --- /dev/null +++ b/tests/integration/test_crear_traduccion_api.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Integración local para POST /wp-json/fea/v1/crear-traduccion (#222). +# Requiere un post ES temporal SIN traducción inglesa; el caller lo elimina +# junto con la traducción creada al terminar. +set -euo pipefail + +: "${FEA_TEST_URL:?Falta FEA_TEST_URL}" +: "${FEA_TEST_AUTH:?Falta FEA_TEST_AUTH}" +: "${FEA_TEST_ES_ID:?Falta FEA_TEST_ES_ID}" + +endpoint="${FEA_TEST_URL%/}/wp-json/fea/v1/crear-traduccion" +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +assert_status() { + local expected="$1" actual="$2" label="$3" response_file="$4" + if [[ "$actual" != "$expected" ]]; then + echo "FAIL: $label — esperado HTTP $expected, recibido $actual" >&2 + cat "$response_file" >&2 || true + exit 1 + fi +} + +status="$(curl -sS -o "$tmpdir/unauth.json" -w '%{http_code}' -X POST "$endpoint")" +assert_status 401 "$status" 'llamada sin autenticar' "$tmpdir/unauth.json" + +status="$(curl -sS -o "$tmpdir/no-source.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F 'lang=en' "$endpoint")" +assert_status 400 "$status" 'falta es_id' "$tmpdir/no-source.json" + +status="$(curl -sS -o "$tmpdir/no-lang.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "es_id=$FEA_TEST_ES_ID" "$endpoint")" +assert_status 400 "$status" 'falta lang' "$tmpdir/no-lang.json" + +status="$(curl -sS -o "$tmpdir/bad-lang.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "es_id=$FEA_TEST_ES_ID" -F 'lang=de' "$endpoint")" +assert_status 400 "$status" 'lang no admitido' "$tmpdir/bad-lang.json" + +status="$(curl -sS -o "$tmpdir/bad-status.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "es_id=$FEA_TEST_ES_ID" -F 'lang=en' -F 'status=private' "$endpoint")" +assert_status 400 "$status" 'status no admitido' "$tmpdir/bad-status.json" + +status="$(curl -sS -o "$tmpdir/no-such-source.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F 'es_id=999999999' -F 'lang=en' "$endpoint")" +assert_status 404 "$status" 'post ES inexistente' "$tmpdir/no-such-source.json" + +status="$(curl -sS -o "$tmpdir/created.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "es_id=$FEA_TEST_ES_ID" -F 'lang=en' -F 'title=Translation API test' -F 'content=

Body API test

' -F 'excerpt=Excerpt API test' -F 'status=draft' -F 'model=test-model' "$endpoint")" +assert_status 201 "$status" 'creación de traducción' "$tmpdir/created.json" + +python3 - "$tmpdir/created.json" "$FEA_TEST_ES_ID" <<'PY' +import json, sys +payload = json.load(open(sys.argv[1])) +assert int(payload['es_id']) == int(sys.argv[2]), payload +assert payload['lang'] == 'en', payload +assert int(payload['translation_id']) > 0, payload +assert payload['created'] is True, payload +assert payload['url'].startswith(('http://', 'https://')), payload +print('PASS: traducción creada', payload['translation_id']) +PY + +status="$(curl -sS -o "$tmpdir/idempotent.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "es_id=$FEA_TEST_ES_ID" -F 'lang=en' -F 'title=No debe crear otro post' "$endpoint")" +assert_status 200 "$status" 'idempotencia' "$tmpdir/idempotent.json" + +python3 - "$tmpdir/created.json" "$tmpdir/idempotent.json" <<'PY' +import json, sys +created = json.load(open(sys.argv[1])) +again = json.load(open(sys.argv[2])) +assert again['translation_id'] == created['translation_id'], (created, again) +assert again['created'] is False, again +print('PASS: idempotencia', again['translation_id']) +PY -- 2.52.0 From 3fb5a3b3f962e74a7720de1895de8d2bc98793e6 Mon Sep 17 00:00:00 2001 From: rafa Date: Mon, 31 Aug 2026 07:47:54 -0400 Subject: [PATCH 2/2] =?UTF-8?q?test:=20usar=20--form-string=20para=20el=20?= =?UTF-8?q?campo=20content=20en=20la=20integraci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit curl -F trata un valor que empieza por "<" como "leer desde este fichero", no como el literal HTML que se pretendía enviar (falla con curl: (26) Failed to open/read local data from file). --form-string evita esa lectura especial. Verificado por Claudix (QA) contra wordpress-web local: con el fix, los 8 casos pasan y Polylang enlaza idioma/grupo/categoría correctamente (56426 es -> 56427 en, grupo {es:56426,en:56427}, categoria 6->3077). --- tests/integration/test_crear_traduccion_api.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) mode change 100755 => 100644 tests/integration/test_crear_traduccion_api.sh diff --git a/tests/integration/test_crear_traduccion_api.sh b/tests/integration/test_crear_traduccion_api.sh old mode 100755 new mode 100644 index 5bd4767..41fa201 --- a/tests/integration/test_crear_traduccion_api.sh +++ b/tests/integration/test_crear_traduccion_api.sh @@ -39,7 +39,7 @@ assert_status 400 "$status" 'status no admitido' "$tmpdir/bad-status.json" status="$(curl -sS -o "$tmpdir/no-such-source.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F 'es_id=999999999' -F 'lang=en' "$endpoint")" assert_status 404 "$status" 'post ES inexistente' "$tmpdir/no-such-source.json" -status="$(curl -sS -o "$tmpdir/created.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "es_id=$FEA_TEST_ES_ID" -F 'lang=en' -F 'title=Translation API test' -F 'content=

Body API test

' -F 'excerpt=Excerpt API test' -F 'status=draft' -F 'model=test-model' "$endpoint")" +status="$(curl -sS -o "$tmpdir/created.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "es_id=$FEA_TEST_ES_ID" -F 'lang=en' -F 'title=Translation API test' --form-string 'content=

Body API test

' -F 'excerpt=Excerpt API test' -F 'status=draft' -F 'model=test-model' "$endpoint")" assert_status 201 "$status" 'creación de traducción' "$tmpdir/created.json" python3 - "$tmpdir/created.json" "$FEA_TEST_ES_ID" <<'PY' -- 2.52.0