Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions scripts/cdcf_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,20 @@ def link_translations(self, translations: dict[str, int]) -> dict:
"""
return self._wp_post("cdcf/v1/link-translations", {"translations": translations})

def link_term_translations(self, taxonomy: str, translations: dict[str, int]) -> dict:
"""POST /cdcf/v1/link-term-translations

Term equivalent of link_translations. Atomically sets each term's
Polylang language and links the group together.

taxonomy: taxonomy slug, e.g. "project_tag"
translations: dict mapping language code to term ID, e.g. {"en": 169, "it": 231}
"""
return self._wp_post(
"cdcf/v1/link-term-translations",
{"taxonomy": taxonomy, "translations": translations},
)

# -- Project Status --

def update_project_status(self, post_id: int, status: str) -> dict:
Expand Down Expand Up @@ -580,6 +594,15 @@ def _build_parser() -> argparse.ArgumentParser:
p.add_argument("--translations", required=True,
help='JSON object mapping lang to post ID, e.g. \'{"en":10,"it":12}\'')

# link-term-translations
p = sub.add_parser(
"link-term-translations",
help="Atomically link translation term IDs across languages within a taxonomy",
)
p.add_argument("--taxonomy", required=True, help='e.g. "project_tag"')
p.add_argument("--translations", required=True,
help='JSON object mapping lang to term ID, e.g. \'{"en":169,"it":231}\'')

# update-project-status
p = sub.add_parser("update-project-status", help="Update project approval status")
p.add_argument("--post-id", type=int, required=True)
Expand Down Expand Up @@ -752,6 +775,10 @@ def _run_cli(args: argparse.Namespace, client: CdcfClient) -> dict:
translations = json.loads(args.translations)
return client.link_translations(translations)

if cmd == "link-term-translations":
translations = json.loads(args.translations)
return client.link_term_translations(args.taxonomy, translations)

if cmd == "update-project-status":
return client.update_project_status(args.post_id, args.status)

Expand Down
39 changes: 38 additions & 1 deletion wordpress/themes/cdcf-headless/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -424,13 +424,49 @@
return current_user_can('edit_posts');
},
'args' => [
'translations' => ['required' => true, 'type' => 'object'],
'translations' => [
'required' => true,
'type' => 'object',
'sanitize_callback' => 'cdcf_sanitize_translations_map',
],
],
]);
});

require_once __DIR__ . '/includes/handlers/link-translations.php';

// ─── REST endpoint for atomically linking term translations ──────────
//
// Term equivalent of /link-translations. Polylang's term-side
// language + group helpers are PHP-only; this is the thin wrapper.
//
// POST /wp-json/cdcf/v1/link-term-translations (Application Password auth)
// Body: { "taxonomy": "project_tag", "translations": { "en": 169, "it": 231, ... } }

add_action('rest_api_init', function () {
register_rest_route('cdcf/v1', '/link-term-translations', [
'methods' => 'POST',
'callback' => 'cdcf_rest_link_term_translations',
'permission_callback' => function () {
return current_user_can('edit_posts');
},
'args' => [
'taxonomy' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_key',
],
'translations' => [
'required' => true,
'type' => 'object',
'sanitize_callback' => 'cdcf_sanitize_translations_map',
],
],
]);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

require_once __DIR__ . '/includes/handlers/link-term-translations.php';

// ─── REST endpoint for updating project status across translations ───
//
// Sets the project_status ACF field on a project and all its Polylang
Expand Down Expand Up @@ -673,6 +709,7 @@
// cdcf_is_spam_content) used by every public-submission endpoint below.
// Required here — after CDCF_DISPOSABLE_DOMAINS_FILE is defined — so
// the disposable-domain lookup can read the blocklist file path.
require_once __DIR__ . '/includes/sanitizers.php';
require_once __DIR__ . '/includes/security.php';

// Footnote/fragment-anchor protection helper, applied at every wp_kses_post
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php
/**
* REST route handler for /cdcf/v1/link-term-translations.
*
* Term equivalent of /link-translations. Given a {lang => term_id} map
* of two or more already-existing terms in a single taxonomy, sets each
* term's Polylang language and links them into a single translation
* group in one atomic pll_save_term_translations() call.
*
* Use case: repairing corrupted Polylang term groups (e.g. after a
* propagation bug scrambled sibling links) or seeding language metadata
* on terms created outside the normal flow. Polylang's term-side
* language and translation-group helpers are PHP-only — there's no
* native REST surface for them, hence this thin wrapper.
*
* Extracted from functions.php so the body can be unit-tested with
* Brain Monkey + Mockery.
*/

if (defined('ABSPATH') === false) {
return;
}

function cdcf_rest_link_term_translations(WP_REST_Request $request) {
if (
!function_exists('pll_set_term_language')
|| !function_exists('pll_save_term_translations')
) {
return new WP_Error('polylang_missing', 'Polylang is not active.', ['status' => 500]);
}

$taxonomy = $request['taxonomy'];
if (!is_string($taxonomy) || $taxonomy === '' || !taxonomy_exists($taxonomy)) {
return new WP_Error(
'invalid_taxonomy',
"Taxonomy '{$taxonomy}' does not exist.",
['status' => 400]
);
}

$translations = $request['translations'];
if (!is_array($translations) || count($translations) < 2) {
return new WP_Error(
'invalid_translations',
'Provide at least 2 language => term_id pairs.',
['status' => 400]
);
}

// Validate all terms exist in the named taxonomy.
foreach ($translations as $lang => $term_id) {
$term_id = (int) $term_id;
$term = get_term($term_id, $taxonomy);
if (!$term || is_wp_error($term)) {
return new WP_Error(
'invalid_term',
"Term {$term_id} does not exist in taxonomy '{$taxonomy}'.",
['status' => 400]
);
}
$translations[$lang] = $term_id;
}

// Set language on each term, then atomically link the group.
foreach ($translations as $lang => $term_id) {
pll_set_term_language($term_id, $lang);
}
if (pll_save_term_translations($translations) === false) {
return new WP_Error(
'link_failed',
'Polylang refused to save the term translation group.',
['status' => 500]
);
}

return rest_ensure_response([
'success' => true,
'taxonomy' => $taxonomy,
'translations' => $translations,
]);
}
48 changes: 48 additions & 0 deletions wordpress/themes/cdcf-headless/includes/sanitizers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php
/**
* Shared sanitize_callback helpers for REST route arg declarations.
*
* Per the convention documented in CLAUDE.md (settled in #111):
* - Sanitization happens ONCE at register_rest_route() args time.
* - Handlers trust the sanitized input and do NOT re-sanitize.
* - Structural validation + contextual WP_Error returns stay in the
* handler body (e.g. count-checks, existence-checks).
*
* Functions are top-level (callable by name from sanitize_callback).
*/

defined('ABSPATH') || exit;

/**
* Sanitize the {lang => id} translations map used by both
* /cdcf/v1/link-translations (posts) and /cdcf/v1/link-term-translations
* (terms). Coerces to an associative array of language-code keys to
* positive integer IDs; silently drops entries whose keys are not a
* recognized language-code shape (`/^[a-z]{2}(-[A-Z]{2})?$/`, covering
* ISO 639-1 alone plus optional ISO 3166-1 region — matches the
* Polylang slug shape the rest of the site uses), and entries whose
* IDs are zero or non-numeric after absint(). The handler is then
* responsible for the count >= 2 structural check and the per-id
* existence check, both of which return contextual WP_Error.
*
* @param mixed $value Whatever the client sent (usually a
* JSON-decoded associative array; WP REST
* framework leaves objects as arrays).
* @return array<string, int> Empty array on no valid entries.
*/
function cdcf_sanitize_translations_map($value): array {
if (!is_array($value)) {
return [];
}
$sanitized = [];
foreach ($value as $lang => $id) {
if (!is_string($lang) || !preg_match('/^[a-z]{2}(-[A-Z]{2})?$/', $lang)) {
continue;
}
$id = absint($id);
if ($id > 0) {
$sanitized[$lang] = $id;
}
}
return $sanitized;
}
Loading