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 b9c8dbe..28b8628 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,21 @@ public function get_languages_map() { return $polylang_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. + * + * @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,26 +150,107 @@ 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 locale could not be mapped unambiguously to a WPML language. + * + * 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). + */ + 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; + } + } + + $this->unmapped_languages[$slug] = $locale; + + 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 ''; } - if (isset($different[$slug])) { - $slug = $different[$slug]; + + 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 ''; } - - return $slug; + + $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 + )); + + return is_array($codes) && 1 === count($codes) && is_string($codes[0]) ? $codes[0] : ''; + } + + /** + * @return bool + */ + private function wpml_tables_available() { + return defined('ICL_SITEPRESS_VERSION'); } diff --git a/migrate-polylang-to-wpml.php b/migrate-polylang-to-wpml.php index 27d25ed..a669a0b 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) + ); } } @@ -499,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, @@ -523,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; } @@ -532,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 )); } @@ -707,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 new file mode 100644 index 0000000..5c2b5d1 --- /dev/null +++ b/tests/phpunit/TestLanguageMapping.php @@ -0,0 +1,104 @@ +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->effective_locales[ $code ] = $locale; + + $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_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( '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' ) ) ); + + $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' ) ) ); + + $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' ) ) ); + + $this->assertSame( '', $subject->lang_slug_to_wpml_format( 'english' ) ); + $this->assertSame( array( 'english' => 'en_US' ), $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_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 + ) + ); + } +}