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
103 changes: 73 additions & 30 deletions wordpress/themes/cdcf-headless/includes/admin/submission-lifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,22 @@ function cdcf_is_public_submission(int $post_id): bool {

/**
* Create + Polylang-link the missing it/es/fr/pt/de sibling drafts of an
* EN source post, then enqueue an AI translation job per newly-created
* source post, then enqueue an AI translation job per newly-created
* sibling. The existing worker (cdcf_process_translation) will auto-
* publish each translation once its source post is `publish`.
*
* Source language is derived from the post itself via
* pll_get_post_language() rather than assumed to be English. A Spanish
* (or Italian, French, etc.) public submission produces translations
* for the OTHER 5 languages — including English. Observed on production
* 2026-06-16: community_project 1534 ("Enciclopedia Católica") was
* submitted in Spanish; the old hardcoded target list ['it','es','fr',
* 'pt','de'] created siblings for IT/FR/PT/DE only (silently dropping
* EN since it wasn't in the list, and silently skipping ES since the
* post already carried that language) and the atomic save was handed
* {en: 1534, ...} — the source post mis-keyed as the EN translation,
* which Polylang rejected → empty group on all six posts.
*
* Group save is **atomic** — exactly one pll_save_post_translations()
* call after every sibling has been created. The earlier shape (one
* save per iteration, accumulating the map) lost-updated the Polylang
Expand All @@ -67,49 +79,80 @@ function cdcf_is_public_submission(int $post_id): bool {
* If the atomic save fails, every just-created draft is force-deleted
* so a failed call leaves no orphans behind (same shape as /translate-all).
*
* @param int $en_post_id The English (source) post ID. MUST be the source,
* not a translation — caller should resolve via
* cdcf_get_source_post_id() first.
* @param string $post_type The CPT slug (project | community_project | local_group).
* @param int $source_post_id The source post ID, in any of the 6 configured
* Polylang languages. MUST be the source, not a
* translation — caller should resolve via
* cdcf_get_source_post_id() first.
* @param string $post_type The CPT slug (project | community_project | local_group).
*/
function cdcf_enqueue_translations_for_submission(int $en_post_id, string $post_type): void {
function cdcf_enqueue_translations_for_submission(int $source_post_id, string $post_type): void {
if (
!function_exists('pll_set_post_language')
|| !function_exists('pll_save_post_translations')
|| !function_exists('pll_get_post_translations')
|| !function_exists('pll_get_post_language')
|| !function_exists('pll_languages_list')
) {
error_log("cdcf_enqueue_translations_for_submission: Polylang not active; skipping post {$en_post_id}.");
error_log("cdcf_enqueue_translations_for_submission: Polylang not active; skipping post {$source_post_id}.");
return;
}

$source_post = get_post($source_post_id);
if (!$source_post) {
error_log("cdcf_enqueue_translations_for_submission: Source post {$source_post_id} not found.");
return;
}

$en_post = get_post($en_post_id);
if (!$en_post) {
error_log("cdcf_enqueue_translations_for_submission: Source post {$en_post_id} not found.");
// Source language is whatever Polylang has assigned to this post —
// ES for a Spanish submission, IT for an Italian submission, etc.
// Fall back to the Polylang site default if the post has no
// language (e.g. submission flow forgot to call pll_set_post_language).
$source_lang = pll_get_post_language($source_post_id, 'slug') ?: '';
if (!$source_lang && function_exists('pll_default_language')) {
$source_lang = pll_default_language('slug') ?: '';
}
if (!$source_lang) {
error_log("cdcf_enqueue_translations_for_submission: Source post {$source_post_id} has no Polylang language and no default; aborting.");
return;
}

$target_langs = ['it', 'es', 'fr', 'pt', 'de'];
// Target = every configured language EXCEPT the source's own.
$all_langs = pll_languages_list(['fields' => 'slug']);
if (!is_array($all_langs) || empty($all_langs)) {
error_log("cdcf_enqueue_translations_for_submission: pll_languages_list returned no languages; aborting.");
return;
}
$target_langs = array_values(array_filter(
$all_langs,
static fn($l) => $l !== $source_lang
));
if (empty($target_langs)) {
error_log("cdcf_enqueue_translations_for_submission: Only the source language is configured; nothing to translate.");
return;
}

// Pre-seed the map from any existing group (handles partial re-runs
// where some langs are already linked from a previous attempt).
$translations = pll_get_post_translations($en_post_id);
$translations['en'] = $en_post_id;
$translations = pll_get_post_translations($source_post_id);
$translations[$source_lang] = $source_post_id;

error_log(sprintf(
'cdcf_enqueue_translations_for_submission: ENTER post_id=%d post_type=%s pre_seed_group=%s',
$en_post_id,
'cdcf_enqueue_translations_for_submission: ENTER post_id=%d post_type=%s source_lang=%s targets=[%s] pre_seed_group=%s',
$source_post_id,
$post_type,
$source_lang,
implode(',', $target_langs),
cdcf_format_lang_map($translations)
));

// Phase 0: ensure attachment translations exist for the source's
// featured_image. Without this, post-translation workers would
// either render the EN image (with EN alt-text / SEO regression) or
// get a null featuredImage from WPGraphQL on non-EN posts. Running
// before Phase 1 means by the time the worker handles each post
// translation, pll_get_post(thumbnail, lang) already returns the
// matching-language attachment sibling.
$source_thumbnail_id = (int) get_post_thumbnail_id($en_post_id);
// either render the source-lang image (with source-lang alt-text /
// SEO regression) or get a null featuredImage from WPGraphQL on
// other-language posts. Running before Phase 1 means by the time
// the worker handles each post translation, pll_get_post(thumbnail,
// lang) already returns the matching-language attachment sibling.
$source_thumbnail_id = (int) get_post_thumbnail_id($source_post_id);
if ($source_thumbnail_id > 0) {
cdcf_ensure_attachment_translations($source_thumbnail_id, $target_langs);
}
Expand All @@ -128,11 +171,11 @@ function cdcf_enqueue_translations_for_submission(int $en_post_id, string $post_
$trans_id = wp_insert_post([
'post_type' => $post_type,
'post_status' => 'draft',
'post_title' => $en_post->post_title,
'post_title' => $source_post->post_title,
]);

if (is_wp_error($trans_id) || !$trans_id) {
error_log("cdcf_enqueue_translations_for_submission: Failed to create {$lang} sibling for post {$en_post_id}.");
error_log("cdcf_enqueue_translations_for_submission: Failed to create {$lang} sibling for post {$source_post_id}.");
continue;
}

Expand All @@ -143,7 +186,7 @@ function cdcf_enqueue_translations_for_submission(int $en_post_id, string $post_

error_log(sprintf(
'cdcf_enqueue_translations_for_submission: PHASE_1_DONE post_id=%d newly_created=%s already_linked=%s',
$en_post_id,
$source_post_id,
cdcf_format_lang_map($newly_created),
cdcf_format_lang_map($already_linked)
));
Expand All @@ -152,7 +195,7 @@ function cdcf_enqueue_translations_for_submission(int $en_post_id, string $post_
// Nothing new to link or enqueue.
error_log(sprintf(
'cdcf_enqueue_translations_for_submission: NO_OP post_id=%d (all target langs already pre-seeded); exiting without group save.',
$en_post_id
$source_post_id
));
return;
}
Expand All @@ -162,7 +205,7 @@ function cdcf_enqueue_translations_for_submission(int $en_post_id, string $post_
if ($save_result === false) {
error_log(sprintf(
'cdcf_enqueue_translations_for_submission: PHASE_2_FAIL post_id=%d pll_save_post_translations returned false; rolling back %d draft(s): %s',
$en_post_id,
$source_post_id,
count($newly_created),
cdcf_format_lang_map($newly_created)
));
Expand All @@ -174,7 +217,7 @@ function cdcf_enqueue_translations_for_submission(int $en_post_id, string $post_

error_log(sprintf(
'cdcf_enqueue_translations_for_submission: PHASE_2_OK post_id=%d atomic group save succeeded; final_group=%s',
$en_post_id,
$source_post_id,
cdcf_format_lang_map($translations)
));

Expand All @@ -183,16 +226,16 @@ function cdcf_enqueue_translations_for_submission(int $en_post_id, string $post_
$queue_name = function_exists('cdcf_enqueue_translation') ? 'redis' : 'wp-cron';
foreach ($newly_created as $lang => $trans_id) {
if (function_exists('cdcf_enqueue_translation')) {
cdcf_enqueue_translation($trans_id, $en_post_id, $lang);
cdcf_enqueue_translation($trans_id, $source_post_id, $lang);
} else {
wp_schedule_single_event(time(), 'cdcf_async_translate', [$trans_id, $en_post_id, $lang]);
wp_schedule_single_event(time(), 'cdcf_async_translate', [$trans_id, $source_post_id, $lang]);
spawn_cron();
}
}

error_log(sprintf(
'cdcf_enqueue_translations_for_submission: PHASE_3_DONE post_id=%d queued %d job(s) via %s: %s',
$en_post_id,
$source_post_id,
count($newly_created),
$queue_name,
cdcf_format_lang_map($newly_created)
Expand Down
70 changes: 70 additions & 0 deletions wordpress/themes/cdcf-headless/tests/SubmissionLifecycleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ private function stubCommonFunctions(): void
// tests that don't care about featured-image translation get a
// default of 0 (no thumbnail = Phase 0 is a no-op).
Functions\when('get_post_thumbnail_id')->justReturn(0);
// Source-language detection (PR following #227): the function
// resolves source_lang via pll_get_post_language and the target
// set via pll_languages_list rather than hardcoding 'en'. Default
// both to the EN-source shape so existing EN-source tests stay
// green; tests for non-EN sources override these.
Functions\when('pll_get_post_language')->justReturn('en');
Functions\when('pll_languages_list')->justReturn(['en', 'it', 'es', 'fr', 'pt', 'de']);
}

private function allowAllFunctionsToExist(): void
Expand Down Expand Up @@ -286,6 +293,8 @@ public function test_enqueue_translations_falls_back_to_wp_cron_when_redis_helpe
Functions\when('pll_set_post_language')->justReturn(true);
Functions\when('pll_save_post_translations')->justReturn(true);
Functions\when('pll_get_post_translations')->justReturn([]);
Functions\when('pll_get_post_language')->justReturn('en');
Functions\when('pll_languages_list')->justReturn(['en', 'it', 'es', 'fr', 'pt', 'de']);
Functions\when('get_post')->justReturn($this->fakePost(['ID' => 42]));
Functions\when('wp_insert_post')->justReturn(200);
// Phase 0 (PR #208): no thumbnail on the source → skip attachment
Expand Down Expand Up @@ -369,6 +378,67 @@ function (array $map) use (&$saves): bool {
);
}

public function test_enqueue_translations_uses_dynamic_source_language_for_non_english_source(): void
{
// Regression guard for the hardcoded-EN bug observed on production
// 2026-06-16: community_project 1534 was submitted in Spanish; the
// publish hook created siblings for IT/FR/PT/DE only (NOT EN — the
// user's expected target) and the Polylang group came out empty
// because pll_save_post_translations was handed {en: 1534, ...} —
// the source post was mis-keyed as the EN translation, so Polylang
// rejected the malformed map.
//
// The function must derive source_lang from pll_get_post_language
// and target_langs from pll_languages_list, treating any of the 6
// languages as a possible submission source.
$this->stubCommonFunctions();
Functions\when('pll_get_post_language')->justReturn('es');
Functions\when('get_post')->justReturn($this->fakePost([
'ID' => 42,
'post_title' => 'Enciclopedia Católica',
'post_type' => 'community_project',
]));

$counter = 199;
Functions\when('wp_insert_post')->alias(
function () use (&$counter): int {
$counter++;
return $counter;
}
);

$saves = [];
Functions\when('pll_save_post_translations')->alias(
function (array $map) use (&$saves): bool {
$saves[] = $map;
return true;
}
);

$enqueued = [];
Functions\when('cdcf_enqueue_translation')->alias(
function (int $post_id, int $source_id, string $lang) use (&$enqueued): string {
$enqueued[$lang] = $post_id;
return 'redis';
}
);
$this->allowAllFunctionsToExist();

cdcf_enqueue_translations_for_submission(42, 'community_project');

$this->assertCount(1, $saves, 'pll_save_post_translations must be called exactly once');
$this->assertSame(
['es' => 42, 'en' => 200, 'it' => 201, 'fr' => 202, 'pt' => 203, 'de' => 204],
$saves[0],
'the source post must be keyed under its actual language (es), and the 5 freshly-created siblings must cover the other 5 languages — INCLUDING en, which the old hardcoded ["it","es","fr","pt","de"] target list silently dropped.'
);
$this->assertSame(
['en' => 200, 'it' => 201, 'fr' => 202, 'pt' => 203, 'de' => 204],
$enqueued,
'every non-source language must be enqueued for translation, and the source language (es) must NOT be re-enqueued against itself.'
);
}

public function test_enqueue_translations_skips_save_and_enqueue_when_all_langs_already_linked(): void
{
// Pre-seed already covers every target lang — no new drafts to
Expand Down