File: /var/www/indoadvisory/wp/wp-content/plugins/polylang-wc/include/products.php
<?php
/**
* @package Polylang-WC
*/
/**
* Manages the products (mainly the synchronization of data).
*
* @since 1.3
*/
class PLLWC_Products {
/**
* Product language data store.
*
* @var PLLWC_Product_Language_CPT
*/
protected $data_store;
/**
* Temporarily stores data related to a product currently being edited.
*
* @see PLLWC_Products::store_product_data()
* @see PLLWC_Products::get_terms_args()
*
* @var array {
* @type string|false $lang The product's language.
* @type string[] $taxonomies Attribute taxonomies.
* }
*/
private $product_data = array(
'lang' => false,
'taxonomies' => array(),
);
/**
* Constructor.
*
* @since 0.1
*/
public function __construct() {
$this->data_store = PLLWC_Data_Store::load( 'product_language' );
// Variations synchronization.
add_action( 'add_meta_boxes', array( $this, 'add_meta_boxes' ), 5, 2 );
add_action( 'woocommerce_update_product', array( $this, 'save_product' ) );
add_action( 'woocommerce_new_product_variation', array( $this, 'save_variation' ) );
add_action( 'woocommerce_update_product_variation', array( $this, 'save_variation' ) );
add_action( 'woocommerce_after_product_object_save', array( $this, 'copy_product' ) );
// Filters `get_terms()` in `LookupDataStore::get_term_ids_by_slug_cache()`.
add_action( 'woocommerce_before_product_object_save', array( $this, 'store_product_data' ) );
add_filter( 'get_terms_args', array( $this, 'get_terms_args' ), 20 ); // After Polylang and Polylang Pro.
add_action( 'woocommerce_after_product_object_save', array( $this, 'reset_product_data' ), 5 ); // Before copy_product(), not mandatory though.
add_action( 'woocommerce_run_product_attribute_lookup_update_callback', array( $this, 'store_product_data' ), 1 ); // To set the language before WC processes the product.
add_action( 'pll_post_synchronized', array( $this, 'synchronize_product' ), 10, 3 );
add_action( 'pll_created_sync_post', array( $this, 'copy_variations' ), 5, 3 );
// Variations deletion.
add_action( 'woocommerce_before_delete_product_variation', array( $this, 'delete_variation' ) );
// Unique product ID.
add_filter( 'wc_product_has_global_unique_id', array( $this, 'unique_id' ), 10, 3 );
// Unique SKU.
add_filter( 'wc_product_has_unique_sku', array( $this, 'unique_id' ), 10, 3 );
add_filter( 'wc_product_pre_lock_on_sku', array( $this, 'filter_lock_on_sku' ), 10, 2 );
// On sale products block.
add_filter( 'pll_filter_query_excluded_query_vars', array( $this, 'fix_on_sale_products_block_query' ), 10, 2 );
}
/**
* Copy (create) or synchronize a variation.
*
* @since 1.0
*
* @param int $id Source variation product id.
* @param int $tr_parent Target variable product id.
* @param string $lang Target language.
* @return void
*
* @phpstan-param non-empty-string $lang
*/
protected function copy_variation( $id, $tr_parent, $lang ) {
static $avoid_recursion = false;
static $avoid_double = array();
static $avoid_reverse = array();
if ( $avoid_recursion || ! empty( $avoid_double[ "$id|$tr_parent" ] ) || ! empty( $avoid_reverse[ $id ] ) ) {
return;
}
$avoid_double[ "$id|$tr_parent" ] = true;
$tr_id = $this->data_store->get( $id, $lang );
if ( $tr_id === $id ) {
return;
}
$variation = wc_get_product( $id );
if ( empty( $variation ) ) {
return;
}
if ( ! $tr_id ) {
// If the product variation is untranslated, attempt to find a translation based on the attribute.
$tr_product = wc_get_product( $tr_parent );
if ( $tr_product instanceof WC_Product_Variable ) {
$tr_attributes = $tr_product->get_variation_attributes();
if ( ! empty( $tr_attributes ) ) {
// At least one translated variation was manually created.
$attributes = $variation->get_attributes();
if ( ! in_array( '', $attributes ) ) {
$attributes = $this->maybe_translate_attributes( $attributes, $lang );
foreach ( $tr_product->get_children() as $_tr_id ) {
$tr_variation = wc_get_product( $_tr_id );
if ( $tr_variation && $attributes === $tr_variation->get_attributes() && empty( $this->data_store->get( $tr_variation->get_id(), $this->data_store->get_language( $id ) ) ) ) {
$tr_id = $tr_variation->get_id();
break;
}
}
}
}
}
if ( ! $tr_id ) {
// Creates the translated product variation if it does not exist yet.
$avoid_recursion = true;
$tr_variation = new WC_Product_Variation();
$tr_variation->set_parent_id( $tr_parent );
$tr_id = $this->copy_product_props( $variation, $tr_variation, $lang );
$avoid_recursion = false;
}
$this->data_store->copy( $id, $tr_id, $lang );
$this->data_store->set_language( $tr_id, $lang );
$translations = $this->data_store->get_translations( $id );
$translations[ $this->data_store->get_language( $id ) ] = $id; // In case this is the first translation created.
$translations[ $lang ] = $tr_id;
$this->data_store->save_translations( $translations );
} else {
// Make sure the parent product is correct. Fixes edge case reported in #153.
$tr_variation = new WC_Product_Variation( $tr_id );
if ( $tr_variation->get_parent_id() !== $tr_parent ) {
$avoid_recursion = true;
$tr_variation->set_parent_id( $tr_parent );
$tr_id = $tr_variation->save();
$avoid_recursion = false;
}
// Synchronize.
$this->data_store->copy( $id, $tr_id, $lang, true );
}
$avoid_reverse[ $tr_id ] = true;
}
/**
* Copy or synchronize variations.
*
* @since 0.1
*
* @param int $from Product id from which we copy information.
* @param int $to Product id to which we paste information.
* @param string $lang Language code.
* @return void
*
* @phpstan-param non-empty-string $lang
*/
public function copy_variations( $from, $to, $lang ) {
$product = wc_get_product( $from );
if ( $product instanceof WC_Product_Variable ) {
$language = $this->data_store->get_language( $from );
if ( $language ) {
$variations = $product->get_children(); // Note: it does not return disabled variations in WC < 3.3.
remove_action( 'woocommerce_new_product_variation', array( $this, 'save_variation' ) ); // Avoid reverse sync.
foreach ( $variations as $id ) {
$this->data_store->set_language( $id, $language );
$this->copy_variation( $id, $to, $lang );
}
add_action( 'woocommerce_new_product_variation', array( $this, 'save_variation' ) );
}
}
}
/**
* Copy variations and metas when using "Add new" ( translation )
* Hooked to the action 'add_meta_boxes'.
*
* @since 0.1
*
* @param string $post_type Post type.
* @param WP_Post $post Current post object.
* @return void
*/
public function add_meta_boxes( $post_type, $post ) {
if ( 'post-new.php' === $GLOBALS['pagenow'] && isset( $_GET['from_post'], $_GET['new_lang'] ) && 'product' === $post_type ) {
check_admin_referer( 'new-post-translation' );
// Capability check already done in post-new.php.
$lang = PLL()->model->get_language( sanitize_key( $_GET['new_lang'] ) ); // Make sure we have a valid language.
if ( $lang ) {
$this->copy_variations( (int) $_GET['from_post'], $post->ID, $lang->slug );
/**
* Fires after metas and variations have been copied from a product to a translation.
*
* @since 0.5
*
* @param int $from Original product ID.
* @param int $to Target product ID.
* @param string $lang Language code of the target product.
* @param bool $sync True when synchronizing products, empty when creating a new translation.
*/
do_action( 'pllwc_copy_product', (int) $_GET['from_post'], $post->ID, $lang->slug );
}
}
}
/**
* Fires an action that can be used to synchronize data when a product is saved.
* Hooked to the action 'woocommerce_update_product'.
*
* @since 1.0
*
* @param int $id Product ID.
* @return void
*/
public function save_product( $id ) {
$translations = $this->data_store->get_translations( $id );
foreach ( $translations as $lang => $tr_id ) {
if ( $id !== $tr_id ) {
// It's useless to copy variations if we already did it by saving variations before.
if ( ! did_action( 'woocommerce_update_product_variation' ) && ! did_action( 'woocommerce_new_product_variation' ) ) {
$this->copy_variations( $id, $tr_id, $lang );
}
/** This action is documented in admin/admin-products.php */
do_action( 'pllwc_copy_product', $id, $tr_id, $lang, true );
}
}
}
/**
* Sets the variation language and synchronizes it with its translations.
* Hooked to the action 'woocommerce_new_product_variation' and 'woocommerce_update_product_variation'.
*
* @since 1.0
*
* @param int $id Variation product id.
* @return void
*/
public function save_variation( $id ) {
static $avoid_recursion = false;
if ( ! doing_action( 'woocommerce_product_duplicate' ) && ! doing_action( 'wp_ajax_woocommerce_do_ajax_product_import' ) && ! $avoid_recursion ) {
$avoid_recursion = true;
if ( $variation = wc_get_product( $id ) ) {
$pid = $variation->get_parent_id();
$language = $this->data_store->get_language( $pid );
if ( $language ) {
$this->data_store->set_language( $id, $language );
foreach ( $this->data_store->get_translations( $pid ) as $lang => $tr_pid ) {
if ( $tr_pid !== $pid ) {
$this->copy_variation( $id, $tr_pid, $lang );
}
}
}
}
}
$avoid_recursion = false;
}
/**
* Synchronizes variations deletion.
* Hooked to the action 'woocommerce_before_delete_product_variation'.
*
* @since 1.0
*
* @param int $id Variation product id.
* @return void
*/
public function delete_variation( $id ) {
static $avoid_delete = array();
if ( ! doing_action( 'delete_post' ) && ! in_array( $id, $avoid_delete ) ) {
$tr_ids = $this->data_store->get_translations( $id );
$avoid_delete = array_merge( $avoid_delete, array_values( $tr_ids ) ); // To avoid deleting a variation two times.
foreach ( $tr_ids as $tr_id ) {
if ( $variation = wc_get_product( $tr_id ) ) {
$variation->delete( true );
}
}
}
}
/**
* Checks whether two products are synchronized.
*
* @since 1.2
*
* @param int $id ID of the first product to compare.
* @param int $other_id ID of the second product to compare.
* @return bool
*/
protected static function are_synchronized( $id, $other_id ) {
return isset( PLL()->sync_post->sync_model ) && PLL()->sync_post->sync_model->are_synchronized( $id, $other_id );
}
/**
* Determines whether texts should be copied depending on duplicate and synchronization options.
*
* @since 1.0
*
* @param int $from Product id from which we copy information.
* @param int $to Product id which we paste information.
* @param bool $sync True if it is synchronization, false if it is a copy.
* @return bool
*/
public static function should_copy_texts( $from, $to, $sync ) {
if ( ! $sync ) {
$duplicate_options = get_user_meta( get_current_user_id(), 'pll_duplicate_content', true );
if ( ! empty( $duplicate_options ) && ! empty( $duplicate_options['product'] ) ) {
return true;
}
}
if ( isset( PLL()->sync_post ) ) {
$from = wc_get_product( $from );
$to = wc_get_product( $to );
if ( ! empty( $from ) && ! empty( $to ) ) {
if ( 'variation' === $from->get_type() ) {
return self::are_synchronized( $from->get_parent_id(), $to->get_parent_id() );
} else {
return self::are_synchronized( $from->get_id(), $to->get_id() );
}
}
}
return false;
}
/**
* Maybe translates a product property.
*
* @since 1.0
* @since 1.8 Type-hinted parameters `$prop` and `$lang`.
*
* @param mixed $value Property value.
* @param string $prop Property name.
* @param string $lang Language code.
* @return mixed Property value, possibly translated.
*/
public static function maybe_translate_property( $value, string $prop, string $lang ) {
$tr_value = $value;
switch ( $prop ) {
case 'image_id':
if ( ! PLL()->options['media_support'] || empty( $value ) ) {
break;
}
$tr_value = pll_get_post( $value, $lang );
if ( empty( $tr_value ) ) {
// Backward compatibility with Polylang < 3.7.
if ( method_exists( PLL()->model->post, 'create_media_translation' ) ) {
$tr_value = PLL()->model->post->create_media_translation( $value, $lang );
} else {
$tr_value = PLL()->posts->create_media_translation( $value, $lang );
}
}
break;
case 'gallery_image_ids':
if ( ! PLL()->options['media_support'] ) {
break;
}
$tr_value = array();
foreach ( array_map( 'absint', explode( ',', $value ) ) as $post_id ) {
if ( empty( $post_id ) ) {
continue;
}
$tr_id = pll_get_post( $post_id, $lang );
if ( empty( $tr_id ) ) {
// Backward compatibility with Polylang < 3.7.
if ( method_exists( PLL()->model->post, 'create_media_translation' ) ) {
$tr_id = PLL()->model->post->create_media_translation( $post_id, $lang );
} else {
$tr_id = PLL()->posts->create_media_translation( $post_id, $lang );
}
}
$tr_value[] = $tr_id;
}
$tr_value = implode( ',', $tr_value );
break;
case 'children':
case 'upsell_ids':
case 'cross_sell_ids':
/** @var PLLWC_Product_Language_CPT */
$data_store = PLLWC_Data_Store::load( 'product_language' );
$tr_value = array();
foreach ( $value as $id ) {
$tr_id = $data_store->get( $id, $lang );
if ( empty( $tr_id ) ) {
continue;
}
$tr_value[] = $tr_id;
}
break;
case 'default_attributes':
case 'attributes':
if ( is_array( $value ) ) {
$tr_value = self::maybe_translate_attributes( $value, $lang );
}
break;
}
/**
* Filter a property value before it is copied or synchronized
* but after it has been maybe translated.
*
* @since 1.0
*
* @param mixed $value Product property value.
* @param string $prop Product property name.
* @param string $lang Language code.
*/
return apply_filters( 'pllwc_translate_product_prop', $tr_value, $prop, $lang );
}
/**
* Translates taxonomy attributes.
*
* @since 1.0
* @since 1.8 Now public and static.
* @since 1.8 Accepts both an array of attributes terms slugs and `WC_Product_Attribute` objects.
*
* @param array $attributes Product attributes. Could be pairs of attribute taxonomies and term slugs (from `WC_Product_Variation`)
* or a list of `WC_Product_Attribute` objects (from other kind of `WC_Product`).
* @param string $lang Language code.
* @return array Array of translated attributes with preserved keys.
*
* @phpstan-param array<string|WC_Product_Attribute> $attributes
* @phpstan-return array<string|WC_Product_Attribute>
*/
public static function maybe_translate_attributes( $attributes, $lang ) {
foreach ( $attributes as $k => $v ) {
if ( ! pll_is_translated_taxonomy( $k ) ) {
continue;
}
switch ( gettype( $v ) ) {
/*
* Current attribute refers to `WC_Product_Variation::$data['attributes']`.
* See: {https://github.com/woocommerce/woocommerce/blob/7.3.0/plugins/woocommerce/includes/class-wc-product-variation.php#L505-L523}
*/
case 'string':
$attributes_taxonomies = wc_get_attribute_taxonomy_names();
if ( ! in_array( $k, $attributes_taxonomies ) ) {
break;
}
if ( '' === $v ) { // Correspond to attribute value "Any".
$attributes[ $k ] = '';
break;
}
$terms = get_terms( array( 'taxonomy' => $k, 'slug' => $v, 'hide_empty' => false, 'lang' => '' ) ); // Don't use get_term_by filtered by language since WP 4.7.
if ( ! is_array( $terms ) ) {
break;
}
$term = reset( $terms );
if ( ! $term instanceof WP_Term ) {
break;
}
$tr_id = pll_get_term( $term->term_id, $lang );
if ( empty( $tr_id ) ) {
break;
}
$tr_term = get_term( $tr_id, $k );
if ( ! $tr_term instanceof WP_Term ) {
break;
}
$attributes[ $k ] = $tr_term->slug;
break;
/*
* Current attribute refers to `WC_Product::$data['attributes']`.
* See: {https://github.com/woocommerce/woocommerce/blob/7.3.0/plugins/woocommerce/includes/abstracts/abstract-wc-product.php#L1095-L1120}
*/
case 'object':
if ( ! $v instanceof WC_Product_Attribute || ! $v->is_taxonomy() ) {
break;
}
$terms = $v->get_terms();
if ( empty( $terms ) ) {
break;
}
$tr_ids = array();
foreach ( $terms as $term ) {
$tr_id = pll_get_term( $term->term_id, $lang );
if ( empty( $tr_id ) ) {
continue;
}
$tr_ids[] = $tr_id;
}
$v = clone $v; // Avoid original attribute to be modified.
$v->set_options( $tr_ids );
$attributes[ $k ] = $v;
break;
}
}
return $attributes;
}
/**
* Checks if a product SKU or global unique ID is unique for its language.
*
* Hooked to `wc_product_has_unique_sku` and `wc_product_has_global_unique_id`.
* Indirectly hooked to `wc_product_pre_lock_on_sku`.
*
* @since 2.1
*
* @param bool $id_found True if the ID is already associated to an existing product, false otherwise.
* @param int $product_id Product ID.
* @param string $id_value Product SKU or global unique ID.
* @return bool
*/
public function unique_id( $id_found, $product_id, $id_value ) {
$current_filter = substr( current_filter(), 15 ); // Can be unique_sku, lock_on_sku or global_unique_id.
if ( $id_found ) {
$language = PLL()->model->get_language( $this->data_store->get_language( $product_id ) );
/**
* Filters the language used to filter `wc_product_has_unique_sku` and `wc_product_has_global_unique_id`.
*
* @since 0.9
* @since 2.1 The filter is now dynamic to handle SKU and global unique ID.
*
* @param PLL_Language|false $language Language object, `false` if the product has no language.
* @param int $product_id Product ID.
*/
$language = apply_filters( "pllwc_language_for_{$current_filter}", $language, $product_id );
if ( $language instanceof PLL_Language ) {
$column = in_array( $current_filter, array( 'unique_sku', 'lock_on_sku' ) ) ? 'sku' : $current_filter;
return $this->data_store->lookup_table_has( $column, $id_value, $product_id, $language );
}
}
return $id_found;
}
/**
* Locks a SKU according to product languages.
*
* @since 2.1
*
* @param bool|null $locked Whether the SKU is locked or not, `null` if not processed at all.
* @param WC_Product $product Product of the SKU to lock.
* @return bool|null Whether the SKU is locked or not, `null` if not bypassed.
*/
public function filter_lock_on_sku( $locked, $product ) {
if ( null === $locked && $product instanceof WC_Product ) {
return ! $this->unique_id( true, $product->get_id(), $product->get_sku() );
}
return $locked;
}
/**
* Make sure that the on sale products block is filtered by the current language.
*
* @since 1.3
*
* @param string[] $excludes Query vars excluded from the language filter.
* @param WP_Query $query WP Query object.
* @return string[]
*/
public function fix_on_sale_products_block_query( $excludes, $query ) {
$q = &$query->query;
if ( isset( $q['post_type'], $q['post__in'] ) && 'product' === $q['post_type'] && array_merge( array( 0 ), wc_get_product_ids_on_sale() ) === $q['post__in'] ) {
$excludes = array_diff( $excludes, array( 'post__in' ) );
}
return $excludes;
}
/**
* Temporarily stores data related to a product currently being saved.
* The aim is to use this data to identify the corresponding arguments of the `get_terms()` used in
* `\Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore::get_term_ids_by_slug_cache()`, and add
* the product's language to it.
* Hooked to `woocommerce_before_product_object_save`.
*
* @since 1.8
* @see PLLWC_Products::get_terms_args()
*
* @param WC_Product|int $product A product being saved.
* @return void
*/
public function store_product_data( $product ) {
if ( ! $product instanceof WC_Product ) {
$product = wc_get_product( $product );
}
if ( ! $product instanceof WC_Product ) {
return;
}
$lang = $this->data_store->get_language( $product->get_id() );
if ( empty( $lang ) ) {
$lang = $this->data_store->get_language( $product->get_parent_id() );
if ( empty( $lang ) ) {
$this->reset_product_data();
return;
}
}
$attributes = $product->get_attributes();
if ( empty( $attributes ) ) {
$this->reset_product_data();
return;
}
$this->product_data = array(
'lang' => $lang,
'taxonomies' => array_map( 'wc_sanitize_taxonomy_name', array_keys( $attributes ) ),
);
}
/**
* Filters the product attributes per language.
* The target is the `get_terms()` used in `LookupDataStore::get_term_ids_by_slug_cache()`.
* Hooked to `get_terms_args`.
*
* @since 1.8
* @see \Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore::get_term_ids_by_slug_cache()
*
* @param array $args Arguments passed to WP_Term_Query.
* @return array Modified arguments.
*/
public function get_terms_args( $args ) {
if ( empty( $this->product_data['lang'] ) ) {
// No language to add.
return $args;
}
if ( empty( $args['fields'] ) || 'id=>slug' !== $args['fields'] ) {
// Not the arguments we're looking for.
return $args;
}
if ( empty( $args['taxonomy'] ) || ! is_array( $args['taxonomy'] ) || 1 !== count( $args['taxonomy'] ) ) {
// Not the arguments we're looking for.
return $args;
}
if ( 0 !== strpos( reset( $args['taxonomy'] ), 'pa_' ) ) {
// Not the taxonomy we're looking for.
return $args;
}
if ( empty( array_intersect( $this->product_data['taxonomies'], $args['taxonomy'] ) ) ) {
// Not the arguments we're looking for.
return $args;
}
$args['lang'] = $this->product_data['lang'];
return $args;
}
/**
* Resets the data related to a product after it has been saved.
* Hooked to `woocommerce_after_product_object_save`.
*
* @since 1.8
* @see PLLWC_Products::store_product_data()
*
* @return void
*/
public function reset_product_data() {
$this->product_data = array(
'lang' => false,
'taxonomies' => array(),
);
}
/**
* Synchronizes product properties through all its translations.
* The goal is also to trigger the product attributes lookup table update.
* See https://github.com/woocommerce/woocommerce/blob/7.4.1/plugins/woocommerce/includes/abstracts/abstract-wc-product.php#L1428-L1431
*
* @since 1.8
*
* @param WC_Product $product The product that has been just saved.
* @return void
*/
public function copy_product( $product ) {
static $avoid_recursion = false;
if ( did_action( 'woocommerce_variable_product_sync_data' ) ) {
return;
}
if ( $avoid_recursion ) {
return;
}
$avoid_recursion = true;
$translations = $this->data_store->get_translations( $product->get_id() );
foreach ( $translations as $lang => $translation ) {
if ( $product->get_id() === $translation ) {
continue;
}
$translated_product = wc_get_product( $translation );
if ( empty( $translated_product ) ) {
continue;
}
$this->copy_product_props( $product, $translated_product, $lang );
}
$avoid_recursion = false;
}
/**
* Copies product properties in its translation after it is duplicated or synchronized and save changes.
*
* @since 1.8
*
* @param WC_Product $from The product we copy information from.
* @param WC_Product $to The target product.
* @param string $lang Language of the target product.
* @return int The id of the target product.
*/
public function copy_product_props( $from, $to, $lang ) {
$to->set_attributes( $this->maybe_translate_attributes( $from->get_attributes(), $lang ) );
$to->set_stock_status( $from->get_stock_status() );
// Synchronizes the status for the variations.
if ( $to instanceof WC_Product_Variation ) {
$to->set_status( $from->get_status() );
}
if ( ! empty( $to->get_changes() ) ) {
$to->save();
}
return $to->get_id();
}
/**
* Synchronizes product properties in its translation after it is duplicated or synchronized.
*
* @since 1.8
*
* @param int $from Id of the product from which we copy information.
* @param int $to Id of the target.
* @param string $lang Language of the target post.
* @return void
*/
public function synchronize_product( $from, $to, $lang ) {
$product = wc_get_product( $from );
$target_product = wc_get_product( $to );
if ( empty( $product ) || empty( $target_product ) ) {
return;
}
$this->copy_product_props( $product, $target_product, $lang );
}
}