Term links already checked in this request. */ private array $linkedTerms = []; public function name(): string { return 'WPML'; } public static function duplicateCustomFieldExceptions(mixed $exceptions): mixed { $identityMeta = array_merge( WooPublisher::TRANSLATION_IDENTITY_META, [ '_wpi_translation_identity', '_wpi_translation_family', '_wpi_translation_language', ] ); return is_array($exceptions) ? array_values(array_unique(array_merge($exceptions, $identityMeta))) : $exceptions; } public function available(): bool { // Only WPML itself proves WPML. Polylang registers the WPML // compatibility filters under its own name, so a filter test reports // WPML on a Polylang store and the wrong adapter publishes. return defined('ICL_SITEPRESS_VERSION') || defined('WPML_VERSION') || class_exists('SitePress'); } public function canPublishProducts(): bool { if (!$this->available() || !class_exists('WCML_Dependencies')) { return false; } return (bool) (new \WCML_Dependencies())->check(); } public function defaultLocale(): string { $language = $this->defaultLanguage(); $languages = $this->activeLanguages(); return (string) ($languages[$language]['default_locale'] ?? $languages[$language]['locale'] ?? determine_locale()); } public function defaultLanguage(): string { return $this->available() ? (string) apply_filters('wpml_default_language', null) : ''; } public function productLanguage(int $productId): string { if (!$this->available()) { return ''; } $type = get_post_type($productId) === 'product_variation' ? 'post_product_variation' : 'post_product'; return (string) apply_filters('wpml_element_language_code', null, [ 'element_id' => $productId, 'element_type' => $type, ]); } public function termLanguage(int $termId, string $taxonomy): string { if (!$this->available()) { return ''; } $term = \WP_Term::get_instance($termId, $taxonomy); if (!$term || is_wp_error($term)) { return ''; } global $wpdb; return (string) $wpdb->get_var($wpdb->prepare( 'SELECT language_code FROM ' . $wpdb->prefix . 'icl_translations' . ' WHERE element_id=%d AND element_type=%s LIMIT 1', (int) $term->term_taxonomy_id, 'tax_' . $taxonomy )); } public function supportsLanguage(string $language): bool { return isset($this->activeLanguages()[$language]); } public function switchLanguage(string $language): string { $previous = $this->available() ? (string) apply_filters('wpml_current_language', null) : ''; $language = sanitize_key($language); if ($this->available() && $language !== '') { do_action('wpml_switch_language', $language); } return $previous; } public function restoreLanguage(string $previousLanguage): void { unset($previousLanguage); if ($this->available()) { // WPML 5.x language switches are nested scopes. Passing null closes // this adapter's scope; switching to the old code would open a new // scope and leave the request in a corrupted language stack. do_action('wpml_switch_language', null); } } public function assignProductLanguage(int $productId, string $language): void { if (!$this->available()) { return; } $type = get_post_type($productId) === 'product_variation' ? 'post_product_variation' : 'post_product'; $current = $this->productLanguage($productId); $trid = apply_filters('wpml_element_trid', null, $productId, $type); if ($current === $language && $trid) { return; } do_action('wpml_set_element_language_details', [ 'element_id' => $productId, 'element_type' => $type, 'trid' => $trid ?: false, 'language_code' => $language, 'source_language_code' => null, 'check_duplicates' => false, ]); } public function assignTermLanguage(int $termId, string $taxonomy, string $language): void { if (!$this->available()) { return; } $term = \WP_Term::get_instance($termId, $taxonomy); if (!$term || is_wp_error($term)) { return; } $trid = apply_filters('wpml_element_trid', null, (int) $term->term_taxonomy_id, 'tax_' . $taxonomy); do_action('wpml_set_element_language_details', [ 'element_id' => (int) $term->term_taxonomy_id, 'element_type' => 'tax_' . $taxonomy, 'trid' => $trid ?: false, 'language_code' => $language, 'check_duplicates' => false, ]); } public function translateTerm(int $termId, string $taxonomy, string $language): ?int { $language = sanitize_key($language); if (!$this->available() || $termId <= 0 || $language === '') { return null; } $map = $this->termTranslationMap($termId, $taxonomy); if (isset($map[$language]) && $map[$language] > 0) { return $map[$language]; } $translated = apply_filters('wpml_object_id', $termId, $taxonomy, false, $language); return $translated ? (int) $translated : null; } public function termTranslationMap(int $termId, string $taxonomy): array { if (!$this->available()) { return []; } $term = \WP_Term::get_instance($termId, $taxonomy); if (!$term || is_wp_error($term)) { return []; } $trid = $this->termTranslationGroup($termId, $taxonomy); if (!$trid) { return []; } global $wpdb; $translations = $wpdb->get_results($wpdb->prepare( 'SELECT i.language_code,t.term_id FROM ' . $wpdb->prefix . 'icl_translations i' . " INNER JOIN {$wpdb->term_taxonomy} t ON t.term_taxonomy_id=i.element_id" . ' WHERE i.trid=%d AND i.element_type=%s AND t.taxonomy=%s', (int) $trid, 'tax_' . $taxonomy, $taxonomy ), ARRAY_A) ?: []; $map = []; foreach ($translations as $translation) { $code = sanitize_key((string) ($translation['language_code'] ?? '')); $translatedId = (int) ($translation['term_id'] ?? 0); if ($code === '' || $translatedId <= 0) { continue; } $map[$code] = $translatedId; } return $map; } public function termTranslationGroup(int $termId, string $taxonomy): string { if (!$this->available()) { return ''; } $term = \WP_Term::get_instance($termId, $taxonomy); if (!$term || is_wp_error($term)) { return ''; } global $wpdb; $trid = $wpdb->get_var($wpdb->prepare( 'SELECT trid FROM ' . $wpdb->prefix . 'icl_translations' . ' WHERE element_id=%d AND element_type=%s LIMIT 1', (int) $term->term_taxonomy_id, 'tax_' . $taxonomy )); return $trid ? (string) $trid : ''; } public function originalProductId(int $productId): int { if (!$this->available() || $productId <= 0) { return $productId; } $type = get_post_type($productId) === 'product_variation' ? 'post_product_variation' : 'post_product'; $trid = apply_filters('wpml_element_trid', null, $productId, $type); if (!$trid) { return $productId; } $translations = apply_filters('wpml_get_element_translations', null, $trid, $type); if (!is_array($translations)) { return $productId; } // The original is the translation with no source language. The WPML // default language can be a later duplicate, so it must not win here. foreach ($translations as $translation) { if (!is_object($translation) || empty($translation->element_id)) { continue; } if (empty($translation->source_language_code) || !empty($translation->original)) { return (int) $translation->element_id; } } $default = $this->defaultLanguage(); if ($default && isset($translations[$default]->element_id)) { return (int) $translations[$default]->element_id; } return $productId; } public function translatedProductId(int $productId, string $language): ?int { $language = sanitize_key($language); if (!$this->available() || $productId <= 0 || $language === '') { return null; } $map = $this->translationMap($productId); if (isset($map[$language]) && $map[$language] > 0) { return $map[$language]; } $type = get_post_type($productId) === 'product_variation' ? 'product_variation' : 'product'; $translated = apply_filters('wpml_object_id', $productId, $type, false, $language); return $translated ? (int) $translated : null; } public function translationMap(int $productId): array { if (!$this->available() || $productId <= 0) { return []; } $type = get_post_type($productId) === 'product_variation' ? 'post_product_variation' : 'post_product'; $trid = apply_filters('wpml_element_trid', null, $productId, $type); if (!$trid) { return []; } $translations = apply_filters('wpml_get_element_translations', null, $trid, $type); if (!is_array($translations)) { return []; } $map = []; foreach ($translations as $language => $translation) { if (!is_object($translation) || empty($translation->element_id)) { continue; } $code = sanitize_key((string) $language); if ($code !== '') { $map[$code] = (int) $translation->element_id; } } return $map; } public function ensureDuplicate(int $sourceId, string $language): int { $language = sanitize_key($language); if ($language === '') { throw new RuntimeException('A product translation language is required.'); } if (!$this->supportsLanguage($language)) { throw new RuntimeException('The requested product language is not configured in WPML.'); } $existing = $this->translatedProductId($sourceId, $language); if ($existing && $existing !== $sourceId) { return $existing; } if (!$this->canPublishProducts()) { throw new RuntimeException('WooCommerce Multilingual is required before translated products can be published safely.'); } $this->duplicateInto($sourceId, $language); $duplicated = $this->translatedProductId($sourceId, $language); if (!$duplicated || $duplicated === $sourceId) { throw new RuntimeException(sprintf( 'WPML did not create the requested product translation. Tried: %s.', $this->duplicationMethodsInWords() )); } // WPML reads the post before it copies product_type. WooCommerce can // retain that first "simple" answer in its separate product cache. // Clear it before any CRUD save can write the stale type back. clean_post_cache($duplicated); \WC_Cache_Helper::invalidate_cache_group('product_' . $duplicated); wc_delete_product_transients($duplicated); return $duplicated; } public function linkProducts(int $sourceId, array $translations): void { if (!$this->available()) { throw new RuntimeException('WPML is not installed on this WooCommerce store.'); } $type = get_post_type($sourceId) === 'product_variation' ? 'post_product_variation' : 'post_product'; $sourceLanguage = $this->productLanguage($sourceId) ?: $this->defaultLanguage(); $trid = apply_filters('wpml_element_trid', null, $sourceId, $type); if (!$trid) { $this->assignProductLanguage($sourceId, $sourceLanguage); $trid = apply_filters('wpml_element_trid', null, $sourceId, $type); } if (!$trid) { throw new RuntimeException('WPML could not establish the primary product translation group.'); } do_action('wpml_set_element_language_details', [ 'element_id' => $sourceId, 'element_type' => $type, 'trid' => $trid, 'language_code' => $sourceLanguage, 'source_language_code' => null, 'check_duplicates' => true, ]); foreach ($translations as $language => $productId) { $language = sanitize_key((string) $language); $productId = (int) $productId; if ($language === '' || $productId <= 0 || $productId === $sourceId) { continue; } do_action('wpml_set_element_language_details', [ 'element_id' => $productId, 'element_type' => $type, 'trid' => $trid, 'language_code' => $language, 'source_language_code' => $sourceLanguage, 'check_duplicates' => true, ]); } } public function prepareTranslation(int $productId): void { // This is the operation used by WPML's "Translate independently" // editor action. Keep the translation group, remove only copy mode. delete_post_meta($productId, '_icl_lang_duplicate_of'); } public function linkTerms( int $sourceId, int $translatedId, string $taxonomy, bool $translatedCreatedNow = false ): void { $key = $taxonomy . ':' . $sourceId . ':' . $translatedId; if ($sourceId === $translatedId || isset($this->linkedTerms[$key])) { return; } $language = $this->termLanguage($translatedId, $taxonomy); $map = $this->termTranslationMap($sourceId, $taxonomy); if (($map[$language] ?? 0) === $translatedId) { $this->linkedTerms[$key] = true; return; } if (!empty($map[$language])) { throw new RuntimeException('An attribute option already has a different translation in ' . $language . '.'); } $source = \WP_Term::get_instance($sourceId, $taxonomy); $target = \WP_Term::get_instance($translatedId, $taxonomy); if (!$source instanceof \WP_Term || !$target instanceof \WP_Term) { throw new RuntimeException('An attribute translation term no longer exists.'); } $sourceGroup = $this->termTranslationGroup($sourceId, $taxonomy); $targetGroup = $this->termTranslationGroup($translatedId, $taxonomy); if (!$translatedCreatedNow && $targetGroup !== '' && $targetGroup !== $sourceGroup) { throw new RuntimeException('An existing attribute option belongs to a different WPML translation group.'); } $type = 'tax_' . $taxonomy; $trid = apply_filters('wpml_element_trid', null, $source->term_taxonomy_id, $type); do_action('wpml_set_element_language_details', [ 'element_id' => $target->term_taxonomy_id, 'element_type' => $type, 'trid' => $trid, 'language_code' => $language, 'source_language_code' => $this->termLanguage($sourceId, $taxonomy), 'check_duplicates' => true, ]); $linked = $this->termTranslationMap($sourceId, $taxonomy); if (($linked[$language] ?? 0) !== $translatedId) { throw new RuntimeException('WPML did not record the requested attribute-option translation.'); } $this->linkedTerms[$key] = true; } /** * Ask WPML to duplicate a post into one language, however this store can. * * There is no single duplication call every WPML install answers, and * `do_action` on a hook nobody registered creates nothing and reports * nothing. So each way is tried in turn, most exact first, and the result * is checked after each: a store that can duplicate into one language is * never asked to duplicate into all of them. */ private function duplicateInto(int $sourceId, string $language): void { if (has_action('wpml_make_duplicate') !== false) { do_action('wpml_make_duplicate', $sourceId, $language); if ($this->translatedProductId($sourceId, $language)) { return; } } $sitepress = $GLOBALS['sitepress'] ?? null; if (is_object($sitepress) && method_exists($sitepress, 'make_duplicate')) { $sitepress->make_duplicate($sourceId, $language); if ($this->translatedProductId($sourceId, $language)) { return; } } // The last one duplicates into every active language rather than the // one asked for. It is what the WPML admin screen itself does, and on // a store with no other way it is the difference between publishing // and not. if (has_action('wpml_admin_make_post_duplicates') !== false) { do_action('wpml_admin_make_post_duplicates', $sourceId); } } /** @return array */ private function duplicationMethods(): array { $sitepress = $GLOBALS['sitepress'] ?? null; return [ 'wpml_make_duplicate' => has_action('wpml_make_duplicate') !== false, 'sitepress_make_duplicate' => is_object($sitepress) && method_exists($sitepress, 'make_duplicate'), 'wpml_admin_make_post_duplicates' => has_action('wpml_admin_make_post_duplicates') !== false, ]; } private function duplicationMethodsInWords(): string { $words = []; foreach ($this->duplicationMethods() as $name => $available) { $words[] = $name . '=' . ($available ? 'available' : 'absent'); } return implode(', ', $words); } public function productIdBySku(string $sku): int { $productId = wc_get_product_id_by_sku($sku); if ($productId <= 0) { return 0; } return $this->originalProductId($productId); } public function diagnostics(): array { $languages = $this->activeLanguages(); return [ 'adapter' => $this->name(), 'installed' => $this->available(), 'product_translation_available' => $this->canPublishProducts(), 'product_duplication_available' => $this->canPublishProducts(), 'default_language' => $this->defaultLanguage(), 'default_locale' => $this->defaultLocale(), 'active_languages' => array_keys($languages), // Which duplication calls this store actually answers. A // do_action on a hook nobody registered succeeds silently and // creates nothing, which is indistinguishable from a duplication // that ran and failed unless the store is asked directly. 'duplication_hooks' => $this->duplicationMethods(), 'message' => $this->available() && !$this->canPublishProducts() ? 'Complete WPML setup and activate WooCommerce Multilingual and WPML String Translation before publishing translations.' : '', ]; } /** @return array> */ private function activeLanguages(): array { if (!$this->available()) { return []; } $languages = apply_filters('wpml_active_languages', null, ['skip_missing' => 0]); return is_array($languages) ? $languages : []; } }