termTranslationMap($sourceId, $taxonomy); $map[$this->termLanguage($sourceId, $taxonomy)] = $sourceId; $map[$this->termLanguage($translatedId, $taxonomy)] = $translatedId; pll_save_term_translations($map); } } public function name(): string { return 'Polylang'; } public function available(): bool { return function_exists('pll_default_language'); } public function canPublishProducts(): bool { if (!$this->available()) { return false; } // This adapter makes the duplicate itself, so the paid WooCommerce // add-on is not required. What Polylang must give is a translatable // product post type; without it a duplicate gets no language. return $this->translatesProducts(); } private function translatesProducts(): bool { return function_exists('pll_is_translated_post_type') && (bool) pll_is_translated_post_type('product'); } private function hasWooCommerceAddon(): bool { return defined('PLLWC_VERSION') || class_exists('PLLWC'); } public function defaultLocale(): string { if (!$this->available()) { return determine_locale(); } return (string) (pll_default_language('locale') ?: determine_locale()); } public function defaultLanguage(): string { return $this->available() ? (string) pll_default_language('slug') : ''; } public function productLanguage(int $productId): string { return $this->available() && function_exists('pll_get_post_language') ? (string) pll_get_post_language($productId, 'slug') : ''; } public function termLanguage(int $termId, string $taxonomy): string { unset($taxonomy); return $this->available() && function_exists('pll_get_term_language') ? (string) pll_get_term_language($termId, 'slug') : ''; } public function supportsLanguage(string $language): bool { if (!$this->available()) { return false; } $languages = function_exists('pll_languages_list') ? pll_languages_list(['fields' => 'slug']) : []; return in_array($language, $languages, true); } public function switchLanguage(string $language): string { $previous = function_exists('pll_current_language') ? (string) pll_current_language() : ''; $language = sanitize_key($language); if ($language === '' || !function_exists('PLL')) { return $previous; } $pll = PLL(); if (!is_object($pll) || !isset($pll->model) || !is_object($pll->model)) { return $previous; } $lang = $pll->model->get_language($language); if ($lang) { $pll->curlang = $lang; } return $previous; } public function restoreLanguage(string $previousLanguage): void { if ($previousLanguage !== '') { $this->switchLanguage($previousLanguage); } } public function assignProductLanguage(int $productId, string $language): void { if ($this->available() && function_exists('pll_set_post_language')) { pll_set_post_language($productId, $language); } } public function assignTermLanguage(int $termId, string $taxonomy, string $language): void { unset($taxonomy); if ($this->available() && function_exists('pll_set_term_language')) { pll_set_term_language($termId, $language); } } 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]; } if (!function_exists('pll_get_term')) { return null; } $translated = pll_get_term($termId, $language); return $translated ? (int) $translated : null; } public function termTranslationMap(int $termId, string $taxonomy): array { unset($taxonomy); if (!$this->available() || !function_exists('pll_get_term_translations')) { return []; } $map = []; foreach (pll_get_term_translations($termId) as $language => $translatedId) { $code = sanitize_key((string) $language); if ($code !== '') { $map[$code] = (int) $translatedId; } } return $map; } public function termTranslationGroup(int $termId, string $taxonomy): string { $map = $this->termTranslationMap($termId, $taxonomy); if ($map === []) { return ''; } sort($map, SORT_NUMERIC); return 'polylang:' . implode(',', array_map('intval', $map)); } public function originalProductId(int $productId): int { $default = $this->defaultLanguage(); if ($default && function_exists('pll_get_post')) { $original = pll_get_post($productId, $default); return $original ? (int) $original : $productId; } 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]; } if (!function_exists('pll_get_post')) { return null; } $translated = pll_get_post($productId, $language); return $translated ? (int) $translated : null; } public function translationMap(int $productId): array { if (!$this->available() || !function_exists('pll_get_post_translations')) { return []; } $map = []; foreach (pll_get_post_translations($productId) as $language => $translatedId) { $code = sanitize_key((string) $language); if ($code !== '') { $map[$code] = (int) $translatedId; } } 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 Polylang.'); } $existing = $this->translatedProductId($sourceId, $language); if ($existing && $existing !== $sourceId) { return $existing; } if (!$this->canPublishProducts()) { throw new RuntimeException('Polylang must translate the product post type before a translation can be published.'); } $duplicated = $this->duplicateProduct($sourceId, $language); $this->linkTranslation($sourceId, $duplicated, $language); $this->finishProduct($sourceId, $duplicated); $found = $this->translatedProductId($sourceId, $language); if (!$found || $found === $sourceId) { throw new RuntimeException('Polylang did not record the new product translation.'); } return (int) $found; } public function linkProducts(int $sourceId, array $translations): void { if (!function_exists('pll_save_post_translations')) { throw new RuntimeException('Polylang cannot link product translations on this store.'); } $map = $this->translationMap($sourceId); $sourceLanguage = $this->productLanguage($sourceId); if ($sourceLanguage !== '') { $map[$sourceLanguage] = $sourceId; } foreach ($translations as $language => $productId) { $language = sanitize_key((string) $language); if ($language !== '' && (int) $productId > 0) { $map[$language] = (int) $productId; } } pll_save_post_translations($map); } /** * Save the finished copy so WooCommerce rebuilds what it derives from it: * the product lookup row and, for a variable product, the price range. * * The type is named here instead of read back. wc_get_product() decides the * class from a cached product_type term, and a stale read builds a simple * product whose save writes 'simple' over the copied 'variable' term. */ private function finishProduct(int $sourceId, int $duplicateId): void { $type = $this->productType($sourceId); wp_set_object_terms($duplicateId, $type, 'product_type'); clean_post_cache($duplicateId); clean_object_term_cache($duplicateId, 'product'); $classname = \WC_Product_Factory::get_product_classname($duplicateId, $type); /** @var \WC_Product $product */ $product = new $classname($duplicateId); $product->save(); } private function productType(int $productId): string { $terms = wp_get_object_terms($productId, 'product_type', ['fields' => 'slugs']); if (is_wp_error($terms) || !$terms) { return 'simple'; } return (string) $terms[0]; } /** Copy one product post, its meta, its terms and its variations. */ private function duplicateProduct(int $sourceId, string $language): int { $source = get_post($sourceId); if (!$source) { throw new RuntimeException('The product to duplicate does not exist.'); } $duplicated = $this->copyPost($source, $language, 0); $this->copyMeta($sourceId, $duplicated); $this->copyTerms($sourceId, $duplicated, $language); foreach ($this->variationIds($sourceId) as $variationId) { $variation = get_post($variationId); if (!$variation) { continue; } $copy = $this->copyPost($variation, $language, $duplicated); $this->copyMeta($variationId, $copy); } return $duplicated; } /** @return array */ private function variationIds(int $productId): array { $product = wc_get_product($productId); if (!$product || !$product->is_type('variable')) { return []; } return array_map('intval', $product->get_children()); } private function copyPost(\WP_Post $source, string $language, int $parentId): int { $slug = $source->post_name !== '' ? $source->post_name . '-' . $language : ''; $created = wp_insert_post([ 'post_author' => $source->post_author, 'post_content' => $source->post_content, 'post_excerpt' => $source->post_excerpt, 'post_title' => $source->post_title, 'post_status' => $source->post_status, 'post_type' => $source->post_type, 'post_parent' => $parentId, 'post_name' => $slug, 'menu_order' => $source->menu_order, 'comment_status' => $source->comment_status, 'ping_status' => $source->ping_status, ], true); if (is_wp_error($created) || (int) $created <= 0) { $reason = is_wp_error($created) ? $created->get_error_message() : 'unknown error'; throw new RuntimeException('WordPress could not duplicate the product: ' . $reason); } $created = (int) $created; // A variation is not a translated post type, so only the product copy // is given a language of its own. if ($source->post_type === 'product' && function_exists('pll_set_post_language')) { pll_set_post_language($created, $language); } return $created; } private function copyMeta(int $sourceId, int $targetId): void { // The edit locks belong to a session and the old slugs belong to the // source history. Nothing else about a product is worth dropping. $skip = [ '_edit_lock', '_edit_last', '_wp_old_slug', '_wp_old_date', ...WooPublisher::TRANSLATION_IDENTITY_META, ]; foreach (get_post_meta($sourceId) as $key => $values) { $key = (string) $key; if (in_array($key, $skip, true)) { continue; } delete_post_meta($targetId, $key); foreach ((array) $values as $value) { add_post_meta($targetId, $key, maybe_unserialize($value)); } } } private function copyTerms(int $sourceId, int $targetId, string $language): void { foreach (get_object_taxonomies('product') as $taxonomy) { if ($taxonomy === 'language' || $taxonomy === 'post_translations') { continue; } $terms = wp_get_object_terms($sourceId, $taxonomy, ['fields' => 'ids']); if (is_wp_error($terms) || !$terms) { continue; } $translated = $this->isTranslatedTaxonomy($taxonomy) ? $this->translateTermIds(array_map('intval', $terms), $taxonomy, $language) : array_map('intval', $terms); if ($translated) { wp_set_object_terms($targetId, $translated, $taxonomy); } } } /** * @param array $termIds * @return array */ private function translateTermIds(array $termIds, string $taxonomy, string $language): array { $translated = []; foreach ($termIds as $termId) { // Keep an untranslated term out of the copy. An English category // name on an Estonian product is worse than no category: the // publisher sets the right term for this language afterwards. $match = $this->translateTerm($termId, $taxonomy, $language); if ($match && $match > 0) { $translated[] = (int) $match; } } return array_values(array_unique($translated)); } private function isTranslatedTaxonomy(string $taxonomy): bool { return function_exists('pll_is_translated_taxonomy') && (bool) pll_is_translated_taxonomy($taxonomy); } private function linkTranslation(int $sourceId, int $duplicateId, string $language): void { if (!function_exists('pll_save_post_translations')) { return; } $map = $this->translationMap($sourceId); $sourceLanguage = $this->productLanguage($sourceId); if ($sourceLanguage !== '') { $map[$sourceLanguage] = $sourceId; } $map[$language] = $duplicateId; pll_save_post_translations(array_map('intval', $map)); } 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->available() && function_exists('pll_languages_list') ? pll_languages_list(['fields' => 'slug']) : []; return [ 'adapter' => $this->name(), 'installed' => $this->available(), 'product_translation_available' => $this->canPublishProducts(), 'product_duplication_available' => $this->canPublishProducts(), 'woocommerce_addon' => $this->hasWooCommerceAddon(), 'default_language' => $this->defaultLanguage(), 'default_locale' => $this->defaultLocale(), 'active_languages' => is_array($languages) ? array_values($languages) : [], 'message' => $this->available() && !$this->canPublishProducts() ? 'Polylang must translate the product post type before a translation can be published.' : '', ]; } }