From 18fde3fa29fa3f0b3bc8fd134440890131ed0d95 Mon Sep 17 00:00:00 2001 From: Mahmoud Ashraf <182176867+SNO7E-G@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:15:19 +0500 Subject: [PATCH 1/5] Map languages by locale instead of two hardcoded slugs lang_slug_to_wpml_format() knew about exactly two languages, pt and zh. Everything else was passed to WPML as the raw Polylang slug, and WPML stores whatever it is given, so any site whose slug differed from the WPML code was migrated into a code WPML cannot resolve, silently. The dependable bridge is the locale: Polylang keeps it in the language term description, WPML keeps it in icl_languages.default_locale with per-site overrides in icl_locale_map. This resolves through WPML's own tables in reverse. Fixes Traditional Chinese (was migrated as Simplified), Norwegian, and any custom slug. Languages WPML still cannot match are recorded via get_unmapped_languages() instead of written blind. --- classes/class-mpw_polylang_data.php | 204 +++++++++++++++++++++++++--- migrate-polylang-to-wpml.php | 29 ++-- 2 files changed, 206 insertions(+), 27 deletions(-) diff --git a/classes/class-mpw_polylang_data.php b/classes/class-mpw_polylang_data.php index b9c8dbe..a32f917 100644 --- a/classes/class-mpw_polylang_data.php +++ b/classes/class-mpw_polylang_data.php @@ -7,11 +7,25 @@ defined('ABSPATH') || exit; class mpw_polylang_data { - + private static $terms; - + + /** + * Resolved Polylang slug => WPML language code, for the life of the request. + * + * @var array + */ + private $wpml_code_cache = array(); + + /** + * Polylang slug => locale, for languages that resolved to a code WPML does not know. + * + * @var array + */ + private $unmapped_languages = array(); + public function __construct() { - + } public function get_languages() { @@ -113,6 +127,26 @@ public function get_languages_map() { return $polylang_languages_map; } + /** + * Translates a Polylang language slug into the WPML language code. + * + * A Polylang slug is whatever the site owner typed when they created the language. A WPML code + * comes from a fixed list of about 250. The dependable bridge between them is the locale: + * Polylang keeps it in the `language` term's serialised description, and WPML keeps it in + * `icl_languages.default_locale`, with per-site overrides in `icl_locale_map`. + * + * WPML resolves a code to a locale in WPML_Locale::get_all_locales() by preferring the override + * and falling back to the default. This runs that same lookup backwards. + * + * Order of resolution: + * 1. the locale from Polylang, matched against WPML's own tables + * 2. the historical pt/zh special cases, if WPML's tables have nothing to say + * 3. the slug unchanged, which is already correct wherever the two systems agree + * + * @param mixed $slug + * + * @return string A WPML language code, or an empty string for unusable input. + */ public function lang_slug_to_wpml_format($slug) { if (!is_scalar($slug)) { @@ -121,27 +155,163 @@ public function lang_slug_to_wpml_format($slug) { $slug = (string) $slug; - $different = array( - 'pt' => 'pt-pt', - 'zh' => 'zh-hans' - ); + if ('' === $slug) { + return ''; + } + + if (!array_key_exists($slug, $this->wpml_code_cache)) { + $this->wpml_code_cache[$slug] = $this->resolve_wpml_code($slug); + } - $languages = $this->get_languages(); + return $this->wpml_code_cache[$slug]; + } - foreach ($languages as $language) { - if (isset($language->slug, $language->description) && $language->slug === 'pt') { - $pt_language_details = maybe_unserialize($language->description); - if (is_array($pt_language_details) && isset($pt_language_details['locale']) && $pt_language_details['locale'] === "pt_BR") { - $different['pt'] = 'pt-br'; - } + /** + * Languages whose slug reached WPML unmapped and which WPML does not recognise. + * + * Writing one of these into icl_translations is not an error WPML will report — it stores the + * code verbatim — so the migration reports them instead. + * + * @return array Polylang slug => locale (empty string when Polylang had no locale either). + */ + public function get_unmapped_languages() { + return $this->unmapped_languages; + } + + /** + * @param string $slug + * + * @return string + */ + private function resolve_wpml_code($slug) { + $locale = $this->get_locale_for_slug($slug); + + if ('' !== $locale) { + $code = $this->wpml_code_for_locale($locale); + + if ('' !== $code) { + return $code; } } - if (isset($different[$slug])) { - $slug = $different[$slug]; + + $legacy_code = $this->legacy_code_for_slug($slug, $locale); + + if ('' !== $legacy_code) { + return $legacy_code; } - + + if (!$this->is_known_wpml_code($slug)) { + $this->unmapped_languages[$slug] = $locale; + } + return $slug; } + + /** + * The mapping this plugin shipped before WPML's tables were consulted. + * + * Kept as a fallback for sites where the WPML tables cannot answer. The Chinese case is + * corrected here: the old code sent every `zh` slug to `zh-hans`, so a Traditional Chinese + * site was migrated as Simplified. + * + * @param string $slug + * @param string $locale + * + * @return string Empty string when this slug has no special case. + */ + private function legacy_code_for_slug($slug, $locale) { + if ('pt' === $slug) { + return 'pt_BR' === $locale ? 'pt-br' : 'pt-pt'; + } + + if ('zh' === $slug) { + return in_array($locale, array('zh_TW', 'zh_HK', 'zh_MO'), true) ? 'zh-hant' : 'zh-hans'; + } + + return ''; + } + + /** + * @param string $slug + * + * @return string The locale Polylang recorded for this language, or an empty string. + */ + private function get_locale_for_slug($slug) { + foreach ($this->get_languages() as $language) { + if (!isset($language->slug) || $language->slug !== $slug) { + continue; + } + + if (!isset($language->description)) { + return ''; + } + + $details = maybe_unserialize($language->description); + + if (is_array($details) && isset($details['locale']) && is_string($details['locale'])) { + return $details['locale']; + } + + return ''; + } + + return ''; + } + + /** + * @param string $locale + * + * @return string The WPML code for this locale, or an empty string. + */ + private function wpml_code_for_locale($locale) { + global $wpdb; + + if (!$this->wpml_tables_available()) { + return ''; + } + + // ORDER BY code so a site that has hand-added a second icl_locale_map row for one locale + // still resolves to a single, deterministic code rather than whatever the engine returns first. + $code = $wpdb->get_var($wpdb->prepare( + "SELECT code FROM {$wpdb->prefix}icl_locale_map WHERE locale = %s ORDER BY code LIMIT 1", + $locale + )); + + if (!$code) { + $code = $wpdb->get_var($wpdb->prepare( + "SELECT code FROM {$wpdb->prefix}icl_languages WHERE default_locale = %s ORDER BY code LIMIT 1", + $locale + )); + } + + return is_string($code) ? $code : ''; + } + + /** + * @param string $code + * + * @return bool + */ + private function is_known_wpml_code($code) { + global $wpdb; + + if (!$this->wpml_tables_available()) { + // Without WPML there is nothing to check against, so don't claim the code is wrong. + return true; + } + + return (bool) $wpdb->get_var($wpdb->prepare( + "SELECT code FROM {$wpdb->prefix}icl_languages WHERE code = %s LIMIT 1", + $code + )); + } + + /** + * @return bool + */ + private function wpml_tables_available() { + return defined('ICL_SITEPRESS_VERSION'); + } public function delete_data() { diff --git a/migrate-polylang-to-wpml.php b/migrate-polylang-to-wpml.php index 27d25ed..ab89077 100644 --- a/migrate-polylang-to-wpml.php +++ b/migrate-polylang-to-wpml.php @@ -435,17 +435,26 @@ private function migrate_languages() { $pll_languages = $this->polylang_data->get_languages(); - if (!empty($pll_languages) && is_array($pll_languages)) { - foreach ($pll_languages as $pll_language) { - if (isset($pll_language->slug)) { - $slug = $this->polylang_data->lang_slug_to_wpml_format($pll_language->slug); - $wpdb->update( - $wpdb->prefix . 'icl_languages', - array('active' => 1), - array('code' => $slug) - ); - } + if (empty($pll_languages) || !is_array($pll_languages)) { + return; + } + + foreach ($pll_languages as $pll_language) { + if (!isset($pll_language->slug)) { + continue; } + + $code = $this->polylang_data->lang_slug_to_wpml_format($pll_language->slug); + + if ('' === $code) { + continue; + } + + $wpdb->update( + $wpdb->prefix . 'icl_languages', + array('active' => 1), + array('code' => $code) + ); } } From 568cb73aa3e26b116e6d1c7105cdc1425b47d1c5 Mon Sep 17 00:00:00 2001 From: David Garcia Watkins Date: Mon, 24 Aug 2026 14:27:55 +0200 Subject: [PATCH 2/5] Test Polylang locale language mapping --- tests/phpunit/TestLanguageMapping.php | 110 ++++++++++++++++++ tests/phpunit/bootstrap.php | 3 + .../includes/class-language-mapping-wpdb.php | 30 +++++ 3 files changed, 143 insertions(+) create mode 100644 tests/phpunit/TestLanguageMapping.php create mode 100644 tests/phpunit/includes/class-language-mapping-wpdb.php diff --git a/tests/phpunit/TestLanguageMapping.php b/tests/phpunit/TestLanguageMapping.php new file mode 100644 index 0000000..eb07f88 --- /dev/null +++ b/tests/phpunit/TestLanguageMapping.php @@ -0,0 +1,110 @@ +wpdb = new LanguageMappingWpdb(); + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- The subject reads the WordPress database global directly. + $GLOBALS['wpdb'] = $this->wpdb; + + WP_Mock::userFunction( 'maybe_unserialize', array( 'return_arg' => 0 ) ); + } + + /** + * @dataProvider localeMappings + */ + public function test_it_maps_polylang_slugs_to_wpml_codes( string $slug, string $locale, string $code ): void { + $this->wpdb->default_locales[ $locale ] = $code; + + $subject = $this->subject_with_languages( array( $this->language( $slug, $locale ) ) ); + + $this->assertSame( $code, $subject->lang_slug_to_wpml_format( $slug ) ); + } + + public function localeMappings(): array { + return array( + 'Traditional Chinese' => array( 'zh', 'zh_TW', 'zh-hant' ), + 'Simplified Chinese' => array( 'zh', 'zh_CN', 'zh-hans' ), + 'Norwegian Bokmal' => array( 'no', 'nb_NO', 'nb' ), + 'custom English slug' => array( 'english', 'en_US', 'en' ), + 'Portuguese' => array( 'pt', 'pt_PT', 'pt-pt' ), + ); + } + + public function test_locale_override_takes_precedence_over_the_default_locale(): void { + $this->wpdb->locale_overrides['pt_PT'] = 'custom-pt'; + $this->wpdb->default_locales['pt_PT'] = 'pt-pt'; + + $subject = $this->subject_with_languages( array( $this->language( 'pt', 'pt_PT' ) ) ); + + $this->assertSame( 'custom-pt', $subject->lang_slug_to_wpml_format( 'pt' ) ); + } + + /** + * @dataProvider legacyMappings + */ + public function test_it_keeps_legacy_fallbacks_when_wpml_tables_do_not_match( string $slug, string $locale, string $code ): void { + $subject = $this->subject_with_languages( array( $this->language( $slug, $locale ) ) ); + + $this->assertSame( $code, $subject->lang_slug_to_wpml_format( $slug ) ); + } + + public function legacyMappings(): array { + return array( + 'Brazilian Portuguese' => array( 'pt', 'pt_BR', 'pt-br' ), + 'Traditional Chinese' => array( 'zh', 'zh_HK', 'zh-hant' ), + 'Simplified Chinese' => array( 'zh', 'zh_CN', 'zh-hans' ), + ); + } + + public function test_it_records_an_unknown_slug(): void { + $subject = $this->subject_with_languages( array( $this->language( 'klingon', 'tlh_AA' ) ) ); + + $this->assertSame( 'klingon', $subject->lang_slug_to_wpml_format( 'klingon' ) ); + $this->assertSame( array( 'klingon' => 'tlh_AA' ), $subject->get_unmapped_languages() ); + } + + public function test_it_accepts_a_known_slug_when_its_locale_does_not_match(): void { + $this->wpdb->known_codes = array( 'es' ); + + $subject = $this->subject_with_languages( array( $this->language( 'es', '' ) ) ); + + $this->assertSame( 'es', $subject->lang_slug_to_wpml_format( 'es' ) ); + $this->assertSame( array(), $subject->get_unmapped_languages() ); + } + + /** + * @dataProvider unusableSlugs + */ + public function test_it_rejects_unusable_slugs( $slug ): void { + $subject = $this->subject_with_languages( array() ); + + $this->assertSame( '', $subject->lang_slug_to_wpml_format( $slug ) ); + } + + public function unusableSlugs(): array { + return array( + 'empty string' => array( '' ), + 'array' => array( array( 'en' ) ), + 'object' => array( (object) array( 'slug' => 'en' ) ), + ); + } + + private function subject_with_languages( array $languages ): mpw_polylang_data { + $terms = new ReflectionProperty( 'mpw_polylang_data', 'terms' ); + $terms->setAccessible( true ); + $terms->setValue( null, array( 'language' => $languages ) ); + + return new mpw_polylang_data(); + } + + private function language( string $slug, string $locale ): object { + return (object) array( + 'slug' => $slug, + 'description' => array( 'locale' => $locale ), + ); + } +} diff --git a/tests/phpunit/bootstrap.php b/tests/phpunit/bootstrap.php index 33848e8..915a22b 100644 --- a/tests/phpunit/bootstrap.php +++ b/tests/phpunit/bootstrap.php @@ -1,8 +1,11 @@ prepared_value = $value; + + return $query; + } + + public function get_var( string $query ) { + if ( false !== strpos( $query, 'icl_locale_map' ) ) { + return $this->locale_overrides[ $this->prepared_value ] ?? null; + } + + if ( false !== strpos( $query, 'default_locale' ) ) { + return $this->default_locales[ $this->prepared_value ] ?? null; + } + + return in_array( $this->prepared_value, $this->known_codes, true ) + ? $this->prepared_value + : null; + } +} From 53cf4d5bc79b3fbcf03446d57d645d3cc629c185 Mon Sep 17 00:00:00 2001 From: David Garcia Watkins Date: Mon, 24 Aug 2026 16:04:14 +0200 Subject: [PATCH 3/5] Resolve language codes only through locales --- classes/class-mpw_migrate_posts.php | 12 ++- classes/class-mpw_polylang_data.php | 92 ++++--------------- migrate-polylang-to-wpml.php | 15 ++- tests/phpunit/TestLanguageMapping.php | 48 +++++----- .../includes/class-language-mapping-wpdb.php | 25 +++-- 5 files changed, 71 insertions(+), 121 deletions(-) diff --git a/classes/class-mpw_migrate_posts.php b/classes/class-mpw_migrate_posts.php index 25c0d8f..c423e48 100644 --- a/classes/class-mpw_migrate_posts.php +++ b/classes/class-mpw_migrate_posts.php @@ -33,6 +33,9 @@ public function migrate_posts() { foreach ($posts_grouped_by_polylang_lang_relation as $relation) { $default_language_code = $this->get_default_language_code($relation); + if ('' === $default_language_code['wpml']) { + continue; + } $originalPostId = $this->getOriginalPostId( $relation, $default_language_code ); if ( ! $originalPostId ) { @@ -245,6 +248,9 @@ private function set_other_posts_language_details( $relation, $default_language_ } $next_post_language_code_wpml_format = $this->polylang_data->lang_slug_to_wpml_format($next_post_language_code); + if ('' === $next_post_language_code_wpml_format) { + continue; + } do_action('wpml_set_element_language_details', array( 'element_id' => $post_id, @@ -262,7 +268,11 @@ private function set_other_posts_language_details( $relation, $default_language_ // Polylang's sync map is keyed by its own slugs; make_duplicate() wants a WPML code. foreach ( $sync as $targetLang => $sourceLang ) { if ( $targetLang !== $sourceLang ) { - $sitepress->make_duplicate( $originalPostId, $this->polylang_data->lang_slug_to_wpml_format( $targetLang ) ); + $target_language_code = $this->polylang_data->lang_slug_to_wpml_format( $targetLang ); + + if ( '' !== $target_language_code ) { + $sitepress->make_duplicate( $originalPostId, $target_language_code ); + } } } } diff --git a/classes/class-mpw_polylang_data.php b/classes/class-mpw_polylang_data.php index a32f917..28b8628 100644 --- a/classes/class-mpw_polylang_data.php +++ b/classes/class-mpw_polylang_data.php @@ -130,19 +130,14 @@ public function get_languages_map() { /** * Translates a Polylang language slug into the WPML language code. * - * A Polylang slug is whatever the site owner typed when they created the language. A WPML code - * comes from a fixed list of about 250. The dependable bridge between them is the locale: - * Polylang keeps it in the `language` term's serialised description, and WPML keeps it in - * `icl_languages.default_locale`, with per-site overrides in `icl_locale_map`. + * Both Polylang slugs and WPML codes can be customised independently. The dependable bridge + * between them is the locale: Polylang keeps it in the `language` term's serialised + * description, and WPML keeps it in `icl_languages.default_locale`, with per-site overrides + * in `icl_locale_map`. * * WPML resolves a code to a locale in WPML_Locale::get_all_locales() by preferring the override * and falling back to the default. This runs that same lookup backwards. * - * Order of resolution: - * 1. the locale from Polylang, matched against WPML's own tables - * 2. the historical pt/zh special cases, if WPML's tables have nothing to say - * 3. the slug unchanged, which is already correct wherever the two systems agree - * * @param mixed $slug * * @return string A WPML language code, or an empty string for unusable input. @@ -167,10 +162,11 @@ public function lang_slug_to_wpml_format($slug) { } /** - * Languages whose slug reached WPML unmapped and which WPML does not recognise. + * Languages whose locale could not be mapped unambiguously to a WPML language. * - * Writing one of these into icl_translations is not an error WPML will report — it stores the - * code verbatim — so the migration reports them instead. + * A Polylang slug and a WPML code may happen to be equal, but that is not evidence that they + * represent the same language. Callers receive an empty code for these entries so they do not + * write an unsupported or incorrect language into WPML. * * @return array Polylang slug => locale (empty string when Polylang had no locale either). */ @@ -194,39 +190,7 @@ private function resolve_wpml_code($slug) { } } - $legacy_code = $this->legacy_code_for_slug($slug, $locale); - - if ('' !== $legacy_code) { - return $legacy_code; - } - - if (!$this->is_known_wpml_code($slug)) { - $this->unmapped_languages[$slug] = $locale; - } - - return $slug; - } - - /** - * The mapping this plugin shipped before WPML's tables were consulted. - * - * Kept as a fallback for sites where the WPML tables cannot answer. The Chinese case is - * corrected here: the old code sent every `zh` slug to `zh-hans`, so a Traditional Chinese - * site was migrated as Simplified. - * - * @param string $slug - * @param string $locale - * - * @return string Empty string when this slug has no special case. - */ - private function legacy_code_for_slug($slug, $locale) { - if ('pt' === $slug) { - return 'pt_BR' === $locale ? 'pt-br' : 'pt-pt'; - } - - if ('zh' === $slug) { - return in_array($locale, array('zh_TW', 'zh_HK', 'zh_MO'), true) ? 'zh-hant' : 'zh-hans'; - } + $this->unmapped_languages[$slug] = $locale; return ''; } @@ -270,40 +234,16 @@ private function wpml_code_for_locale($locale) { return ''; } - // ORDER BY code so a site that has hand-added a second icl_locale_map row for one locale - // still resolves to a single, deterministic code rather than whatever the engine returns first. - $code = $wpdb->get_var($wpdb->prepare( - "SELECT code FROM {$wpdb->prefix}icl_locale_map WHERE locale = %s ORDER BY code LIMIT 1", + $codes = $wpdb->get_col($wpdb->prepare( + "SELECT languages.code + FROM {$wpdb->prefix}icl_languages languages + LEFT JOIN {$wpdb->prefix}icl_locale_map locale_map ON locale_map.code = languages.code + WHERE COALESCE(locale_map.locale, languages.default_locale) = %s + ORDER BY languages.code", $locale )); - if (!$code) { - $code = $wpdb->get_var($wpdb->prepare( - "SELECT code FROM {$wpdb->prefix}icl_languages WHERE default_locale = %s ORDER BY code LIMIT 1", - $locale - )); - } - - return is_string($code) ? $code : ''; - } - - /** - * @param string $code - * - * @return bool - */ - private function is_known_wpml_code($code) { - global $wpdb; - - if (!$this->wpml_tables_available()) { - // Without WPML there is nothing to check against, so don't claim the code is wrong. - return true; - } - - return (bool) $wpdb->get_var($wpdb->prepare( - "SELECT code FROM {$wpdb->prefix}icl_languages WHERE code = %s LIMIT 1", - $code - )); + return is_array($codes) && 1 === count($codes) && is_string($codes[0]) ? $codes[0] : ''; } /** diff --git a/migrate-polylang-to-wpml.php b/migrate-polylang-to-wpml.php index ab89077..a669a0b 100644 --- a/migrate-polylang-to-wpml.php +++ b/migrate-polylang-to-wpml.php @@ -508,6 +508,10 @@ private function migrate_taxonomies() { $element_type = apply_filters('wpml_element_type', $original_term->taxonomy); $original_language_code = $this->polylang_data->lang_slug_to_wpml_format($original_slug); + if ('' === $original_language_code) { + continue; + } + do_action('wpml_set_element_language_details', array( 'element_id' => $original_term->term_taxonomy_id, 'element_type' => $element_type, @@ -532,8 +536,9 @@ private function migrate_taxonomies() { foreach ($relation as $translation_slug => $term_id) { $translated_term = $this->get_term_by_term_id($term_id); + $translation_language_code = $this->polylang_data->lang_slug_to_wpml_format($translation_slug); - if (!isset($translated_term->term_taxonomy_id)) { + if (!isset($translated_term->term_taxonomy_id) || '' === $translation_language_code) { continue; } @@ -541,7 +546,7 @@ private function migrate_taxonomies() { 'element_id' => $translated_term->term_taxonomy_id, 'element_type' => $element_type, 'trid' => $trid, - 'language_code' => $this->polylang_data->lang_slug_to_wpml_format($translation_slug), + 'language_code' => $translation_language_code, 'source_language_code' => $original_language_code )); } @@ -716,7 +721,11 @@ private function migrate_widgets() { if ($option && is_array($option)) { foreach ($option as $key => $val) { if (is_numeric($key) && is_array($val) && isset($val['pll_lang'])) { - $option[$key]['wpml_language'] = $this->polylang_data->lang_slug_to_wpml_format($val['pll_lang']); + $language_code = $this->polylang_data->lang_slug_to_wpml_format($val['pll_lang']); + + if ('' !== $language_code) { + $option[$key]['wpml_language'] = $language_code; + } } } update_option($widget->option_name, $option); diff --git a/tests/phpunit/TestLanguageMapping.php b/tests/phpunit/TestLanguageMapping.php index eb07f88..5c2b5d1 100644 --- a/tests/phpunit/TestLanguageMapping.php +++ b/tests/phpunit/TestLanguageMapping.php @@ -17,7 +17,7 @@ public function setUp(): void { * @dataProvider localeMappings */ public function test_it_maps_polylang_slugs_to_wpml_codes( string $slug, string $locale, string $code ): void { - $this->wpdb->default_locales[ $locale ] = $code; + $this->wpdb->effective_locales[ $code ] = $locale; $subject = $this->subject_with_languages( array( $this->language( $slug, $locale ) ) ); @@ -34,46 +34,40 @@ public function localeMappings(): array { ); } - public function test_locale_override_takes_precedence_over_the_default_locale(): void { - $this->wpdb->locale_overrides['pt_PT'] = 'custom-pt'; - $this->wpdb->default_locales['pt_PT'] = 'pt-pt'; + public function test_it_supports_an_arbitrary_custom_wpml_code(): void { + $this->wpdb->effective_locales['customers-own-code'] = 'pt_PT'; $subject = $this->subject_with_languages( array( $this->language( 'pt', 'pt_PT' ) ) ); - $this->assertSame( 'custom-pt', $subject->lang_slug_to_wpml_format( 'pt' ) ); + $this->assertSame( 'customers-own-code', $subject->lang_slug_to_wpml_format( 'pt' ) ); } - /** - * @dataProvider legacyMappings - */ - public function test_it_keeps_legacy_fallbacks_when_wpml_tables_do_not_match( string $slug, string $locale, string $code ): void { - $subject = $this->subject_with_languages( array( $this->language( $slug, $locale ) ) ); + public function test_it_records_an_unknown_slug(): void { + $subject = $this->subject_with_languages( array( $this->language( 'klingon', 'tlh_AA' ) ) ); - $this->assertSame( $code, $subject->lang_slug_to_wpml_format( $slug ) ); + $this->assertSame( '', $subject->lang_slug_to_wpml_format( 'klingon' ) ); + $this->assertSame( array( 'klingon' => 'tlh_AA' ), $subject->get_unmapped_languages() ); } - public function legacyMappings(): array { - return array( - 'Brazilian Portuguese' => array( 'pt', 'pt_BR', 'pt-br' ), - 'Traditional Chinese' => array( 'zh', 'zh_HK', 'zh-hant' ), - 'Simplified Chinese' => array( 'zh', 'zh_CN', 'zh-hans' ), - ); - } + public function test_equal_codes_do_not_map_without_a_matching_locale(): void { + $this->wpdb->effective_locales['es'] = 'es_MX'; - public function test_it_records_an_unknown_slug(): void { - $subject = $this->subject_with_languages( array( $this->language( 'klingon', 'tlh_AA' ) ) ); + $subject = $this->subject_with_languages( array( $this->language( 'es', 'es_ES' ) ) ); - $this->assertSame( 'klingon', $subject->lang_slug_to_wpml_format( 'klingon' ) ); - $this->assertSame( array( 'klingon' => 'tlh_AA' ), $subject->get_unmapped_languages() ); + $this->assertSame( '', $subject->lang_slug_to_wpml_format( 'es' ) ); + $this->assertSame( array( 'es' => 'es_ES' ), $subject->get_unmapped_languages() ); } - public function test_it_accepts_a_known_slug_when_its_locale_does_not_match(): void { - $this->wpdb->known_codes = array( 'es' ); + public function test_an_ambiguous_locale_is_not_mapped_arbitrarily(): void { + $this->wpdb->effective_locales = array( + 'custom-one' => 'en_US', + 'custom-two' => 'en_US', + ); - $subject = $this->subject_with_languages( array( $this->language( 'es', '' ) ) ); + $subject = $this->subject_with_languages( array( $this->language( 'english', 'en_US' ) ) ); - $this->assertSame( 'es', $subject->lang_slug_to_wpml_format( 'es' ) ); - $this->assertSame( array(), $subject->get_unmapped_languages() ); + $this->assertSame( '', $subject->lang_slug_to_wpml_format( 'english' ) ); + $this->assertSame( array( 'english' => 'en_US' ), $subject->get_unmapped_languages() ); } /** diff --git a/tests/phpunit/includes/class-language-mapping-wpdb.php b/tests/phpunit/includes/class-language-mapping-wpdb.php index 350c886..48e7403 100644 --- a/tests/phpunit/includes/class-language-mapping-wpdb.php +++ b/tests/phpunit/includes/class-language-mapping-wpdb.php @@ -1,10 +1,8 @@ locale_overrides[ $this->prepared_value ] ?? null; + public function get_col( string $query ): array { + if ( false === strpos( $query, 'COALESCE' ) ) { + return array(); } - if ( false !== strpos( $query, 'default_locale' ) ) { - return $this->default_locales[ $this->prepared_value ] ?? null; - } - - return in_array( $this->prepared_value, $this->known_codes, true ) - ? $this->prepared_value - : null; + return array_keys( + array_filter( + $this->effective_locales, + fn( string $locale ): bool => $this->prepared_value === $locale + ) + ); } } From 1889415a6de46bfa4cd0da25980b62a5816bf21d Mon Sep 17 00:00:00 2001 From: David Garcia Watkins Date: Mon, 24 Aug 2026 18:13:17 +0200 Subject: [PATCH 4/5] Use active WPML languages for locale mapping --- classes/class-mpw_polylang_data.php | 40 ++++++-------- tests/phpunit/TestLanguageMapping.php | 54 +++++++++++-------- tests/phpunit/bootstrap.php | 2 - .../includes/class-language-mapping-wpdb.php | 27 ---------- 4 files changed, 48 insertions(+), 75 deletions(-) delete mode 100644 tests/phpunit/includes/class-language-mapping-wpdb.php diff --git a/classes/class-mpw_polylang_data.php b/classes/class-mpw_polylang_data.php index 28b8628..eb9ef97 100644 --- a/classes/class-mpw_polylang_data.php +++ b/classes/class-mpw_polylang_data.php @@ -130,13 +130,8 @@ public function get_languages_map() { /** * Translates a Polylang language slug into the WPML language code. * - * Both Polylang slugs and WPML codes can be customised independently. The dependable bridge - * between them is the locale: Polylang keeps it in the `language` term's serialised - * description, and WPML keeps it in `icl_languages.default_locale`, with per-site overrides - * in `icl_locale_map`. - * - * WPML resolves a code to a locale in WPML_Locale::get_all_locales() by preferring the override - * and falling back to the default. This runs that same lookup backwards. + * Both Polylang slugs and WPML codes can be customised independently, so the locale is used + * as the bridge between them. * * @param mixed $slug * @@ -228,29 +223,26 @@ private function get_locale_for_slug($slug) { * @return string The WPML code for this locale, or an empty string. */ private function wpml_code_for_locale($locale) { - global $wpdb; + $languages = apply_filters('wpml_active_languages', null); - if (!$this->wpml_tables_available()) { + if (!is_array($languages)) { return ''; } - $codes = $wpdb->get_col($wpdb->prepare( - "SELECT languages.code - FROM {$wpdb->prefix}icl_languages languages - LEFT JOIN {$wpdb->prefix}icl_locale_map locale_map ON locale_map.code = languages.code - WHERE COALESCE(locale_map.locale, languages.default_locale) = %s - ORDER BY languages.code", - $locale - )); + $codes = array(); - return is_array($codes) && 1 === count($codes) && is_string($codes[0]) ? $codes[0] : ''; - } + foreach ($languages as $language) { + if ( + is_array($language) + && isset($language['language_code'], $language['default_locale']) + && is_string($language['language_code']) + && $locale === $language['default_locale'] + ) { + $codes[] = $language['language_code']; + } + } - /** - * @return bool - */ - private function wpml_tables_available() { - return defined('ICL_SITEPRESS_VERSION'); + return 1 === count($codes) ? $codes[0] : ''; } diff --git a/tests/phpunit/TestLanguageMapping.php b/tests/phpunit/TestLanguageMapping.php index 5c2b5d1..5be2572 100644 --- a/tests/phpunit/TestLanguageMapping.php +++ b/tests/phpunit/TestLanguageMapping.php @@ -1,15 +1,9 @@ wpdb = new LanguageMappingWpdb(); - // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- The subject reads the WordPress database global directly. - $GLOBALS['wpdb'] = $this->wpdb; - WP_Mock::userFunction( 'maybe_unserialize', array( 'return_arg' => 0 ) ); } @@ -17,9 +11,10 @@ public function setUp(): void { * @dataProvider localeMappings */ public function test_it_maps_polylang_slugs_to_wpml_codes( string $slug, string $locale, string $code ): void { - $this->wpdb->effective_locales[ $code ] = $locale; - - $subject = $this->subject_with_languages( array( $this->language( $slug, $locale ) ) ); + $subject = $this->subject_with_languages( + array( $this->language( $slug, $locale ) ), + array( $this->wpml_language( $code, $locale ) ) + ); $this->assertSame( $code, $subject->lang_slug_to_wpml_format( $slug ) ); } @@ -35,37 +30,43 @@ public function localeMappings(): array { } public function test_it_supports_an_arbitrary_custom_wpml_code(): void { - $this->wpdb->effective_locales['customers-own-code'] = 'pt_PT'; - - $subject = $this->subject_with_languages( array( $this->language( 'pt', 'pt_PT' ) ) ); + $subject = $this->subject_with_languages( + array( $this->language( 'pt', 'pt_PT' ) ), + array( $this->wpml_language( 'customers-own-code', 'pt_PT' ) ) + ); $this->assertSame( 'customers-own-code', $subject->lang_slug_to_wpml_format( 'pt' ) ); } public function test_it_records_an_unknown_slug(): void { - $subject = $this->subject_with_languages( array( $this->language( 'klingon', 'tlh_AA' ) ) ); + $subject = $this->subject_with_languages( + array( $this->language( 'klingon', 'tlh_AA' ) ), + array() + ); $this->assertSame( '', $subject->lang_slug_to_wpml_format( 'klingon' ) ); $this->assertSame( array( 'klingon' => 'tlh_AA' ), $subject->get_unmapped_languages() ); } public function test_equal_codes_do_not_map_without_a_matching_locale(): void { - $this->wpdb->effective_locales['es'] = 'es_MX'; - - $subject = $this->subject_with_languages( array( $this->language( 'es', 'es_ES' ) ) ); + $subject = $this->subject_with_languages( + array( $this->language( 'es', 'es_ES' ) ), + array( $this->wpml_language( 'es', 'es_MX' ) ) + ); $this->assertSame( '', $subject->lang_slug_to_wpml_format( 'es' ) ); $this->assertSame( array( 'es' => 'es_ES' ), $subject->get_unmapped_languages() ); } public function test_an_ambiguous_locale_is_not_mapped_arbitrarily(): void { - $this->wpdb->effective_locales = array( - 'custom-one' => 'en_US', - 'custom-two' => 'en_US', + $subject = $this->subject_with_languages( + array( $this->language( 'english', 'en_US' ) ), + array( + $this->wpml_language( 'custom-one', 'en_US' ), + $this->wpml_language( 'custom-two', 'en_US' ), + ) ); - $subject = $this->subject_with_languages( array( $this->language( 'english', 'en_US' ) ) ); - $this->assertSame( '', $subject->lang_slug_to_wpml_format( 'english' ) ); $this->assertSame( array( 'english' => 'en_US' ), $subject->get_unmapped_languages() ); } @@ -87,11 +88,13 @@ public function unusableSlugs(): array { ); } - private function subject_with_languages( array $languages ): mpw_polylang_data { + private function subject_with_languages( array $languages, array $wpml_languages = array() ): mpw_polylang_data { $terms = new ReflectionProperty( 'mpw_polylang_data', 'terms' ); $terms->setAccessible( true ); $terms->setValue( null, array( 'language' => $languages ) ); + WP_Mock::onFilter( 'wpml_active_languages' )->with( null )->reply( $wpml_languages ); + return new mpw_polylang_data(); } @@ -101,4 +104,11 @@ private function language( string $slug, string $locale ): object { 'description' => array( 'locale' => $locale ), ); } + + private function wpml_language( string $code, string $locale ): array { + return array( + 'language_code' => $code, + 'default_locale' => $locale, + ); + } } diff --git a/tests/phpunit/bootstrap.php b/tests/phpunit/bootstrap.php index 915a22b..5ad0956 100644 --- a/tests/phpunit/bootstrap.php +++ b/tests/phpunit/bootstrap.php @@ -1,11 +1,9 @@ prepared_value = $value; - - return $query; - } - - public function get_col( string $query ): array { - if ( false === strpos( $query, 'COALESCE' ) ) { - return array(); - } - - return array_keys( - array_filter( - $this->effective_locales, - fn( string $locale ): bool => $this->prepared_value === $locale - ) - ); - } -} From 9ade3265dca6065c20a0fdbd039662fd5a0deaa4 Mon Sep 17 00:00:00 2001 From: David Garcia Watkins Date: Mon, 24 Aug 2026 19:05:27 +0200 Subject: [PATCH 5/5] Revert "Use active WPML languages for locale mapping" This reverts commit 1889415a6de46bfa4cd0da25980b62a5816bf21d. --- classes/class-mpw_polylang_data.php | 40 ++++++++------ tests/phpunit/TestLanguageMapping.php | 54 ++++++++----------- tests/phpunit/bootstrap.php | 2 + .../includes/class-language-mapping-wpdb.php | 27 ++++++++++ 4 files changed, 75 insertions(+), 48 deletions(-) create mode 100644 tests/phpunit/includes/class-language-mapping-wpdb.php diff --git a/classes/class-mpw_polylang_data.php b/classes/class-mpw_polylang_data.php index eb9ef97..28b8628 100644 --- a/classes/class-mpw_polylang_data.php +++ b/classes/class-mpw_polylang_data.php @@ -130,8 +130,13 @@ public function get_languages_map() { /** * Translates a Polylang language slug into the WPML language code. * - * Both Polylang slugs and WPML codes can be customised independently, so the locale is used - * as the bridge between them. + * Both Polylang slugs and WPML codes can be customised independently. The dependable bridge + * between them is the locale: Polylang keeps it in the `language` term's serialised + * description, and WPML keeps it in `icl_languages.default_locale`, with per-site overrides + * in `icl_locale_map`. + * + * WPML resolves a code to a locale in WPML_Locale::get_all_locales() by preferring the override + * and falling back to the default. This runs that same lookup backwards. * * @param mixed $slug * @@ -223,26 +228,29 @@ private function get_locale_for_slug($slug) { * @return string The WPML code for this locale, or an empty string. */ private function wpml_code_for_locale($locale) { - $languages = apply_filters('wpml_active_languages', null); + global $wpdb; - if (!is_array($languages)) { + if (!$this->wpml_tables_available()) { return ''; } - $codes = array(); + $codes = $wpdb->get_col($wpdb->prepare( + "SELECT languages.code + FROM {$wpdb->prefix}icl_languages languages + LEFT JOIN {$wpdb->prefix}icl_locale_map locale_map ON locale_map.code = languages.code + WHERE COALESCE(locale_map.locale, languages.default_locale) = %s + ORDER BY languages.code", + $locale + )); - foreach ($languages as $language) { - if ( - is_array($language) - && isset($language['language_code'], $language['default_locale']) - && is_string($language['language_code']) - && $locale === $language['default_locale'] - ) { - $codes[] = $language['language_code']; - } - } + return is_array($codes) && 1 === count($codes) && is_string($codes[0]) ? $codes[0] : ''; + } - return 1 === count($codes) ? $codes[0] : ''; + /** + * @return bool + */ + private function wpml_tables_available() { + return defined('ICL_SITEPRESS_VERSION'); } diff --git a/tests/phpunit/TestLanguageMapping.php b/tests/phpunit/TestLanguageMapping.php index 5be2572..5c2b5d1 100644 --- a/tests/phpunit/TestLanguageMapping.php +++ b/tests/phpunit/TestLanguageMapping.php @@ -1,9 +1,15 @@ wpdb = new LanguageMappingWpdb(); + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- The subject reads the WordPress database global directly. + $GLOBALS['wpdb'] = $this->wpdb; + WP_Mock::userFunction( 'maybe_unserialize', array( 'return_arg' => 0 ) ); } @@ -11,10 +17,9 @@ public function setUp(): void { * @dataProvider localeMappings */ public function test_it_maps_polylang_slugs_to_wpml_codes( string $slug, string $locale, string $code ): void { - $subject = $this->subject_with_languages( - array( $this->language( $slug, $locale ) ), - array( $this->wpml_language( $code, $locale ) ) - ); + $this->wpdb->effective_locales[ $code ] = $locale; + + $subject = $this->subject_with_languages( array( $this->language( $slug, $locale ) ) ); $this->assertSame( $code, $subject->lang_slug_to_wpml_format( $slug ) ); } @@ -30,43 +35,37 @@ public function localeMappings(): array { } public function test_it_supports_an_arbitrary_custom_wpml_code(): void { - $subject = $this->subject_with_languages( - array( $this->language( 'pt', 'pt_PT' ) ), - array( $this->wpml_language( 'customers-own-code', 'pt_PT' ) ) - ); + $this->wpdb->effective_locales['customers-own-code'] = 'pt_PT'; + + $subject = $this->subject_with_languages( array( $this->language( 'pt', 'pt_PT' ) ) ); $this->assertSame( 'customers-own-code', $subject->lang_slug_to_wpml_format( 'pt' ) ); } public function test_it_records_an_unknown_slug(): void { - $subject = $this->subject_with_languages( - array( $this->language( 'klingon', 'tlh_AA' ) ), - array() - ); + $subject = $this->subject_with_languages( array( $this->language( 'klingon', 'tlh_AA' ) ) ); $this->assertSame( '', $subject->lang_slug_to_wpml_format( 'klingon' ) ); $this->assertSame( array( 'klingon' => 'tlh_AA' ), $subject->get_unmapped_languages() ); } public function test_equal_codes_do_not_map_without_a_matching_locale(): void { - $subject = $this->subject_with_languages( - array( $this->language( 'es', 'es_ES' ) ), - array( $this->wpml_language( 'es', 'es_MX' ) ) - ); + $this->wpdb->effective_locales['es'] = 'es_MX'; + + $subject = $this->subject_with_languages( array( $this->language( 'es', 'es_ES' ) ) ); $this->assertSame( '', $subject->lang_slug_to_wpml_format( 'es' ) ); $this->assertSame( array( 'es' => 'es_ES' ), $subject->get_unmapped_languages() ); } public function test_an_ambiguous_locale_is_not_mapped_arbitrarily(): void { - $subject = $this->subject_with_languages( - array( $this->language( 'english', 'en_US' ) ), - array( - $this->wpml_language( 'custom-one', 'en_US' ), - $this->wpml_language( 'custom-two', 'en_US' ), - ) + $this->wpdb->effective_locales = array( + 'custom-one' => 'en_US', + 'custom-two' => 'en_US', ); + $subject = $this->subject_with_languages( array( $this->language( 'english', 'en_US' ) ) ); + $this->assertSame( '', $subject->lang_slug_to_wpml_format( 'english' ) ); $this->assertSame( array( 'english' => 'en_US' ), $subject->get_unmapped_languages() ); } @@ -88,13 +87,11 @@ public function unusableSlugs(): array { ); } - private function subject_with_languages( array $languages, array $wpml_languages = array() ): mpw_polylang_data { + private function subject_with_languages( array $languages ): mpw_polylang_data { $terms = new ReflectionProperty( 'mpw_polylang_data', 'terms' ); $terms->setAccessible( true ); $terms->setValue( null, array( 'language' => $languages ) ); - WP_Mock::onFilter( 'wpml_active_languages' )->with( null )->reply( $wpml_languages ); - return new mpw_polylang_data(); } @@ -104,11 +101,4 @@ private function language( string $slug, string $locale ): object { 'description' => array( 'locale' => $locale ), ); } - - private function wpml_language( string $code, string $locale ): array { - return array( - 'language_code' => $code, - 'default_locale' => $locale, - ); - } } diff --git a/tests/phpunit/bootstrap.php b/tests/phpunit/bootstrap.php index 5ad0956..915a22b 100644 --- a/tests/phpunit/bootstrap.php +++ b/tests/phpunit/bootstrap.php @@ -1,9 +1,11 @@ prepared_value = $value; + + return $query; + } + + public function get_col( string $query ): array { + if ( false === strpos( $query, 'COALESCE' ) ) { + return array(); + } + + return array_keys( + array_filter( + $this->effective_locales, + fn( string $locale ): bool => $this->prepared_value === $locale + ) + ); + } +}