HEX
Server: LiteSpeed
System: Linux eko108.isimtescil.net 4.18.0-477.21.1.lve.1.el8.x86_64 #1 SMP Tue Sep 5 23:08:35 UTC 2023 x86_64
User: uyarreklamcomtr (11202)
PHP: 7.4.33
Disabled: opcache_get_status
Upload Files
File: /var/www/vhosts/uyarreklam.com.tr/httpdocs/Product.tar
AttributeMapping/AttributeMappingHelper.php000064400000010013151542451210015146 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping;

use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\Adult;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\AgeGroup;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\Brand;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\Color;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\AttributeInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\Condition;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\Gender;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\GTIN;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\IsBundle;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\Material;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\MPN;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\Multipack;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\Pattern;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\Size;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\SizeSystem;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\SizeType;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\WithMappingInterface;

defined( 'ABSPATH' ) || exit;

/**
 * Helper Class for Attribute Mapping
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping
 */
class AttributeMappingHelper implements Service {


	private const ATTRIBUTES_AVAILABLE_FOR_MAPPING = [
		Adult::class,
		AgeGroup::class,
		Brand::class,
		Color::class,
		Condition::class,
		Gender::class,
		GTIN::class,
		IsBundle::class,
		Material::class,
		MPN::class,
		Multipack::class,
		Pattern::class,
		Size::class,
		SizeSystem::class,
		SizeType::class,
	];

	public const CATEGORY_CONDITION_TYPE_ALL    = 'ALL';
	public const CATEGORY_CONDITION_TYPE_ONLY   = 'ONLY';
	public const CATEGORY_CONDITION_TYPE_EXCEPT = 'EXCEPT';

	/**
	 * Gets all the available attributes for mapping
	 *
	 * @return array
	 */
	public function get_attributes(): array {
		$destinations = [];

		/**
		 * @var WithMappingInterface $attribute
		 */
		foreach ( self::ATTRIBUTES_AVAILABLE_FOR_MAPPING as $attribute ) {
			array_push(
				$destinations,
				[
					'id'    => $attribute::get_id(),
					'label' => $attribute::get_name(),
					'enum'  => $attribute::is_enum(),
				]
			);
		}

		return $destinations;
	}

	/**
	 * Get the attribute class based on attribute ID.
	 *
	 * @param string $attribute_id  The attribute ID to get the class
	 * @return string|null The attribute class path or null if it's not found
	 */
	public static function get_attribute_by_id( string $attribute_id ): ?string {
		foreach ( self::ATTRIBUTES_AVAILABLE_FOR_MAPPING as $class ) {
			if ( $class::get_id() === $attribute_id ) {
				return $class;
			}
		}

		return null;
	}

	/**
	 * Get the sources for an attribute
	 *
	 * @param string $attribute_id The attribute ID to get the sources from.
	 * @return array The sources for the attribute
	 */
	public function get_sources_for_attribute( string $attribute_id ): array {
		/**
		 * @var AttributeInterface $attribute
		 */
		$attribute         = self::get_attribute_by_id( $attribute_id );
		$attribute_sources = [];

		if ( is_null( $attribute ) ) {
			return $attribute_sources;
		}

		foreach ( $attribute::get_sources() as $key => $value ) {
			array_push(
				$attribute_sources,
				[
					'id'    => $key,
					'label' => $value,
				]
			);
		}

		return $attribute_sources;
	}

	/**
	 * Get the available conditions for the category.
	 *
	 * @return string[] The list of available category conditions
	 */
	public function get_category_condition_types(): array {
		return [
			self::CATEGORY_CONDITION_TYPE_ALL,
			self::CATEGORY_CONDITION_TYPE_EXCEPT,
			self::CATEGORY_CONDITION_TYPE_ONLY,
		];
	}
}
AttributeMapping/Traits/IsEnumTrait.php000064400000001104151542451210014202 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits;

defined( 'ABSPATH' ) || exit;

/**
 * Trait for enums
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits
 */
trait IsEnumTrait {

	/**
	 * Returns true for the is_enum property
	 *
	 * @return true
	 */
	public static function is_enum(): bool {
		return true;
	}

	/**
	 * Returns the attribute sources
	 *
	 * @return array
	 */
	public static function get_sources(): array {
		return self::get_value_options();
	}
}
AttributeMapping/Traits/IsFieldTrait.php000064400000007572151542451210014340 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits;

defined( 'ABSPATH' ) || exit;

/**
 * Trait for fields
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits
 */
trait IsFieldTrait {

	/**
	 * Returns false for the is_enum property
	 *
	 * @return false
	 */
	public static function is_enum(): bool {
		return false;
	}

	/**
	 * Returns the attribute sources
	 *
	 * @return array The available sources
	 */
	public static function get_sources(): array {
		return apply_filters(
			'woocommerce_gla_attribute_mapping_sources',
			array_merge(
				self::get_source_product_fields(),
				self::get_source_taxonomies(),
				self::get_source_custom_attributes()
			),
			self::get_id()
		);
	}

	/**
	 * Gets the taxonomies and global attributes to render them as options in the frontend.
	 *
	 * @return array An array with the taxonomies and global attributes
	 */
	public static function get_source_taxonomies(): array {
		$object_taxonomies = get_object_taxonomies( 'product', 'objects' );
		$taxonomies        = [];
		$attributes        = [];
		$sources           = [];

		foreach ( $object_taxonomies as $taxonomy ) {
			if ( taxonomy_is_product_attribute( $taxonomy->name ) ) {
				$attributes[ 'taxonomy:' . $taxonomy->name ] = $taxonomy->label;
				continue;
			}

			$taxonomies[ 'taxonomy:' . $taxonomy->name ] = $taxonomy->label;
		}

		asort( $taxonomies );
		asort( $attributes );

		$attributes = apply_filters( 'woocommerce_gla_attribute_mapping_sources_global_attributes', $attributes );
		$taxonomies = apply_filters( 'woocommerce_gla_attribute_mapping_sources_taxonomies', $taxonomies );

		if ( ! empty( $attributes ) ) {
			$sources = array_merge(
				[
					'disabled:attributes' => __( '- Global attributes -', 'google-listings-and-ads' ),
				],
				$attributes
			);
		}

		if ( ! empty( $taxonomies ) ) {
			$sources = array_merge(
				$sources,
				[
					'disabled:taxonomies' => __( '- Taxonomies -', 'google-listings-and-ads' ),
				],
				$taxonomies
			);
		}

		return $sources;
	}

	/**
	 * Get a list of the available product sources.
	 *
	 * @return array An array with the available product sources.
	 */
	public static function get_source_product_fields(): array {
		$fields = [
			'product:backorders'       => __( 'Allow backorders setting', 'google-listings-and-ads' ),
			'product:title'            => __( 'Product title', 'google-listings-and-ads' ),
			'product:sku'              => __( 'SKU', 'google-listings-and-ads' ),
			'product:stock_quantity'   => __( 'Stock Qty', 'google-listings-and-ads' ),
			'product:stock_status'     => __( 'Stock Status', 'google-listings-and-ads' ),
			'product:tax_class'        => __( 'Tax class', 'google-listings-and-ads' ),
			'product:name'             => __( 'Variation title', 'google-listings-and-ads' ),
			'product:weight'           => __( 'Weight (raw value, no units)', 'google-listings-and-ads' ),
			'product:weight_with_unit' => __( 'Weight (with units)', 'google-listings-and-ads' ),
		];
		asort( $fields );

		$fields = array_merge(
			[
				'disabled:product' => __( '- Product fields -', 'google-listings-and-ads' ),
			],
			$fields
		);

		return apply_filters( 'woocommerce_gla_attribute_mapping_sources_product_fields', $fields );
	}

	/**
	 * Allowing to register custom attributes by using a filter.
	 *
	 * @return array The custom attributes
	 */
	public static function get_source_custom_attributes(): array {
		$attributes     = [];
		$attribute_keys = apply_filters( 'woocommerce_gla_attribute_mapping_sources_custom_attributes', [] );
		foreach ( $attribute_keys as $key ) {
			$attributes[ 'attribute:' . $key ] = $key;
		}

		if ( ! empty( $attributes ) ) {
			$attributes = array_merge(
				[
					'disabled:attribute' => __( '- Custom Attributes -', 'google-listings-and-ads' ),
				],
				$attributes
			);
		}

		return $attributes;
	}
}
Attributes/AbstractAttribute.php000064400000003562151542451210013040 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;

defined( 'ABSPATH' ) || exit;

/**
 * Class AbstractAttribute
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 */
abstract class AbstractAttribute implements AttributeInterface {

	/**
	 * @var mixed
	 */
	protected $value = null;

	/**
	 * AbstractAttribute constructor.
	 *
	 * @param mixed $value
	 */
	public function __construct( $value = null ) {
		$this->set_value( $value );
	}

	/**
	 * Return the attribute type. Must be a valid PHP type.
	 *
	 * @return string
	 *
	 * @link https://www.php.net/manual/en/function.settype.php
	 */
	public static function get_value_type(): string {
		return 'string';
	}

	/**
	 * Returns the attribute value.
	 *
	 * @return mixed
	 */
	public function get_value() {
		return $this->value;
	}

	/**
	 * @param mixed $value
	 *
	 * @return $this
	 */
	public function set_value( $value ): AbstractAttribute {
		$this->value = $this->cast_value( $value );

		return $this;
	}

	/**
	 * Casts the value to the attribute value type and returns the result.
	 *
	 * @param mixed $value
	 *
	 * @return mixed
	 */
	protected function cast_value( $value ) {
		if ( is_string( $value ) ) {
			$value = trim( $value );

			if ( '' === $value ) {
				return null;
			}
		}

		$value_type = static::get_value_type();
		if ( in_array( $value_type, [ 'bool', 'boolean' ], true ) ) {
			$value = wc_string_to_bool( $value );
		} else {
			settype( $value, $value_type );
		}

		return $value;
	}

	/**
	 * Return an array of WooCommerce product types that this attribute can be applied to.
	 *
	 * @return array
	 */
	public static function get_applicable_product_types(): array {
		return [ 'simple', 'variable', 'variation' ];
	}

	/**
	 * @return string
	 */
	public function __toString() {
		return (string) $this->get_value();
	}
}
Attributes/Adult.php000064400000003630151542451210010456 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\AdultInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsEnumTrait;

defined( 'ABSPATH' ) || exit;

/**
 * Class Adult
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 */
class Adult extends AbstractAttribute implements WithMappingInterface {

	use IsEnumTrait;

	/**
	 * Returns the attribute ID.
	 *
	 * Must be the same as a Google product's property name to be set automatically.
	 *
	 * @return string
	 *
	 * @see \Google\Service\ShoppingContent\Product for the list of properties.
	 */
	public static function get_id(): string {
		return 'adult';
	}

	/**
	 * Return an array of WooCommerce product types that this attribute can be applied to.
	 *
	 * @return array
	 */
	public static function get_applicable_product_types(): array {
		return [ 'simple', 'variable', 'variation' ];
	}

	/**
	 * Return the attribute type. Must be a valid PHP type.
	 *
	 * @return string
	 *
	 * @link https://www.php.net/manual/en/function.settype.php
	 */
	public static function get_value_type(): string {
		return 'boolean';
	}

	/**
	 * Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
	 *
	 * @return string
	 *
	 * @see AttributeInputInterface
	 *
	 * @since 1.5.0
	 */
	public static function get_input_type(): string {
		return AdultInput::class;
	}

	/**
	 * Returns the attribute name
	 *
	 * @return string
	 */
	public static function get_name(): string {
		return __( 'Adult', 'google-listings-and-ads' );
	}

	/**
	 * Returns the attribute sources
	 *
	 * @return array
	 */
	public static function get_sources(): array {
		return [
			'yes' => __( 'Yes', 'google-listings-and-ads' ),
			'no'  => __( 'No', 'google-listings-and-ads' ),
		];
	}
}
Attributes/AgeGroup.php000064400000003760151542451210011122 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\AgeGroupInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsEnumTrait;

defined( 'ABSPATH' ) || exit;

/**
 * Class AgeGroup
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 */
class AgeGroup extends AbstractAttribute implements WithValueOptionsInterface, WithMappingInterface {

	use IsEnumTrait;

	/**
	 * Returns the attribute ID.
	 *
	 * Must be the same as a Google product's property name to be set automatically.
	 *
	 * @return string
	 *
	 * @see \Google\Service\ShoppingContent\Product for the list of properties.
	 */
	public static function get_id(): string {
		return 'ageGroup';
	}

	/**
	 * Return an array of values available to choose for the attribute.
	 *
	 * Note: array key is used as the option key.
	 *
	 * @return array
	 */
	public static function get_value_options(): array {
		return [
			'newborn' => __( 'Newborn', 'google-listings-and-ads' ),
			'infant'  => __( 'Infant', 'google-listings-and-ads' ),
			'toddler' => __( 'Toddler', 'google-listings-and-ads' ),
			'kids'    => __( 'Kids', 'google-listings-and-ads' ),
			'adult'   => __( 'Adult', 'google-listings-and-ads' ),
		];
	}

	/**
	 * Return an array of WooCommerce product types that this attribute can be applied to.
	 *
	 * @return array
	 */
	public static function get_applicable_product_types(): array {
		return [ 'simple', 'variation' ];
	}

	/**
	 * Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
	 *
	 * @return string
	 *
	 * @see AttributeInputInterface
	 *
	 * @since 1.5.0
	 */
	public static function get_input_type(): string {
		return AgeGroupInput::class;
	}


	/**
	 * Returns the attribute name
	 *
	 * @return string
	 */
	public static function get_name(): string {
		return __( 'Age group', 'google-listings-and-ads' );
	}
}
Attributes/AttributeInterface.php000064400000002563151542451210013175 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\AttributeInputInterface;

defined( 'ABSPATH' ) || exit;

/**
 * Interface AttributeInterface
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 */
interface AttributeInterface {

	/**
	 * Returns the attribute ID.
	 *
	 * Must be the same as a Google product's property name to be set automatically.
	 *
	 * @return string
	 *
	 * @see \Google\Service\ShoppingContent\Product for the list of properties.
	 */
	public static function get_id(): string;

	/**
	 * Return the attribute's value type. Must be a valid PHP type.
	 *
	 * @return string
	 *
	 * @link https://www.php.net/manual/en/function.settype.php
	 */
	public static function get_value_type(): string;

	/**
	 * Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
	 *
	 * @return string
	 *
	 * @see AttributeInputInterface
	 *
	 * @since 1.5.0
	 */
	public static function get_input_type(): string;

	/**
	 * Return an array of WooCommerce product types that this attribute can be applied to.
	 *
	 * @return array
	 */
	public static function get_applicable_product_types(): array;

	/**
	 * Returns the attribute value.
	 *
	 * @return mixed
	 */
	public function get_value();
}
Attributes/AttributeManager.php000064400000026252151542451210012650 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;

use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidClass;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ValidateInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\WCProductAdapter;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\AttributeMappingRulesQuery;
use WC_Product;
use WC_Product_Variation;

defined( 'ABSPATH' ) || exit;

/**
 * Class AttributeManager
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 */
class AttributeManager implements Service {

	use PluginHelper;
	use ValidateInterface;

	protected const ATTRIBUTES = [
		GTIN::class,
		MPN::class,
		Brand::class,
		Condition::class,
		Gender::class,
		Size::class,
		SizeSystem::class,
		SizeType::class,
		Color::class,
		Material::class,
		Pattern::class,
		AgeGroup::class,
		Multipack::class,
		IsBundle::class,
		AvailabilityDate::class,
		Adult::class,
	];

	/**
	 * @var array Attribute types mapped to product types
	 */
	protected $attribute_types_map;

	/**
	 * @var AttributeMappingRulesQuery
	 */
	protected $attribute_mapping_rules_query;

	/**
	 * @var WC
	 */
	protected $wc;

	/**
	 * AttributeManager constructor.
	 *
	 * @param AttributeMappingRulesQuery $attribute_mapping_rules_query
	 * @param WC                         $wc
	 */
	public function __construct( AttributeMappingRulesQuery $attribute_mapping_rules_query, WC $wc ) {
		$this->attribute_mapping_rules_query = $attribute_mapping_rules_query;
		$this->wc                            = $wc;
	}

	/**
	 * @param WC_Product         $product
	 * @param AttributeInterface $attribute
	 *
	 * @throws InvalidValue If the attribute is invalid for the given product.
	 */
	public function update( WC_Product $product, AttributeInterface $attribute ) {
		$this->validate( $product, $attribute::get_id() );

		if ( null === $attribute->get_value() || '' === $attribute->get_value() ) {
			$this->delete( $product, $attribute::get_id() );
			return;
		}

		$value = $attribute->get_value();
		if ( in_array( $attribute::get_value_type(), [ 'bool', 'boolean' ], true ) ) {
			$value = wc_bool_to_string( $value );
		}

		$product->update_meta_data( $this->prefix_meta_key( $attribute::get_id() ), $value );
		$product->save_meta_data();
	}

	/**
	 * @param WC_Product $product
	 * @param string     $attribute_id
	 *
	 * @return AttributeInterface|null
	 *
	 * @throws InvalidValue If the attribute ID is invalid for the given product.
	 */
	public function get( WC_Product $product, string $attribute_id ): ?AttributeInterface {
		$this->validate( $product, $attribute_id );

		$value = null;
		if ( $this->exists( $product, $attribute_id ) ) {
			$value = $product->get_meta( $this->prefix_meta_key( $attribute_id ), true );
		}

		if ( null === $value || '' === $value ) {
			return null;
		}

		$attribute_class = $this->get_attribute_types_for_product( $product )[ $attribute_id ];
		return new $attribute_class( $value );
	}

	/**
	 * Return all attribute values for the given product, after the mapping rules, GLA attributes, and filters have been applied.
	 * GLA Attributes has priority over the product attributes.
	 *
	 * @since 2.8.0
	 *
	 * @param WC_Product $product
	 *
	 * @return array of attribute values
	 * @throws InvalidValue When the product does not exist.
	 */
	public function get_all_aggregated_values( WC_Product $product ) {
		$attributes = $this->get_all_values( $product );

		$parent_product = null;
		// merge with parent's attributes if it's a variation product
		if ( $product instanceof WC_Product_Variation ) {
			$parent_product    = $this->wc->get_product( $product->get_parent_id() );
			$parent_attributes = $this->get_all_values( $parent_product );
			$attributes        = array_merge( $parent_attributes, $attributes );
		}

		$mapping_rules = $this->attribute_mapping_rules_query->get_results();

		$adapted_product = new WCProductAdapter(
			[
				'wc_product'        => $product,
				'parent_wc_product' => $parent_product,
				'targetCountry'     => 'US', // targetCountry is required to create a new WCProductAdapter instance, but it's not used in the attributes context.
				'gla_attributes'    => $attributes,
				'mapping_rules'     => $mapping_rules,
			]
		);

		foreach ( self::ATTRIBUTES as $attribute_class ) {
			$attribute_id = $attribute_class::get_id();
			if ( $attribute_id === 'size' ) {
				$attribute_id = 'sizes';
			}

			if ( isset( $adapted_product->$attribute_id ) ) {
				$attributes[ $attribute_id ] = $adapted_product->$attribute_id;
			}
		}

		return $attributes;
	}

	/**
	 * Return attribute value.
	 *
	 * @param WC_Product $product
	 * @param string     $attribute_id
	 *
	 * @return mixed|null
	 */
	public function get_value( WC_Product $product, string $attribute_id ) {
		$attribute = $this->get( $product, $attribute_id );

		return $attribute instanceof AttributeInterface ? $attribute->get_value() : null;
	}

	/**
	 * Return all attributes for the given product
	 *
	 * @param WC_Product $product
	 *
	 * @return AttributeInterface[]
	 */
	public function get_all( WC_Product $product ): array {
		$all_attributes = [];
		foreach ( array_keys( $this->get_attribute_types_for_product( $product ) ) as $attribute_id ) {
			$attribute = $this->get( $product, $attribute_id );
			if ( null !== $attribute ) {
				$all_attributes[ $attribute_id ] = $attribute;
			}
		}

		return $all_attributes;
	}

	/**
	 * Return all attribute values for the given product
	 *
	 * @param WC_Product $product
	 *
	 * @return array of attribute values
	 */
	public function get_all_values( WC_Product $product ): array {
		$all_attributes = [];
		foreach ( array_keys( $this->get_attribute_types_for_product( $product ) ) as $attribute_id ) {
			$attribute = $this->get_value( $product, $attribute_id );
			if ( null !== $attribute ) {
				$all_attributes[ $attribute_id ] = $attribute;
			}
		}

		return $all_attributes;
	}

	/**
	 * @param WC_Product $product
	 * @param string     $attribute_id
	 *
	 * @throws InvalidValue If the attribute ID is invalid for the given product.
	 */
	public function delete( WC_Product $product, string $attribute_id ) {
		$this->validate( $product, $attribute_id );

		$product->delete_meta_data( $this->prefix_meta_key( $attribute_id ) );
		$product->save_meta_data();
	}

	/**
	 * Whether the attribute exists and has been set for the product.
	 *
	 * @param WC_Product $product
	 * @param string     $attribute_id
	 *
	 * @return bool
	 *
	 * @since 1.2.0
	 */
	public function exists( WC_Product $product, string $attribute_id ): bool {
		return $product->meta_exists( $this->prefix_meta_key( $attribute_id ) );
	}

	/**
	 * Returns an array of attribute types for the given product
	 *
	 * @param WC_Product $product
	 *
	 * @return string[] of attribute classes mapped to attribute IDs
	 */
	public function get_attribute_types_for_product( WC_Product $product ): array {
		return $this->get_attribute_types_for_product_types( [ $product->get_type() ] );
	}

	/**
	 * Returns an array of attribute types for the given product types
	 *
	 * @param string[] $product_types array of WooCommerce product types
	 *
	 * @return string[] of attribute classes mapped to attribute IDs
	 */
	public function get_attribute_types_for_product_types( array $product_types ): array {
		// flip the product types array to have them as array keys
		$product_types_keys = array_flip( $product_types );

		// intersect the product types with our stored attributes map to get arrays of attributes matching the given product types
		$match_attributes = array_intersect_key( $this->get_attribute_types_map(), $product_types_keys );

		// re-index the attributes map array to avoid string ($product_type) array keys
		$match_attributes = array_values( $match_attributes );

		if ( empty( $match_attributes ) ) {
			return [];
		}

		// merge all of the attribute arrays from the map (there might be duplicates) and return the results
		return array_merge( ...$match_attributes );
	}

	/**
	 * Returns all available attribute IDs.
	 *
	 * @return array
	 *
	 * @since 1.3.0
	 */
	public static function get_available_attribute_ids(): array {
		$attributes = [];
		foreach ( self::get_available_attribute_types() as $attribute_type ) {
			if ( method_exists( $attribute_type, 'get_id' ) ) {
				$attribute_id                = call_user_func( [ $attribute_type, 'get_id' ] );
				$attributes[ $attribute_id ] = $attribute_id;
			}
		}

		return $attributes;
	}

	/**
	 * Return an array of all available attribute class names.
	 *
	 * @return string[] Attribute class names
	 *
	 * @since 1.3.0
	 */
	public static function get_available_attribute_types(): array {
		/**
		 * Filters the list of available product attributes.
		 *
		 * @param string[] $attributes Array of attribute class names (FQN)
		 */
		return apply_filters( 'woocommerce_gla_product_attribute_types', self::ATTRIBUTES );
	}

	/**
	 * Returns an array of attribute types for all product types
	 *
	 * @return string[][] of attribute classes mapped to product types
	 */
	protected function get_attribute_types_map(): array {
		if ( ! isset( $this->attribute_types_map ) ) {
			$this->map_attribute_types();
		}

		return $this->attribute_types_map;
	}

	/**
	 * @param WC_Product $product
	 * @param string     $attribute_id
	 *
	 * @throws InvalidValue If the attribute type is invalid for the given product.
	 */
	protected function validate( WC_Product $product, string $attribute_id ) {
		$attribute_types = $this->get_attribute_types_for_product( $product );
		if ( ! isset( $attribute_types[ $attribute_id ] ) ) {
			do_action(
				'woocommerce_gla_error',
				sprintf( 'Attribute "%s" is not supported for a "%s" product (ID: %s).', $attribute_id, $product->get_type(), $product->get_id() ),
				__METHOD__
			);

			throw InvalidValue::not_in_allowed_list( 'attribute_id', array_keys( $attribute_types ) );
		}
	}

	/**
	 * @throws InvalidClass If any of the given attribute classes do not implement the AttributeInterface.
	 */
	protected function map_attribute_types(): void {
		$this->attribute_types_map = [];
		foreach ( self::get_available_attribute_types() as $attribute_type ) {
			$this->validate_interface( $attribute_type, AttributeInterface::class );

			$attribute_id     = call_user_func( [ $attribute_type, 'get_id' ] );
			$applicable_types = call_user_func( [ $attribute_type, 'get_applicable_product_types' ] );

			/**
			 * Filters the list of applicable product types for each attribute.
			 *
			 * @param string[] $applicable_types Array of WooCommerce product types
			 * @param string   $attribute_type   Attribute class name (FQN)
			 */
			$applicable_types = apply_filters( "woocommerce_gla_attribute_applicable_product_types_{$attribute_id}", $applicable_types, $attribute_type );

			foreach ( $applicable_types as $product_type ) {
				$this->attribute_types_map[ $product_type ]                  = $this->attribute_types_map[ $product_type ] ?? [];
				$this->attribute_types_map[ $product_type ][ $attribute_id ] = $attribute_type;
			}
		}
	}
}
Attributes/AvailabilityDate.php000064400000003350151542451210012614 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\AvailabilityDateInput;

defined( 'ABSPATH' ) || exit;

/**
 * Class AvailabilityDate
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 *
 * @since 1.5.0
 */
class AvailabilityDate extends AbstractAttribute {

	/**
	 * Returns the attribute ID.
	 *
	 * Must be the same as a Google product's property name to be set automatically.
	 *
	 * @return string
	 *
	 * @see \Google\Service\ShoppingContent\Product for the list of properties.
	 */
	public static function get_id(): string {
		return 'availabilityDate';
	}

	/**
	 * Returns a name for the attribute. Used in attribute's input.
	 *
	 * @return string
	 */
	public static function get_name(): string {
		return __( 'Availability Date', 'google-listings-and-ads' );
	}

	/**
	 * Returns a short description for the attribute. Used in attribute's input.
	 *
	 * @return string
	 */
	public static function get_description(): string {
		return __( 'The date a preordered or backordered product becomes available for delivery. Required if product availability is preorder or backorder', 'google-listings-and-ads' );
	}

	/**
	 * Return an array of WooCommerce product types that this attribute can be applied to.
	 *
	 * @return array
	 */
	public static function get_applicable_product_types(): array {
		return [ 'simple', 'variation' ];
	}

	/**
	 * Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
	 *
	 * @return string
	 *
	 * @see AttributeInputInterface
	 */
	public static function get_input_type(): string {
		return AvailabilityDateInput::class;
	}
}
Attributes/Brand.php000064400000002673151542451210010441 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\BrandInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsFieldTrait;

defined( 'ABSPATH' ) || exit;

/**
 * Class Brand
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 */
class Brand extends AbstractAttribute implements WithMappingInterface {

	use IsFieldTrait;

	/**
	 * Returns the attribute ID.
	 *
	 * Must be the same as a Google product's property name to be set automatically.
	 *
	 * @return string
	 *
	 * @see \Google\Service\ShoppingContent\Product for the list of properties.
	 */
	public static function get_id(): string {
		return 'brand';
	}

	/**
	 * Return an array of WooCommerce product types that this attribute can be applied to.
	 *
	 * @return array
	 */
	public static function get_applicable_product_types(): array {
		return [ 'simple', 'variable' ];
	}

	/**
	 * Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
	 *
	 * @return string
	 *
	 * @see AttributeInputInterface
	 *
	 * @since 1.5.0
	 */
	public static function get_input_type(): string {
		return BrandInput::class;
	}

	/**
	 * Returns the attribute name
	 *
	 * @return string
	 */
	public static function get_name(): string {
		return __( 'Brand', 'google-listings-and-ads' );
	}
}
Attributes/Color.php000064400000002674151542451210010472 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\ColorInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsFieldTrait;

defined( 'ABSPATH' ) || exit;

/**
 * Class Color
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 */
class Color extends AbstractAttribute implements WithMappingInterface {

	use IsFieldTrait;

	/**
	 * Returns the attribute ID.
	 *
	 * Must be the same as a Google product's property name to be set automatically.
	 *
	 * @return string
	 *
	 * @see \Google\Service\ShoppingContent\Product for the list of properties.
	 */
	public static function get_id(): string {
		return 'color';
	}

	/**
	 * Return an array of WooCommerce product types that this attribute can be applied to.
	 *
	 * @return array
	 */
	public static function get_applicable_product_types(): array {
		return [ 'simple', 'variation' ];
	}

	/**
	 * Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
	 *
	 * @return string
	 *
	 * @see AttributeInputInterface
	 *
	 * @since 1.5.0
	 */
	public static function get_input_type(): string {
		return ColorInput::class;
	}

	/**
	 * Returns the attribute name
	 *
	 * @return string
	 */
	public static function get_name(): string {
		return __( 'Color', 'google-listings-and-ads' );
	}
}
Attributes/Condition.php000064400000003613151542451210011334 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\ConditionInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsEnumTrait;

defined( 'ABSPATH' ) || exit;

/**
 * Class Condition
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 */
class Condition extends AbstractAttribute implements WithValueOptionsInterface, WithMappingInterface {

	use IsEnumTrait;

	/**
	 * Returns the attribute ID.
	 *
	 * Must be the same as a Google product's property name to be set automatically.
	 *
	 * @return string
	 *
	 * @see \Google\Service\ShoppingContent\Product for the list of properties.
	 */
	public static function get_id(): string {
		return 'condition';
	}

	/**
	 * Return an array of WooCommerce product types that this attribute can be applied to.
	 *
	 * @return array
	 */
	public static function get_applicable_product_types(): array {
		return [ 'simple', 'variation' ];
	}

	/**
	 * Return an array of values available to choose for the attribute.
	 *
	 * Note: array key is used as the option key.
	 *
	 * @return array
	 */
	public static function get_value_options(): array {
		return [
			'new'         => __( 'New', 'google-listings-and-ads' ),
			'refurbished' => __( 'Refurbished', 'google-listings-and-ads' ),
			'used'        => __( 'Used', 'google-listings-and-ads' ),
		];
	}

	/**
	 * Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
	 *
	 * @return string
	 *
	 * @see AttributeInputInterface
	 *
	 * @since 1.5.0
	 */
	public static function get_input_type(): string {
		return ConditionInput::class;
	}

	/**
	 * Returns the attribute name
	 *
	 * @return string
	 */
	public static function get_name(): string {
		return __( 'Condition', 'google-listings-and-ads' );
	}
}
Attributes/GTIN.php000064400000002666151542451210010156 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\GTINInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsFieldTrait;

defined( 'ABSPATH' ) || exit;

/**
 * Class GTIN
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 */
class GTIN extends AbstractAttribute implements WithMappingInterface {

	use IsFieldTrait;

	/**
	 * Returns the attribute ID.
	 *
	 * Must be the same as a Google product's property name to be set automatically.
	 *
	 * @return string
	 *
	 * @see \Google\Service\ShoppingContent\Product for the list of properties.
	 */
	public static function get_id(): string {
		return 'gtin';
	}

	/**
	 * Return an array of WooCommerce product types that this attribute can be applied to.
	 *
	 * @return array
	 */
	public static function get_applicable_product_types(): array {
		return [ 'simple', 'variation' ];
	}

	/**
	 * Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
	 *
	 * @return string
	 *
	 * @see AttributeInputInterface
	 *
	 * @since 1.5.0
	 */
	public static function get_input_type(): string {
		return GTINInput::class;
	}

	/**
	 * Returns the attribute name
	 *
	 * @return string
	 */
	public static function get_name(): string {
		return __( 'GTIN', 'google-listings-and-ads' );
	}
}
Attributes/Gender.php000064400000003550151542451210010612 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\GenderInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsEnumTrait;

defined( 'ABSPATH' ) || exit;

/**
 * Class Gender
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 */
class Gender extends AbstractAttribute implements WithValueOptionsInterface, WithMappingInterface {

	use IsEnumTrait;

	/**
	 * Returns the attribute ID.
	 *
	 * Must be the same as a Google product's property name to be set automatically.
	 *
	 * @return string
	 *
	 * @see \Google\Service\ShoppingContent\Product for the list of properties.
	 */
	public static function get_id(): string {
		return 'gender';
	}

	/**
	 * Return an array of values available to choose for the attribute.
	 *
	 * Note: array key is used as the option key.
	 *
	 * @return array
	 */
	public static function get_value_options(): array {
		return [
			'male'   => __( 'Male', 'google-listings-and-ads' ),
			'female' => __( 'Female', 'google-listings-and-ads' ),
			'unisex' => __( 'Unisex', 'google-listings-and-ads' ),
		];
	}

	/**
	 * Return an array of WooCommerce product types that this attribute can be applied to.
	 *
	 * @return array
	 */
	public static function get_applicable_product_types(): array {
		return [ 'simple', 'variation' ];
	}

	/**
	 * Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
	 *
	 * @return string
	 *
	 * @see AttributeInputInterface
	 *
	 * @since 1.5.0
	 */
	public static function get_input_type(): string {
		return GenderInput::class;
	}

	/**
	 * Returns the attribute name
	 *
	 * @return string
	 */
	public static function get_name(): string {
		return __( 'Gender', 'google-listings-and-ads' );
	}
}
Attributes/IsBundle.php000064400000003640151542451210011113 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\IsBundleInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsEnumTrait;

defined( 'ABSPATH' ) || exit;

/**
 * Class IsBundle
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 */
class IsBundle extends AbstractAttribute implements WithMappingInterface {

	use IsEnumTrait;

	/**
	 * Returns the attribute ID.
	 *
	 * Must be the same as a Google product's property name to be set automatically.
	 *
	 * @return string
	 *
	 * @see \Google\Service\ShoppingContent\Product for the list of properties.
	 */
	public static function get_id(): string {
		return 'isBundle';
	}

	/**
	 * Return an array of WooCommerce product types that this attribute can be applied to.
	 *
	 * @return array
	 */
	public static function get_applicable_product_types(): array {
		return [ 'simple', 'variation' ];
	}

	/**
	 * Return the attribute type. Must be a valid PHP type.
	 *
	 * @return string
	 *
	 * @link https://www.php.net/manual/en/function.settype.php
	 */
	public static function get_value_type(): string {
		return 'boolean';
	}

	/**
	 * Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
	 *
	 * @return string
	 *
	 * @see AttributeInputInterface
	 *
	 * @since 1.5.0
	 */
	public static function get_input_type(): string {
		return IsBundleInput::class;
	}

	/**
	 * Returns the attribute name
	 *
	 * @return string
	 */
	public static function get_name(): string {
		return __( 'Is Bundle', 'google-listings-and-ads' );
	}


	/**
	 * Returns the attribute sources
	 *
	 * @return array
	 */
	public static function get_sources(): array {
		return [
			'yes' => __( 'Yes', 'google-listings-and-ads' ),
			'no'  => __( 'No', 'google-listings-and-ads' ),
		];
	}
}
Attributes/MPN.php000064400000002660151542451210010041 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\MPNInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsFieldTrait;

defined( 'ABSPATH' ) || exit;

/**
 * Class MPN
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 */
class MPN extends AbstractAttribute implements WithMappingInterface {

	use IsFieldTrait;

	/**
	 * Returns the attribute ID.
	 *
	 * Must be the same as a Google product's property name to be set automatically.
	 *
	 * @return string
	 *
	 * @see \Google\Service\ShoppingContent\Product for the list of properties.
	 */
	public static function get_id(): string {
		return 'mpn';
	}

	/**
	 * Return an array of WooCommerce product types that this attribute can be applied to.
	 *
	 * @return array
	 */
	public static function get_applicable_product_types(): array {
		return [ 'simple', 'variation' ];
	}

	/**
	 * Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
	 *
	 * @return string
	 *
	 * @see AttributeInputInterface
	 *
	 * @since 1.5.0
	 */
	public static function get_input_type(): string {
		return MPNInput::class;
	}

	/**
	 * Returns the attribute name
	 *
	 * @return string
	 */
	public static function get_name(): string {
		return __( 'MPN', 'google-listings-and-ads' );
	}
}
Attributes/Material.php000064400000002716151542451210011147 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\MaterialInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsFieldTrait;

defined( 'ABSPATH' ) || exit;

/**
 * Class Material
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 */
class Material extends AbstractAttribute implements WithMappingInterface {

	use IsFieldTrait;

	/**
	 * Returns the attribute ID.
	 *
	 * Must be the same as a Google product's property name to be set automatically.
	 *
	 * @return string
	 *
	 * @see \Google\Service\ShoppingContent\Product for the list of properties.
	 */
	public static function get_id(): string {
		return 'material';
	}

	/**
	 * Return an array of WooCommerce product types that this attribute can be applied to.
	 *
	 * @return array
	 */
	public static function get_applicable_product_types(): array {
		return [ 'simple', 'variation' ];
	}

	/**
	 * Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
	 *
	 * @return string
	 *
	 * @see AttributeInputInterface
	 *
	 * @since 1.5.0
	 */
	public static function get_input_type(): string {
		return MaterialInput::class;
	}

	/**
	 * Returns the attribute name
	 *
	 * @return string
	 */
	public static function get_name(): string {
		return __( 'Material', 'google-listings-and-ads' );
	}
}
Attributes/Multipack.php000064400000003272151542451210011340 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\MultipackInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsFieldTrait;

defined( 'ABSPATH' ) || exit;

/**
 * Class Multipack
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 */
class Multipack extends AbstractAttribute implements WithMappingInterface {

	use IsFieldTrait;

	/**
	 * Returns the attribute ID.
	 *
	 * Must be the same as a Google product's property name to be set automatically.
	 *
	 * @return string
	 *
	 * @see \Google\Service\ShoppingContent\Product for the list of properties.
	 */
	public static function get_id(): string {
		return 'multipack';
	}

	/**
	 * Return an array of WooCommerce product types that this attribute can be applied to.
	 *
	 * @return array
	 */
	public static function get_applicable_product_types(): array {
		return [ 'simple', 'variation' ];
	}

	/**
	 * Return the attribute type. Must be a valid PHP type.
	 *
	 * @return string
	 *
	 * @link https://www.php.net/manual/en/function.settype.php
	 */
	public static function get_value_type(): string {
		return 'integer';
	}

	/**
	 * Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
	 *
	 * @return string
	 *
	 * @see AttributeInputInterface
	 *
	 * @since 1.5.0
	 */
	public static function get_input_type(): string {
		return MultipackInput::class;
	}

	/**
	 * Returns the attribute name
	 *
	 * @return string
	 */
	public static function get_name(): string {
		return __( 'Multipack', 'google-listings-and-ads' );
	}
}
Attributes/Pattern.php000064400000002710151542451210011020 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\PatternInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsFieldTrait;

defined( 'ABSPATH' ) || exit;

/**
 * Class Pattern
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 */
class Pattern extends AbstractAttribute implements WithMappingInterface {

	use IsFieldTrait;

	/**
	 * Returns the attribute ID.
	 *
	 * Must be the same as a Google product's property name to be set automatically.
	 *
	 * @return string
	 *
	 * @see \Google\Service\ShoppingContent\Product for the list of properties.
	 */
	public static function get_id(): string {
		return 'pattern';
	}

	/**
	 * Return an array of WooCommerce product types that this attribute can be applied to.
	 *
	 * @return array
	 */
	public static function get_applicable_product_types(): array {
		return [ 'simple', 'variation' ];
	}

	/**
	 * Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
	 *
	 * @return string
	 *
	 * @see AttributeInputInterface
	 *
	 * @since 1.5.0
	 */
	public static function get_input_type(): string {
		return PatternInput::class;
	}

	/**
	 * Returns the attribute name
	 *
	 * @return string
	 */
	public static function get_name(): string {
		return __( 'Pattern', 'google-listings-and-ads' );
	}
}
Attributes/Size.php000064400000002666151542451210010327 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\SizeInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsFieldTrait;

defined( 'ABSPATH' ) || exit;

/**
 * Class Size
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 */
class Size extends AbstractAttribute implements WithMappingInterface {

	use IsFieldTrait;

	/**
	 * Returns the attribute ID.
	 *
	 * Must be the same as a Google product's property name to be set automatically.
	 *
	 * @return string
	 *
	 * @see \Google\Service\ShoppingContent\Product for the list of properties.
	 */
	public static function get_id(): string {
		return 'size';
	}

	/**
	 * Return an array of WooCommerce product types that this attribute can be applied to.
	 *
	 * @return array
	 */
	public static function get_applicable_product_types(): array {
		return [ 'simple', 'variation' ];
	}

	/**
	 * Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
	 *
	 * @return string
	 *
	 * @see AttributeInputInterface
	 *
	 * @since 1.5.0
	 */
	public static function get_input_type(): string {
		return SizeInput::class;
	}

	/**
	 * Returns the attribute name
	 *
	 * @return string
	 */
	public static function get_name(): string {
		return __( 'Size', 'google-listings-and-ads' );
	}
}
Attributes/SizeSystem.php000064400000004407151542451210011527 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\SizeSystemInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsEnumTrait;

defined( 'ABSPATH' ) || exit;

/**
 * Class SizeSystem
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 */
class SizeSystem extends AbstractAttribute implements WithValueOptionsInterface, WithMappingInterface {

	use IsEnumTrait;

	/**
	 * Returns the attribute ID.
	 *
	 * Must be the same as a Google product's property name to be set automatically.
	 *
	 * @return string
	 *
	 * @see \Google\Service\ShoppingContent\Product for the list of properties.
	 */
	public static function get_id(): string {
		return 'sizeSystem';
	}

	/**
	 * Return an array of WooCommerce product types that this attribute can be applied to.
	 *
	 * @return array
	 */
	public static function get_applicable_product_types(): array {
		return [ 'simple', 'variation' ];
	}

	/**
	 * Return an array of values available to choose for the attribute.
	 *
	 * Note: array key is used as the option key.
	 *
	 * @return array
	 */
	public static function get_value_options(): array {
		return [
			'US'  => __( 'US', 'google-listings-and-ads' ),
			'EU'  => __( 'EU', 'google-listings-and-ads' ),
			'UK'  => __( 'UK', 'google-listings-and-ads' ),
			'DE'  => __( 'DE', 'google-listings-and-ads' ),
			'FR'  => __( 'FR', 'google-listings-and-ads' ),
			'IT'  => __( 'IT', 'google-listings-and-ads' ),
			'AU'  => __( 'AU', 'google-listings-and-ads' ),
			'BR'  => __( 'BR', 'google-listings-and-ads' ),
			'CN'  => __( 'CN', 'google-listings-and-ads' ),
			'JP'  => __( 'JP', 'google-listings-and-ads' ),
			'MEX' => __( 'MEX', 'google-listings-and-ads' ),
		];
	}

	/**
	 * Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
	 *
	 * @return string
	 *
	 * @see AttributeInputInterface
	 *
	 * @since 1.5.0
	 */
	public static function get_input_type(): string {
		return SizeSystemInput::class;
	}

	/**
	 * Returns the attribute name
	 *
	 * @return string
	 */
	public static function get_name(): string {
		return __( 'Size System', 'google-listings-and-ads' );
	}
}
Attributes/SizeType.php000064400000004160151542451210011160 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\SizeTypeInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\Traits\IsEnumTrait;

defined( 'ABSPATH' ) || exit;

/**
 * Class SizeType
 *
 * @see https://support.google.com/merchants/answer/6324497
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 */
class SizeType extends AbstractAttribute implements WithValueOptionsInterface, WithMappingInterface {

	use IsEnumTrait;

	/**
	 * Returns the attribute ID.
	 *
	 * Must be the same as a Google product's property name to be set automatically.
	 *
	 * @return string
	 *
	 * @see \Google\Service\ShoppingContent\Product for the list of properties.
	 */
	public static function get_id(): string {
		return 'sizeType';
	}

	/**
	 * Return an array of WooCommerce product types that this attribute can be applied to.
	 *
	 * @return array
	 */
	public static function get_applicable_product_types(): array {
		return [ 'simple', 'variation' ];
	}

	/**
	 * Return an array of values available to choose for the attribute.
	 *
	 * Note: array key is used as the option key.
	 *
	 * @return array
	 */
	public static function get_value_options(): array {
		return [
			'regular'   => __( 'Regular', 'google-listings-and-ads' ),
			'petite'    => __( 'Petite', 'google-listings-and-ads' ),
			'plus'      => __( 'Plus', 'google-listings-and-ads' ),
			'tall'      => __( 'Tall', 'google-listings-and-ads' ),
			'big'       => __( 'Big', 'google-listings-and-ads' ),
			'maternity' => __( 'Maternity', 'google-listings-and-ads' ),
		];
	}

	/**
	 * Return the attribute's input class. Must be an instance of `AttributeInputInterface`.
	 *
	 * @return string
	 *
	 * @see AttributeInputInterface
	 *
	 * @since 1.5.0
	 */
	public static function get_input_type(): string {
		return SizeTypeInput::class;
	}

	/**
	 * Returns the attribute name
	 *
	 * @return string
	 */
	public static function get_name(): string {
		return __( 'Size Type', 'google-listings-and-ads' );
	}
}
Attributes/WithMappingInterface.php000064400000001227151542451210013455 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;

defined( 'ABSPATH' ) || exit;

/**
 * Interface with specific options for mapping
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 */
interface WithMappingInterface {

	/**
	 * Returns the attribute name
	 *
	 * @return string
	 */
	public static function get_name(): string;

	/**
	 * Returns true if the attribute is enum type
	 *
	 * @return boolean
	 */
	public static function is_enum(): bool;

	/**
	 * Returns the available attribute sources
	 *
	 * @return array
	 */
	public static function get_sources(): array;
}
Attributes/WithValueOptionsInterface.php000064400000000775151542451210014521 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes;

defined( 'ABSPATH' ) || exit;

/**
 * Interface WithValueOptionsInterface
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 */
interface WithValueOptionsInterface {
	/**
	 * Return an array of values available to choose for the attribute.
	 *
	 * Note: array key is used as the option key.
	 *
	 * @return array
	 */
	public static function get_value_options(): array;
}
BatchProductHelper.php000064400000023624151542451210011006 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;

use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\AttributeMappingRulesQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\GoogleListingsAndAdsException;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ValidateInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\BatchProductIDRequestEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\BatchInvalidProductEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\BatchProductEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\BatchProductRequestEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\TargetAudience;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Product as GoogleProduct;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use WC_Product;
use WC_Product_Variable;

defined( 'ABSPATH' ) || exit;

/**
 * Class BatchProductHelper
 *
 * Contains helper methods for batch processing products.
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product
 */
class BatchProductHelper implements Service {

	use ValidateInterface;

	/**
	 * @var ProductMetaHandler
	 */
	protected $meta_handler;

	/**
	 * @var ProductHelper
	 */
	protected $product_helper;

	/**
	 * @var ValidatorInterface
	 */
	protected $validator;

	/**
	 * @var ProductFactory
	 */
	protected $product_factory;

	/**
	 * @var TargetAudience
	 */
	protected $target_audience;

	/**
	 * @var AttributeMappingRulesQuery
	 */
	protected $attribute_mapping_rules_query;

	/**
	 * BatchProductHelper constructor.
	 *
	 * @param ProductMetaHandler         $meta_handler
	 * @param ProductHelper              $product_helper
	 * @param ValidatorInterface         $validator
	 * @param ProductFactory             $product_factory
	 * @param TargetAudience             $target_audience
	 * @param AttributeMappingRulesQuery $attribute_mapping_rules_query
	 */
	public function __construct(
		ProductMetaHandler $meta_handler,
		ProductHelper $product_helper,
		ValidatorInterface $validator,
		ProductFactory $product_factory,
		TargetAudience $target_audience,
		AttributeMappingRulesQuery $attribute_mapping_rules_query
	) {
		$this->meta_handler                  = $meta_handler;
		$this->product_helper                = $product_helper;
		$this->validator                     = $validator;
		$this->product_factory               = $product_factory;
		$this->target_audience               = $target_audience;
		$this->attribute_mapping_rules_query = $attribute_mapping_rules_query;
	}

	/**
	 * Filters and returns only the products already synced with Google Merchant Center.
	 *
	 * @param WC_Product[] $products
	 *
	 * @return WC_Product[] The synced products.
	 */
	public function filter_synced_products( array $products ): array {
		return array_filter( $products, [ $this->product_helper, 'is_product_synced' ] );
	}

	/**
	 * @param BatchProductEntry $product_entry
	 */
	public function mark_as_synced( BatchProductEntry $product_entry ) {
		$wc_product     = $this->product_helper->get_wc_product( $product_entry->get_wc_product_id() );
		$google_product = $product_entry->get_google_product();

		$this->validate_instanceof( $google_product, GoogleProduct::class );

		$this->product_helper->mark_as_synced( $wc_product, $google_product );
	}

	/**
	 * @param BatchProductEntry $product_entry
	 */
	public function mark_as_unsynced( BatchProductEntry $product_entry ) {
		try {
			$wc_product = $this->product_helper->get_wc_product( $product_entry->get_wc_product_id() );
		} catch ( InvalidValue $exception ) {
			return;
		}

		$this->product_helper->mark_as_unsynced( $wc_product );
	}

	/**
	 * Mark a batch of WooCommerce product IDs as unsynced.
	 * Invalid products will be skipped.
	 *
	 * @since 1.12.0
	 *
	 * @param array $product_ids
	 */
	public function mark_batch_as_unsynced( array $product_ids ) {
		foreach ( $product_ids as $product_id ) {
			try {
				$product = $this->product_helper->get_wc_product( $product_id );
			} catch ( InvalidValue $exception ) {
				continue;
			}

			$this->product_helper->mark_as_unsynced( $product );
		}
	}

	/**
	 * Marks a WooCommerce product as invalid and stores the errors in a meta data key.
	 *
	 * Note: If a product variation is invalid then the parent product is also marked as invalid.
	 *
	 * @param BatchInvalidProductEntry $product_entry
	 */
	public function mark_as_invalid( BatchInvalidProductEntry $product_entry ) {
		$wc_product = $this->product_helper->get_wc_product( $product_entry->get_wc_product_id() );
		$errors     = $product_entry->get_errors();

		$this->product_helper->mark_as_invalid( $wc_product, $errors );
	}

	/**
	 * Generates an array map containing the Google product IDs as key and the WooCommerce product IDs as values.
	 *
	 * @param WC_Product[] $products
	 *
	 * @return BatchProductIDRequestEntry[]
	 */
	public function generate_delete_request_entries( array $products ): array {
		$request_entries = [];
		foreach ( $products as $product ) {
			$this->validate_instanceof( $product, WC_Product::class );

			if ( $product instanceof WC_Product_Variable ) {
				$request_entries = array_merge( $request_entries, $this->generate_delete_request_entries( $product->get_available_variations( 'objects' ) ) );
				continue;
			}

			$google_ids = $this->product_helper->get_synced_google_product_ids( $product );
			if ( empty( $google_ids ) ) {
				continue;
			}

			foreach ( $google_ids as $google_id ) {
				$request_entries[ $google_id ] = new BatchProductIDRequestEntry(
					$product->get_id(),
					$google_id
				);
			}
		}

		return $request_entries;
	}

	/**
	 * @param WC_Product[] $products
	 *
	 * @return BatchProductRequestEntry[]
	 */
	public function validate_and_generate_update_request_entries( array $products ): array {
		$request_entries = [];
		$mapping_rules   = $this->attribute_mapping_rules_query->get_results();

		foreach ( $products as $product ) {
			$this->validate_instanceof( $product, WC_Product::class );

			try {
				if ( ! $this->product_helper->is_sync_ready( $product ) ) {
					do_action(
						'woocommerce_gla_debug_message',
						sprintf( 'Skipping product (ID: %s) because it is not ready to be synced.', $product->get_id() ),
						__METHOD__
					);

					continue;
				}

				if ( $product instanceof WC_Product_Variable ) {
					$request_entries = array_merge( $request_entries, $this->validate_and_generate_update_request_entries( $product->get_available_variations( 'objects' ) ) );
					continue;
				}

				$target_countries    = $this->target_audience->get_target_countries();
				$main_target_country = $this->target_audience->get_main_target_country();

				// validate the product
				$adapted_product   = $this->product_factory->create( $product, $main_target_country, $mapping_rules );
				$validation_result = $this->validate_product( $adapted_product );
				if ( $validation_result instanceof BatchInvalidProductEntry ) {
					$this->mark_as_invalid( $validation_result );

					do_action(
						'woocommerce_gla_debug_message',
						sprintf( 'Skipping product (ID: %s) because it does not pass validation: %s', $product->get_id(), wp_json_encode( $validation_result ) ),
						__METHOD__
					);

					continue;
				}

				// add shipping for all selected target countries
				array_walk( $target_countries, [ $adapted_product, 'add_shipping_country' ] );

				$request_entries[] = new BatchProductRequestEntry(
					$product->get_id(),
					$adapted_product
				);
			} catch ( GoogleListingsAndAdsException $exception ) {
				do_action(
					'woocommerce_gla_error',
					sprintf( 'Skipping product (ID: %s) due to exception: %s', $product->get_id(), $exception->getMessage() ),
					__METHOD__
				);

				continue;
			}
		}

		return $request_entries;
	}

	/**
	 * @param WCProductAdapter $product
	 *
	 * @return BatchInvalidProductEntry|true
	 */
	protected function validate_product( WCProductAdapter $product ) {
		$violations = $this->validator->validate( $product );

		if ( 0 !== count( $violations ) ) {
			$invalid_product = new BatchInvalidProductEntry( $product->get_wc_product()->get_id() );
			$invalid_product->map_validation_violations( $violations );

			return $invalid_product;
		}

		return true;
	}

	/**
	 * Filters and returns an array of request entries for Google products that should no longer be submitted for the selected target audience.
	 *
	 * @param WC_Product[] $products
	 *
	 * @return BatchProductIDRequestEntry[]
	 */
	public function generate_stale_products_request_entries( array $products ): array {
		$target_audience = $this->target_audience->get_target_countries();
		$request_entries = [];
		foreach ( $products as $product ) {
			$google_ids = $this->meta_handler->get_google_ids( $product ) ?: [];
			$stale_ids  = array_diff_key( $google_ids, array_flip( $target_audience ) );
			foreach ( $stale_ids as $stale_id ) {
				$request_entries[ $stale_id ] = new BatchProductIDRequestEntry(
					$product->get_id(),
					$stale_id
				);
			}
		}

		return $request_entries;
	}

	/**
	 * Returns an array of request entries for Google products that should no
	 * longer be submitted for every target country.
	 *
	 * @since 1.1.0
	 *
	 * @param WC_Product[] $products
	 *
	 * @return BatchProductIDRequestEntry[]
	 */
	public function generate_stale_countries_request_entries( array $products ): array {
		$main_target_country = $this->target_audience->get_main_target_country();

		$request_entries = [];
		foreach ( $products as $product ) {
			$google_ids = $this->meta_handler->get_google_ids( $product ) ?: [];
			$stale_ids  = array_diff_key( $google_ids, array_flip( [ $main_target_country ] ) );
			foreach ( $stale_ids as $stale_id ) {
				$request_entries[ $stale_id ] = new BatchProductIDRequestEntry(
					$product->get_id(),
					$stale_id
				);
			}
		}

		return $request_entries;
	}
}
FilteredProductList.php000064400000003170151542451210011211 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;

use Countable;
use WC_Product;

defined( 'ABSPATH' ) || exit;

/**
 * Class FilteredProductList
 *
 * A list of filtered products and their total count before filtering.
 *
 * @since 1.4.0
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product
 */
class FilteredProductList implements Countable {

	/**
	 * List of product objects or IDs.
	 *
	 * @var WC_Product[]
	 */
	protected $products = [];

	/**
	 * Count before filtering.
	 *
	 * @var int
	 */
	protected $unfiltered_count;

	/**
	 * FilteredProductList constructor.
	 *
	 * @param WC_Product[] $products         List of filtered products.
	 * @param int          $unfiltered_count Product count before filtering.
	 */
	public function __construct( array $products, int $unfiltered_count ) {
		$this->products         = $products;
		$this->unfiltered_count = $unfiltered_count;
	}

	/**
	 * Get the list of products.
	 *
	 * @return WC_Product[]
	 */
	public function get(): array {
		return $this->products;
	}

	/**
	 * Get product IDs.
	 *
	 * @return int[]
	 */
	public function get_product_ids(): array {
		return array_map(
			function ( $product ) {
				if ( $product instanceof WC_Product ) {
					return $product->get_id();
				}
				return $product;
			},
			$this->products
		);
	}

	/**
	 * Get the unfiltered amount of results.
	 *
	 * @return int
	 */
	public function get_unfiltered_count(): int {
		return $this->unfiltered_count;
	}

	/**
	 * Count products for Countable.
	 *
	 * @return int
	 */
	public function count(): int {
		return count( $this->products );
	}
}
ProductFactory.php000064400000004417151542451210010233 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;

use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ValidateInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\AttributeManager;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use WC_Product;
use WC_Product_Variable;
use WC_Product_Variation;

defined( 'ABSPATH' ) || exit;

/**
 * Class ProductFactory
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product
 */
class ProductFactory {

	use ValidateInterface;

	/**
	 * @var AttributeManager
	 */
	protected $attribute_manager;

	/**
	 * @var WC
	 */
	protected $wc;

	/**
	 * ProductFactory constructor.
	 *
	 * @param AttributeManager $attribute_manager
	 * @param WC               $wc
	 */
	public function __construct( AttributeManager $attribute_manager, WC $wc ) {
		$this->attribute_manager = $attribute_manager;
		$this->wc                = $wc;
	}

	/**
	 * @param WC_Product $product
	 * @param string     $target_country
	 * @param array      $mapping_rules The mapping rules setup by the user
	 *
	 * @return WCProductAdapter
	 *
	 * @throws InvalidValue When the product is a variation and its parent does not exist.
	 */
	public function create( WC_Product $product, string $target_country, array $mapping_rules ): WCProductAdapter {
		// We do not support syncing the parent variable product. Each variation is synced individually instead.
		$this->validate_not_instanceof( $product, WC_Product_Variable::class );

		$attributes = $this->attribute_manager->get_all_values( $product );

		$parent_product = null;
		// merge with parent's attributes if it's a variation product
		if ( $product instanceof WC_Product_Variation ) {
			$parent_product    = $this->wc->get_product( $product->get_parent_id() );
			$parent_attributes = $this->attribute_manager->get_all_values( $parent_product );
			$attributes        = array_merge( $parent_attributes, $attributes );
		}

		return new WCProductAdapter(
			[
				'wc_product'        => $product,
				'parent_wc_product' => $parent_product,
				'targetCountry'     => $target_country,
				'gla_attributes'    => $attributes,
				'mapping_rules'     => $mapping_rules,
			]
		);
	}
}
ProductFilter.php000064400000004471151542451210010051 0ustar00<?php

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;

use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use WC_Product;

/**
 * Class ProductFilter
 *
 * Filters a list of products retrieved from the repository.
 *
 * @since 1.4.0
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product
 */
class ProductFilter implements Service {

	/**
	 * @var ProductHelper
	 */
	protected $product_helper;

	/**
	 * ProductFilter constructor.
	 *
	 * @param ProductHelper $product_helper
	 */
	public function __construct( ProductHelper $product_helper ) {
		$this->product_helper = $product_helper;
	}

	/**
	 * Filters and returns a list of products that are ready to be submitted to Google Merchant Center.
	 *
	 * @param WC_Product[] $products
	 *
	 * @return FilteredProductList
	 */
	public function filter_sync_ready_products( array $products ): FilteredProductList {
		$unfiltered_count = count( $products );
		/**
		 * Filters the list of products ready to be synced (before applying filters to check failures and sync-ready status).
		 *
		 * @param WC_Product[] $products Sync-ready WooCommerce products
		 */
		$products = apply_filters( 'woocommerce_gla_get_sync_ready_products_pre_filter', $products );
		$results  = array_values(
			array_filter(
				$products,
				function ( $product ) {
					return $this->product_helper->is_sync_ready( $product ) && ! $this->product_helper->is_sync_failed_recently( $product );
				}
			)
		);
		/**
		 * Filters the list of products ready to be synced (after applying filters to check failures and sync-ready status).
		 *
		 * @param WC_Product[] $results Sync-ready WooCommerce products
		 */
		$results = apply_filters( 'woocommerce_gla_get_sync_ready_products_filter', $results );

		return new FilteredProductList( $results, $unfiltered_count );
	}

	/**
	 * Filter and return a list of products that can be deleted.
	 *
	 * @since 1.12.0
	 *
	 * @param WC_Product[] $products
	 *
	 * @return FilteredProductList
	 */
	public function filter_products_for_delete( array $products ): FilteredProductList {
		$results = array_values(
			array_filter(
				$products,
				function ( $product ) {
					return ! $this->product_helper->is_delete_failed_threshold_reached( $product );
				}
			)
		);

		return new FilteredProductList( $results, count( $products ) );
	}
}
ProductHelper.php000064400000061474151542451210010051 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;

use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleProductService;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\TargetAudience;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\NotificationStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\MCStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\SyncStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Product as GoogleProduct;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications\HelperNotificationInterface;
use WC_Product;
use WC_Product_Variation;
use WP_Post;

defined( 'ABSPATH' ) || exit;

/**
 * Class ProductHelper
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product
 */
class ProductHelper implements Service, HelperNotificationInterface {

	use PluginHelper;

	/**
	 * @var ProductMetaHandler
	 */
	protected $meta_handler;

	/**
	 * @var WC
	 */
	protected $wc;

	/**
	 * @var TargetAudience
	 */
	protected $target_audience;

	/**
	 * ProductHelper constructor.
	 *
	 * @param ProductMetaHandler $meta_handler
	 * @param WC                 $wc
	 * @param TargetAudience     $target_audience
	 */
	public function __construct( ProductMetaHandler $meta_handler, WC $wc, TargetAudience $target_audience ) {
		$this->meta_handler    = $meta_handler;
		$this->wc              = $wc;
		$this->target_audience = $target_audience;
	}

	/**
	 * Mark the item as notified.
	 *
	 * @param WC_Product $product
	 *
	 * @return void
	 */
	public function mark_as_notified( $product ): void {
		$this->meta_handler->delete_failed_delete_attempts( $product );
		$this->meta_handler->update_synced_at( $product, time() );
		$this->meta_handler->update_sync_status( $product, SyncStatus::SYNCED );
		$this->update_empty_visibility( $product );

		// mark the parent product as synced if it's a variation
		if ( $product instanceof WC_Product_Variation ) {
			try {
				$parent_product = $this->get_wc_product( $product->get_parent_id() );
			} catch ( InvalidValue $exception ) {
				return;
			}

			$this->mark_as_notified( $parent_product );
		}
	}

	/**
	 * Mark a product as synced in the local database.
	 * This function also handles the following cleanup tasks:
	 * - Remove any failed delete attempts
	 * - Update the visibility (if it was previously empty)
	 * - Remove any previous product errors (if it was synced for all target countries)
	 *
	 * @param WC_Product    $product
	 * @param GoogleProduct $google_product
	 */
	public function mark_as_synced( WC_Product $product, GoogleProduct $google_product ) {
		$this->meta_handler->delete_failed_delete_attempts( $product );
		$this->meta_handler->update_synced_at( $product, time() );
		$this->meta_handler->update_sync_status( $product, SyncStatus::SYNCED );
		$this->update_empty_visibility( $product );

		// merge and update all google product ids
		$current_google_ids = $this->meta_handler->get_google_ids( $product );
		$current_google_ids = ! empty( $current_google_ids ) ? $current_google_ids : [];
		$google_ids         = array_unique( array_merge( $current_google_ids, [ $google_product->getTargetCountry() => $google_product->getId() ] ) );
		$this->meta_handler->update_google_ids( $product, $google_ids );

		// check if product is synced for main target country and remove any previous errors if it is
		$synced_countries = array_keys( $google_ids );
		$target_countries = $this->target_audience->get_target_countries();
		if ( empty( array_diff( $synced_countries, $target_countries ) ) ) {
			$this->meta_handler->delete_errors( $product );
			$this->meta_handler->delete_failed_sync_attempts( $product );
			$this->meta_handler->delete_sync_failed_at( $product );
		}

		// mark the parent product as synced if it's a variation
		if ( $product instanceof WC_Product_Variation ) {
			try {
				$parent_product = $this->get_wc_product( $product->get_parent_id() );
			} catch ( InvalidValue $exception ) {
				return;
			}

			$this->mark_as_synced( $parent_product, $google_product );
		}
	}

	/**
	 * @param WC_Product $product
	 */
	public function mark_as_unsynced( $product ): void {
		$this->meta_handler->delete_synced_at( $product );
		if ( ! $this->is_sync_ready( $product ) ) {
			$this->meta_handler->delete_sync_status( $product );
		} else {
			$this->meta_handler->update_sync_status( $product, SyncStatus::NOT_SYNCED );
		}
		$this->meta_handler->delete_google_ids( $product );
		$this->meta_handler->delete_errors( $product );
		$this->meta_handler->delete_failed_sync_attempts( $product );
		$this->meta_handler->delete_sync_failed_at( $product );

		// mark the parent product as un-synced if it's a variation
		if ( $product instanceof WC_Product_Variation ) {
			try {
				$parent_product = $this->get_wc_product( $product->get_parent_id() );
			} catch ( InvalidValue $exception ) {
				return;
			}

			$this->mark_as_unsynced( $parent_product );
		}
	}

	/**
	 * @param WC_Product $product
	 * @param string     $google_id
	 */
	public function remove_google_id( WC_Product $product, string $google_id ) {
		$google_ids = $this->meta_handler->get_google_ids( $product );
		if ( empty( $google_ids ) ) {
			return;
		}

		$idx = array_search( $google_id, $google_ids, true );
		if ( false === $idx ) {
			return;
		}

		unset( $google_ids[ $idx ] );

		if ( ! empty( $google_ids ) ) {
			$this->meta_handler->update_google_ids( $product, $google_ids );
		} else {
			// if there are no Google IDs left then this product is no longer considered "synced"
			$this->mark_as_unsynced( $product );
		}
	}

	/**
	 * Marks a WooCommerce product as invalid and stores the errors in a meta data key.
	 *
	 * Note: If a product variation is invalid then the parent product is also marked as invalid.
	 *
	 * @param WC_Product $product
	 * @param string[]   $errors
	 */
	public function mark_as_invalid( WC_Product $product, array $errors ) {
		// bail if no errors exist
		if ( empty( $errors ) ) {
			return;
		}

		$this->meta_handler->update_errors( $product, $errors );
		$this->meta_handler->update_sync_status( $product, SyncStatus::HAS_ERRORS );
		$this->update_empty_visibility( $product );

		if ( ! empty( $errors[ GoogleProductService::INTERNAL_ERROR_REASON ] ) ) {
			// update failed sync attempts count in case of internal errors
			$failed_attempts = ! empty( $this->meta_handler->get_failed_sync_attempts( $product ) ) ?
				$this->meta_handler->get_failed_sync_attempts( $product ) :
				0;
			$this->meta_handler->update_failed_sync_attempts( $product, $failed_attempts + 1 );
			$this->meta_handler->update_sync_failed_at( $product, time() );
		}

		// mark the parent product as invalid if it's a variation
		if ( $product instanceof WC_Product_Variation ) {
			try {
				$parent_product = $this->get_wc_product( $product->get_parent_id() );
			} catch ( InvalidValue $exception ) {
				return;
			}

			$parent_errors = ! empty( $this->meta_handler->get_errors( $parent_product ) ) ?
				$this->meta_handler->get_errors( $parent_product ) :
				[];

			$parent_errors[ $product->get_id() ] = $errors;

			$this->mark_as_invalid( $parent_product, $parent_errors );
		}
	}

	/**
	 * Marks a WooCommerce product as pending synchronization.
	 *
	 * Note: If a product variation is pending then the parent product is also marked as pending.
	 *
	 * @param WC_Product $product
	 */
	public function mark_as_pending( WC_Product $product ) {
		$this->meta_handler->update_sync_status( $product, SyncStatus::PENDING );

		// mark the parent product as pending if it's a variation
		if ( $product instanceof WC_Product_Variation ) {
			try {
				$parent_product = $this->get_wc_product( $product->get_parent_id() );
			} catch ( InvalidValue $exception ) {
				return;
			}

			$this->mark_as_pending( $parent_product );
		}
	}

	/**
	 * Update empty (NOT EXIST) visibility meta values to SYNC_AND_SHOW.
	 *
	 * @param WC_Product $product
	 */
	protected function update_empty_visibility( WC_Product $product ): void {
		try {
			$product = $this->maybe_swap_for_parent( $product );
		} catch ( InvalidValue $exception ) {
			return;
		}

		$visibility = $this->meta_handler->get_visibility( $product );

		if ( empty( $visibility ) ) {
			$this->meta_handler->update_visibility( $product, ChannelVisibility::SYNC_AND_SHOW );
		}
	}

	/**
	 * Update a product's channel visibility.
	 *
	 * @param WC_Product $product
	 * @param string     $visibility
	 */
	public function update_channel_visibility( WC_Product $product, string $visibility ): void {
		try {
			$product = $this->maybe_swap_for_parent( $product );
		} catch ( InvalidValue $exception ) {
			// The error has been logged within the call of maybe_swap_for_parent
			return;
		}

		try {
			$visibility = ChannelVisibility::cast( $visibility )->get();
		} catch ( InvalidValue $exception ) {
			do_action( 'woocommerce_gla_exception', $exception, __METHOD__ );
			return;
		}

		$this->meta_handler->update_visibility( $product, $visibility );
	}

	/**
	 * @param WC_Product $product
	 *
	 * @return string[]|null An array of Google product IDs stored for each WooCommerce product
	 */
	public function get_synced_google_product_ids( WC_Product $product ): ?array {
		return $this->meta_handler->get_google_ids( $product );
	}

	/**
	 * See: WCProductAdapter::map_wc_product_id()
	 *
	 * @param string $mc_product_id Simple product ID (`merchant_center_id`) or
	 *                              namespaced product ID (`online:en:GB:merchant_center_id`)
	 *
	 * @return int the ID for the WC product linked to the provided Google product ID (0 if not found)
	 */
	public function get_wc_product_id( string $mc_product_id ): int {
		// Maybe remove everything before the last colon ':'
		$mc_product_id_tokens = explode( ':', $mc_product_id );
		$mc_product_id        = end( $mc_product_id_tokens );

		// Support a fully numeric ID both with and without the `gla_` prefix.
		$wc_product_id = 0;
		$pattern       = '/^(' . preg_quote( $this->get_slug(), '/' ) . '_)?(\d+)$/';
		if ( preg_match( $pattern, $mc_product_id, $matches ) ) {
			$wc_product_id = (int) $matches[2];
		}

		/**
		 * Filters the WooCommerce product ID that was determined to be associated with the
		 * given Merchant Center product ID.
		 *
		 * @param string $wc_product_id The WooCommerce product ID as determined by default.
		 * @param string $mc_product_id Simple Merchant Center product ID (without any prefixes).
		 * @since 2.4.6
		 *
		 * @return string Merchant Center product ID as normally generated by the plugin (e.g., gla_1234).
		 */
		return (int) apply_filters( 'woocommerce_gla_get_wc_product_id', $wc_product_id, $mc_product_id );
	}

	/**
	 * Attempt to get the WooCommerce product title.
	 * The MC ID is converted to a WC ID before retrieving the product.
	 * If we can't retrieve the title we fallback to the original MC ID.
	 *
	 * @param string $mc_product_id Merchant Center product ID.
	 *
	 * @return string
	 */
	public function get_wc_product_title( string $mc_product_id ): string {
		try {
			$product = $this->get_wc_product( $this->get_wc_product_id( $mc_product_id ) );
		} catch ( InvalidValue $e ) {
			return $mc_product_id;
		}

		return $product->get_title();
	}

	/**
	 * Get WooCommerce product
	 *
	 * @param int $product_id
	 *
	 * @return WC_Product
	 *
	 * @throws InvalidValue If the given ID doesn't reference a valid product.
	 */
	public function get_wc_product( int $product_id ): WC_Product {
		return $this->wc->get_product( $product_id );
	}

	/**
	 * Get WooCommerce product by WP get_post
	 *
	 * @param int $product_id
	 *
	 * @return WP_Post|null
	 */
	public function get_wc_product_by_wp_post( int $product_id ): ?WP_Post {
		return get_post( $product_id );
	}

	/**
	 * @param WC_Product $product
	 *
	 * @return bool
	 */
	public function is_product_synced( WC_Product $product ): bool {
		$synced_at  = $this->meta_handler->get_synced_at( $product );
		$google_ids = $this->meta_handler->get_google_ids( $product );

		return ! empty( $synced_at ) && ! empty( $google_ids );
	}

	/**
	 * Indicates if a product is ready for sending Notifications.
	 * A product is ready to send notifications if DONT_SYNC_AND_SHOW is not enabled and the post status is publish.
	 *
	 * @param WC_Product $product
	 *
	 * @return bool
	 */
	public function is_ready_to_notify( WC_Product $product ): bool {
		$is_ready = ChannelVisibility::DONT_SYNC_AND_SHOW !== $this->get_channel_visibility( $product ) &&
			$product->get_status() === 'publish' &&
			in_array( $product->get_type(), ProductSyncer::get_supported_product_types(), true );

		if ( $is_ready && $product instanceof WC_Product_Variation ) {
			$parent   = $this->maybe_swap_for_parent( $product );
			$is_ready = $this->is_ready_to_notify( $parent );
		}

		/**
		 * Allow users to filter if a product is ready to notify.
		 *
		 * @since 2.8.0
		 *
		 * @param bool $value The current filter value.
		 * @param WC_Product $product The product for the notification.
		 */
		return apply_filters( 'woocommerce_gla_product_is_ready_to_notify', $is_ready, $product );
	}

	/**
	 * Indicates if a product is ready for sending a create Notification.
	 * A product is ready to send create notifications if is ready to notify and has not sent create notification yet.
	 *
	 * @param WC_Product $product
	 *
	 * @return bool
	 */
	public function should_trigger_create_notification( $product ): bool {
		return ! $product instanceof WC_Product_Variation && $this->is_ready_to_notify( $product ) && ! $this->has_notified_creation( $product );
	}

	/**
	 * Indicates if a product is ready for sending an update Notification.
	 * A product is ready to send update notifications if is ready to notify and has sent create notification already.
	 *
	 * @param WC_Product $product
	 *
	 * @return bool
	 */
	public function should_trigger_update_notification( $product ): bool {
		return ! $product instanceof WC_Product_Variation && $this->is_ready_to_notify( $product ) && $this->has_notified_creation( $product );
	}

	/**
	 * Indicates if a product is ready for sending a delete Notification.
	 * A product is ready to send delete notifications if it is not ready to notify and has sent create notification already.
	 *
	 * @param WC_Product $product
	 *
	 * @return bool
	 */
	public function should_trigger_delete_notification( $product ): bool {
		return ! $this->is_ready_to_notify( $product ) && $this->has_notified_creation( $product );
	}

	/**
	 * Indicates if a product was already notified about its creation.
	 * Notice we consider synced products in MC as notified for creation.
	 *
	 * @param WC_Product $product
	 *
	 * @return bool
	 */
	public function has_notified_creation( WC_Product $product ): bool {
		if ( $product instanceof WC_Product_Variation ) {
			return $this->has_notified_creation( $this->maybe_swap_for_parent( $product ) );
		}

		$valid_has_notified_creation_statuses = [
			NotificationStatus::NOTIFICATION_CREATED,
			NotificationStatus::NOTIFICATION_UPDATED,
			NotificationStatus::NOTIFICATION_PENDING_UPDATE,
			NotificationStatus::NOTIFICATION_PENDING_DELETE,
		];

		return in_array(
			$this->meta_handler->get_notification_status( $product ),
			$valid_has_notified_creation_statuses,
			true
		) || $this->is_product_synced( $product );
	}

	/**
	 * Set the notification status for a WooCommerce product.
	 *
	 * @param WC_Product $product
	 * @param string     $status
	 */
	public function set_notification_status( $product, $status ): void {
		$this->meta_handler->update_notification_status( $product, $status );
	}

	/**
	 * @param WC_Product $product
	 *
	 * @return bool
	 */
	public function is_sync_ready( WC_Product $product ): bool {
		$product_visibility = $product->is_visible();
		$product_status     = $product->get_status();

		if ( $product instanceof WC_Product_Variation ) {
			// Check the post status of the parent product if it's a variation
			try {
				$parent = $this->get_wc_product( $product->get_parent_id() );
			} catch ( InvalidValue $exception ) {
				do_action(
					'woocommerce_gla_error',
					sprintf( 'Cannot sync an orphaned variation (ID: %s).', $product->get_id() ),
					__METHOD__
				);

				return false;
			}

			$product_status = $parent->get_status();

			/**
			 * Optionally hide invisible variations (disabled variations and variations with empty price).
			 *
			 * @see WC_Product_Variable::get_available_variations for filter documentation
			 */
			if ( apply_filters( 'woocommerce_hide_invisible_variations', true, $parent->get_id(), $product ) && ! $product->variation_is_visible() ) {
				$product_visibility = false;
			}
		}

		return ( ChannelVisibility::DONT_SYNC_AND_SHOW !== $this->get_channel_visibility( $product ) ) &&
			( in_array( $product->get_type(), ProductSyncer::get_supported_product_types(), true ) ) &&
			( 'publish' === $product_status ) &&
			$product_visibility;
	}

	/**
	 * Whether the sync has failed repeatedly for the product within the given timeframe.
	 *
	 * @param WC_Product $product
	 *
	 * @return bool
	 *
	 * @see ProductSyncer::FAILURE_THRESHOLD        The number of failed attempts allowed per timeframe
	 * @see ProductSyncer::FAILURE_THRESHOLD_WINDOW The specified timeframe
	 */
	public function is_sync_failed_recently( WC_Product $product ): bool {
		$failed_attempts = $this->meta_handler->get_failed_sync_attempts( $product );
		$failed_at       = $this->meta_handler->get_sync_failed_at( $product );

		// if it has failed more times than the specified threshold AND if syncing it has failed within the specified window
		return $failed_attempts > ProductSyncer::FAILURE_THRESHOLD &&
			$failed_at > strtotime( sprintf( '-%s', ProductSyncer::FAILURE_THRESHOLD_WINDOW ) );
	}

	/**
	 * Increment failed delete attempts.
	 *
	 * @since 1.12.0
	 *
	 * @param WC_Product $product
	 */
	public function increment_failed_delete_attempt( WC_Product $product ) {
		$failed_attempts = $this->meta_handler->get_failed_delete_attempts( $product ) ?? 0;
		$this->meta_handler->update_failed_delete_attempts( $product, $failed_attempts + 1 );
	}

	/**
	 * Whether deleting has failed more times than the specified threshold.
	 *
	 * @since 1.12.0
	 *
	 * @param WC_Product $product
	 *
	 * @return boolean
	 */
	public function is_delete_failed_threshold_reached( WC_Product $product ): bool {
		$failed_attempts = $this->meta_handler->get_failed_delete_attempts( $product ) ?? 0;
		return $failed_attempts >= ProductSyncer::FAILURE_THRESHOLD;
	}

	/**
	 * Increment failed delete attempts.
	 *
	 * @since 1.12.2
	 *
	 * @param WC_Product $product
	 */
	public function increment_failed_update_attempt( WC_Product $product ) {
		$failed_attempts = $this->meta_handler->get_failed_sync_attempts( $product ) ?? 0;
		$this->meta_handler->update_failed_sync_attempts( $product, $failed_attempts + 1 );
	}

	/**
	 * Whether deleting has failed more times than the specified threshold.
	 *
	 * @since 1.12.2
	 *
	 * @param WC_Product $product
	 *
	 * @return boolean
	 */
	public function is_update_failed_threshold_reached( WC_Product $product ): bool {
		$failed_attempts = $this->meta_handler->get_failed_sync_attempts( $product ) ?? 0;
		return $failed_attempts >= ProductSyncer::FAILURE_THRESHOLD;
	}

	/**
	 * @param WC_Product $wc_product
	 *
	 * @return string|null
	 */
	public function get_channel_visibility( WC_Product $wc_product ): ?string {
		try {
			// todo: we might need to define visibility per variation later.
			return $this->meta_handler->get_visibility( $this->maybe_swap_for_parent( $wc_product ) );
		} catch ( InvalidValue $exception ) {
			do_action(
				'woocommerce_gla_debug_message',
				sprintf( 'Channel visibility forced to "%s" for invalid product (ID: %s).', ChannelVisibility::DONT_SYNC_AND_SHOW, $wc_product->get_id() ),
				__METHOD__
			);

			return ChannelVisibility::DONT_SYNC_AND_SHOW;
		}
	}

	/**
	 * Return a string indicating sync status based on several factors.
	 *
	 * @param WC_Product $wc_product
	 *
	 * @return string|null
	 */
	public function get_sync_status( WC_Product $wc_product ): ?string {
		return $this->meta_handler->get_sync_status( $wc_product );
	}

	/**
	 * Return the string indicating the product status as reported by the Merchant Center.
	 *
	 * @param WC_Product $wc_product
	 *
	 * @return string|null
	 */
	public function get_mc_status( WC_Product $wc_product ): ?string {
		try {
			// If the mc_status is not set, return NOT_SYNCED.
			return $this->meta_handler->get_mc_status( $this->maybe_swap_for_parent( $wc_product ) ) ?: MCStatus::NOT_SYNCED;
		} catch ( InvalidValue $exception ) {
			do_action(
				'woocommerce_gla_debug_message',
				sprintf( 'Product status returned null for invalid product (ID: %s).', $wc_product->get_id() ),
				__METHOD__
			);

			return null;
		}
	}

	/**
	 * If an item from the provided list of products has a parent, replace it with the parent ID.
	 *
	 * @param int[] $product_ids                        A list of WooCommerce product ID.
	 * @param bool  $check_product_status    (Optional) Check if the product status is publish.
	 * @param bool  $ignore_product_on_error (Optional) Ignore the product when invalid value error occurs.
	 *
	 * @return int[] A list of parent ID or product ID if it doesn't have a parent.
	 *
	 * @throws InvalidValue If the given param ignore_product_on_error is false and any of a given ID doesn't reference a valid product.
	 *                      Or if a variation product does not have a valid parent ID (i.e. it's an orphan).
	 *
	 * @since 2.2.0
	 */
	public function maybe_swap_for_parent_ids( array $product_ids, bool $check_product_status = true, bool $ignore_product_on_error = true ) {
		$new_product_ids = [];

		foreach ( $product_ids as $index => $product_id ) {
			try {
				$product     = $this->get_wc_product( $product_id );
				$new_product = $this->maybe_swap_for_parent( $product );
				if ( ! $check_product_status || 'publish' === $new_product->get_status() ) {
					$new_product_ids[ $index ] = $new_product->get_id();
				}
			} catch ( InvalidValue $exception ) {
				if ( ! $ignore_product_on_error ) {
					throw $exception;
				}
			}
		}

		return array_unique( $new_product_ids );
	}

	/**
	 * If the provided product has a parent, return its ID. Otherwise, return the given (valid product) ID.
	 *
	 * @param int $product_id WooCommerce product ID.
	 *
	 * @return int The parent ID or product ID if it doesn't have a parent.
	 *
	 * @throws InvalidValue If a given ID doesn't reference a valid product. Or if a variation product does not have a
	 *                      valid parent ID (i.e. it's an orphan).
	 */
	public function maybe_swap_for_parent_id( int $product_id ): int {
		$product = $this->get_wc_product( $product_id );

		return $this->maybe_swap_for_parent( $product )->get_id();
	}

	/**
	 * If the provided product has a parent, return its parent object. Otherwise, return the given product.
	 *
	 * @param WC_Product $product WooCommerce product object.
	 *
	 * @return WC_Product The parent product object or the given product object if it doesn't have a parent.
	 *
	 * @throws InvalidValue If a variation product does not have a valid parent ID (i.e. it's an orphan).
	 *
	 * @since 1.3.0
	 */
	public function maybe_swap_for_parent( WC_Product $product ): WC_Product {
		if ( $product instanceof WC_Product_Variation ) {
			try {
				return $this->get_wc_product( $product->get_parent_id() );
			} catch ( InvalidValue $exception ) {
				do_action(
					'woocommerce_gla_error',
					sprintf( 'An orphaned variation found (ID: %s). Please delete it via "WooCommerce > Status > Tools > Delete orphaned variations".', $product->get_id() ),
					__METHOD__
				);

				throw $exception;
			}
		}

		return $product;
	}

	/**
	 * Get validation errors for a specific product.
	 * Combines errors for variable products, which have a variation-indexed array of errors.
	 *
	 * @param WC_Product $product
	 *
	 * @return array
	 */
	public function get_validation_errors( WC_Product $product ): array {
		$errors = $this->meta_handler->get_errors( $product ) ?: [];

		$first_key = array_key_first( $errors );
		if ( ! empty( $errors ) && is_numeric( $first_key ) && 0 !== $first_key ) {
			$errors = array_unique( array_merge( ...$errors ) );
		}

		return $errors;
	}

	/**
	 * Get categories list for a specific product.
	 *
	 * @param WC_Product $product
	 *
	 * @return array
	 */
	public function get_categories( WC_Product $product ): array {
		$terms = get_the_terms( $product->get_id(), 'product_cat' );
		return ( empty( $terms ) || is_wp_error( $terms ) ) ? [] : wp_list_pluck( $terms, 'name' );
	}

	/**
	 * Get the offer id for a product
	 *
	 * @since 2.8.0
	 * @param int $product_id The product id to get the offer id.
	 *
	 * @return string The offer id
	 */
	public function get_offer_id( int $product_id ) {
		return WCProductAdapter::get_google_product_offer_id( $this->get_slug(), $product_id );
	}
}
ProductMetaHandler.php000064400000023210151542451210011000 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;

use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidMeta;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use BadMethodCallException;
use WC_Product;

defined( 'ABSPATH' ) || exit;

/**
 * Class ProductMetaHandler
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product
 *
 * @method update_synced_at( WC_Product $product, $value )
 * @method delete_synced_at( WC_Product $product )
 * @method get_synced_at( WC_Product $product ): int|null
 * @method update_google_ids( WC_Product $product, array $value )
 * @method delete_google_ids( WC_Product $product )
 * @method get_google_ids( WC_Product $product ): array|null
 * @method update_visibility( WC_Product $product, $value )
 * @method delete_visibility( WC_Product $product )
 * @method get_visibility( WC_Product $product ): string|null
 * @method update_errors( WC_Product $product, array $value )
 * @method delete_errors( WC_Product $product )
 * @method get_errors( WC_Product $product ): array|null
 * @method update_failed_delete_attempts( WC_Product $product, int $value )
 * @method delete_failed_delete_attempts( WC_Product $product )
 * @method get_failed_delete_attempts( WC_Product $product ): int|null
 * @method update_failed_sync_attempts( WC_Product $product, int $value )
 * @method delete_failed_sync_attempts( WC_Product $product )
 * @method get_failed_sync_attempts( WC_Product $product ): int|null
 * @method update_sync_failed_at( WC_Product $product, int $value )
 * @method delete_sync_failed_at( WC_Product $product )
 * @method get_sync_failed_at( WC_Product $product ): int|null
 * @method update_sync_status( WC_Product $product, string $value )
 * @method delete_sync_status( WC_Product $product )
 * @method get_sync_status( WC_Product $product ): string|null
 * @method update_mc_status( WC_Product $product, string $value )
 * @method delete_mc_status( WC_Product $product )
 * @method get_mc_status( WC_Product $product ): string|null
 * @method update_notification_status( WC_Product $product, string $value )
 * @method delete_notification_status( WC_Product $product )
 * @method get_notification_status( WC_Product $product ): string|null
 */
class ProductMetaHandler implements Service, Registerable {

	use PluginHelper;

	public const KEY_SYNCED_AT              = 'synced_at';
	public const KEY_GOOGLE_IDS             = 'google_ids';
	public const KEY_VISIBILITY             = 'visibility';
	public const KEY_ERRORS                 = 'errors';
	public const KEY_FAILED_DELETE_ATTEMPTS = 'failed_delete_attempts';
	public const KEY_FAILED_SYNC_ATTEMPTS   = 'failed_sync_attempts';
	public const KEY_SYNC_FAILED_AT         = 'sync_failed_at';
	public const KEY_SYNC_STATUS            = 'sync_status';
	public const KEY_MC_STATUS              = 'mc_status';
	public const KEY_NOTIFICATION_STATUS    = 'notification_status';

	protected const TYPES = [
		self::KEY_SYNCED_AT              => 'int',
		self::KEY_GOOGLE_IDS             => 'array',
		self::KEY_VISIBILITY             => 'string',
		self::KEY_ERRORS                 => 'array',
		self::KEY_FAILED_DELETE_ATTEMPTS => 'int',
		self::KEY_FAILED_SYNC_ATTEMPTS   => 'int',
		self::KEY_SYNC_FAILED_AT         => 'int',
		self::KEY_SYNC_STATUS            => 'string',
		self::KEY_MC_STATUS              => 'string',
		self::KEY_NOTIFICATION_STATUS    => 'string',
	];

	/**
	 * @param string $name
	 * @param mixed  $arguments
	 *
	 * @return mixed
	 *
	 * @throws BadMethodCallException If the method that's called doesn't exist.
	 * @throws InvalidMeta            If the meta key is invalid.
	 */
	public function __call( string $name, $arguments ) {
		$found_matches = preg_match( '/^([a-z]+)_([\w\d]+)$/i', $name, $matches );

		if ( ! $found_matches ) {
			throw new BadMethodCallException( sprintf( 'The method %s does not exist in class ProductMetaHandler', $name ) );
		}

		[ $function_name, $method, $key ] = $matches;

		// validate the method
		if ( ! in_array( $method, [ 'update', 'delete', 'get' ], true ) ) {
			throw new BadMethodCallException( sprintf( 'The method %s does not exist in class ProductMetaHandler', $function_name ) );
		}

		// set the value as the third argument if method is `update`
		if ( 'update' === $method ) {
			$arguments[2] = $arguments[1];
		}
		// set the key as the second argument
		$arguments[1] = $key;

		return call_user_func_array( [ $this, $method ], $arguments );
	}

	/**
	 * @param WC_Product $product
	 * @param string     $key
	 * @param mixed      $value
	 *
	 * @throws InvalidMeta If the meta key is invalid.
	 */
	public function update( WC_Product $product, string $key, $value ) {
		self::validate_meta_key( $key );

		if ( isset( self::TYPES[ $key ] ) ) {
			if ( in_array( self::TYPES[ $key ], [ 'bool', 'boolean' ], true ) ) {
				$value = wc_bool_to_string( $value );
			} else {
				settype( $value, self::TYPES[ $key ] );
			}
		}

		$product->update_meta_data( $this->prefix_meta_key( $key ), $value );
		$product->save_meta_data();
	}

	/**
	 * @param WC_Product $product
	 * @param string     $key
	 *
	 * @throws InvalidMeta If the meta key is invalid.
	 */
	public function delete( WC_Product $product, string $key ) {
		self::validate_meta_key( $key );

		$product->delete_meta_data( $this->prefix_meta_key( $key ) );
		$product->save_meta_data();
	}

	/**
	 * @param WC_Product $product
	 * @param string     $key
	 *
	 * @return mixed The value, or null if the meta key doesn't exist.
	 *
	 * @throws InvalidMeta If the meta key is invalid.
	 */
	public function get( WC_Product $product, string $key ) {
		self::validate_meta_key( $key );

		$value = null;
		if ( $product->meta_exists( $this->prefix_meta_key( $key ) ) ) {
			$value = $product->get_meta( $this->prefix_meta_key( $key ), true );

			if ( isset( self::TYPES[ $key ] ) && in_array( self::TYPES[ $key ], [ 'bool', 'boolean' ], true ) ) {
				$value = wc_string_to_bool( $value );
			}
		}

		return $value;
	}

	/**
	 * @param string $key
	 *
	 * @throws InvalidMeta If the meta key is invalid.
	 */
	protected static function validate_meta_key( string $key ) {
		if ( ! self::is_meta_key_valid( $key ) ) {
			do_action(
				'woocommerce_gla_error',
				sprintf( 'Product meta key is invalid: %s', $key ),
				__METHOD__
			);

			throw InvalidMeta::invalid_key( $key );
		}
	}

	/**
	 * @param string $key
	 *
	 * @return bool Whether the meta key is valid.
	 */
	public static function is_meta_key_valid( string $key ): bool {
		return isset( self::TYPES[ $key ] );
	}

	/**
	 * Register a service.
	 */
	public function register(): void {
		add_filter(
			'woocommerce_product_data_store_cpt_get_products_query',
			function ( array $query, array $query_vars ) {
				return $this->handle_query_vars( $query, $query_vars );
			},
			10,
			2
		);
	}

	/**
	 * Handle the WooCommerce product's meta data query vars.
	 *
	 * @hooked handle_query_vars
	 *
	 * @param array $query      Args for WP_Query.
	 * @param array $query_vars Query vars from WC_Product_Query.
	 *
	 * @return array modified $query
	 */
	protected function handle_query_vars( array $query, array $query_vars ): array {
		if ( ! empty( $query_vars['meta_query'] ) ) {
			$meta_query = $this->sanitize_meta_query( $query_vars['meta_query'] );
			if ( ! empty( $meta_query ) ) {
				$query['meta_query'] = array_merge( $query['meta_query'], $meta_query );
			}
		}

		return $query;
	}

	/**
	 * Ensure the 'meta_query' argument passed to self::handle_query_vars is well-formed.
	 *
	 * @param array $queries Array of meta query clauses.
	 *
	 * @return array Sanitized array of meta query clauses.
	 */
	protected function sanitize_meta_query( $queries ): array {
		$prefixed_valid_keys = array_map( [ $this, 'prefix_meta_key' ], array_keys( self::TYPES ) );
		$clean_queries       = [];

		if ( ! is_array( $queries ) ) {
			return $clean_queries;
		}

		foreach ( $queries as $key => $meta_query ) {
			if ( 'relation' !== $key && ! is_array( $meta_query ) ) {
				continue;
			}

			if ( 'relation' === $key && is_string( $meta_query ) ) {
				$clean_queries[ $key ] = $meta_query;

				// First-order clause.
			} elseif ( isset( $meta_query['key'] ) || isset( $meta_query['value'] ) ) {
				if ( in_array( $meta_query['key'], $prefixed_valid_keys, true ) ) {
					$clean_queries[ $key ] = $meta_query;
				}

				// Otherwise, it's a nested meta_query, so we recurse.
			} else {
				$cleaned_query = $this->sanitize_meta_query( $meta_query );

				if ( ! empty( $cleaned_query ) ) {
					$clean_queries[ $key ] = $cleaned_query;
				}
			}
		}

		return $clean_queries;
	}

	/**
	 * @param array $meta_queries
	 *
	 * @return array
	 */
	public function prefix_meta_query_keys( $meta_queries ): array {
		$updated_queries = [];
		if ( ! is_array( $meta_queries ) ) {
			return $updated_queries;
		}

		foreach ( $meta_queries as $key => $meta_query ) {
			// First-order clause.
			if ( 'relation' === $key && is_string( $meta_query ) ) {
				$updated_queries[ $key ] = $meta_query;

				// First-order clause.
			} elseif ( isset( $meta_query['key'] ) || isset( $meta_query['value'] ) ) {
				if ( self::is_meta_key_valid( $meta_query['key'] ) ) {
					$meta_query['key'] = $this->prefix_meta_key( $meta_query['key'] );
				}
			} else {
				// Otherwise, it's a nested meta_query, so we recurse.
				$meta_query = $this->prefix_meta_query_keys( $meta_query );
			}

			$updated_queries[ $key ] = $meta_query;
		}

		return $updated_queries;
	}

	/**
	 * Returns all available meta keys.
	 *
	 * @return array
	 */
	public static function get_all_meta_keys(): array {
		return array_keys( self::TYPES );
	}
}
ProductRepository.php000064400000030522151542451210010777 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;

use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility;
use WC_Product;

defined( 'ABSPATH' ) || exit;

/**
 * Class ProductRepository
 *
 * Contains methods to find and retrieve products from database.
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product
 */
class ProductRepository implements Service {

	use PluginHelper;

	/**
	 * @var ProductMetaHandler
	 */
	protected $meta_handler;

	/**
	 * @var ProductFilter
	 */
	protected $product_filter;

	/**
	 * ProductRepository constructor.
	 *
	 * @param ProductMetaHandler $meta_handler
	 * @param ProductFilter      $product_filter
	 */
	public function __construct( ProductMetaHandler $meta_handler, ProductFilter $product_filter ) {
		$this->meta_handler   = $meta_handler;
		$this->product_filter = $product_filter;
	}

	/**
	 * Find and return an array of WooCommerce product objects based on the provided arguments.
	 *
	 * @param array $args   Array of WooCommerce args (except 'return'), and product metadata.
	 * @param int   $limit  Maximum number of results to retrieve or -1 for unlimited.
	 * @param int   $offset Amount to offset product results.
	 *
	 * @see execute_woocommerce_query For more information about the arguments.
	 *
	 * @return WC_Product[] Array of WooCommerce product objects
	 */
	public function find( array $args = [], int $limit = -1, int $offset = 0 ): array {
		$args['return'] = 'objects';

		return $this->execute_woocommerce_query( $args, $limit, $offset );
	}

	/**
	 * Find and return an array of WooCommerce product IDs based on the provided arguments.
	 *
	 * @param array $args   Array of WooCommerce args (except 'return'), and product metadata.
	 * @param int   $limit  Maximum number of results to retrieve or -1 for unlimited.
	 * @param int   $offset Amount to offset product results.
	 *
	 * @see execute_woocommerce_query For more information about the arguments.
	 *
	 * @return int[] Array of WooCommerce product IDs
	 */
	public function find_ids( array $args = [], int $limit = -1, int $offset = 0 ): array {
		$args['return'] = 'ids';

		return $this->execute_woocommerce_query( $args, $limit, $offset );
	}

	/**
	 * Find and return an array of WooCommerce product objects based on the provided product IDs.
	 *
	 * @param int[] $ids    Array of WooCommerce product IDs
	 * @param array $args   Array of WooCommerce args (except 'return'), and product metadata.
	 * @param int   $limit  Maximum number of results to retrieve or -1 for unlimited.
	 * @param int   $offset Amount to offset product results.
	 *
	 * @return WC_Product[] Array of WooCommerce product objects
	 */
	public function find_by_ids( array $ids, array $args = [], int $limit = -1, int $offset = 0 ): array {
		// If no product IDs are supplied then return early to avoid querying and loading every product.
		if ( empty( $ids ) ) {
			return [];
		}

		$args['include'] = $ids;

		return $this->find( $args, $limit, $offset );
	}

	/**
	 * Find and return an associative array of products with the product ID as the key.
	 *
	 * @param int[] $ids    Array of WooCommerce product IDs
	 * @param array $args   Array of WooCommerce args (except 'return'), and product metadata.
	 * @param int   $limit  Maximum number of results to retrieve or -1 for unlimited.
	 * @param int   $offset Amount to offset product results.
	 *
	 * @return WC_Product[] Array of WooCommerce product objects
	 */
	public function find_by_ids_as_associative_array( array $ids, array $args = [], int $limit = -1, int $offset = 0 ): array {
		$products = $this->find_by_ids( $ids, $args, $limit, $offset );
		$map      = [];
		foreach ( $products as $product ) {
			$map[ $product->get_id() ] = $product;
		}
		return $map;
	}

	/**
	 * Find and return an array of WooCommerce product objects already submitted to Google Merchant Center.
	 *
	 * @param array $args   Array of WooCommerce args (except 'return' and 'meta_query').
	 * @param int   $limit  Maximum number of results to retrieve or -1 for unlimited.
	 * @param int   $offset Amount to offset product results.
	 *
	 * @return WC_Product[] Array of WooCommerce product objects
	 */
	public function find_synced_products( array $args = [], int $limit = -1, int $offset = 0 ): array {
		$args['meta_query'] = $this->get_synced_products_meta_query();

		return $this->find( $args, $limit, $offset );
	}

	/**
	 * Find and return an array of WooCommerce product IDs already submitted to Google Merchant Center.
	 *
	 * Note: Includes product variations.
	 *
	 * @param array $args  Array of WooCommerce args (except 'return' and 'meta_query').
	 * @param int   $limit  Maximum number of results to retrieve or -1 for unlimited.
	 * @param int   $offset Amount to offset product results.
	 *
	 * @return int[] Array of WooCommerce product IDs
	 */
	public function find_synced_product_ids( array $args = [], int $limit = -1, int $offset = 0 ): array {
		$args['meta_query'] = $this->get_synced_products_meta_query();

		return $this->find_ids( $args, $limit, $offset );
	}

	/**
	 * @return array
	 */
	protected function get_synced_products_meta_query(): array {
		return [
			[
				'key'     => ProductMetaHandler::KEY_GOOGLE_IDS,
				'compare' => 'EXISTS',
			],
		];
	}

	/**
	 * Find and return an array of WooCommerce product objects ready to be submitted to Google Merchant Center.
	 *
	 * @param array $args   Array of WooCommerce args (except 'return'), and product metadata.
	 * @param int   $limit  Maximum number of results to retrieve or -1 for unlimited.
	 * @param int   $offset Amount to offset product results.
	 *
	 * @return FilteredProductList List of WooCommerce product objects after filtering.
	 */
	public function find_sync_ready_products( array $args = [], int $limit = - 1, int $offset = 0 ): FilteredProductList {
		$results = $this->find( $this->get_sync_ready_products_query_args( $args ), $limit, $offset );

		return $this->product_filter->filter_sync_ready_products( $results );
	}

	/**
	 * Find and return an array of WooCommerce product ID's ready to be deleted from the Google Merchant Center.
	 *
	 * @since 1.12.0
	 *
	 * @param int[] $ids    Array of WooCommerce product IDs
	 * @param int   $limit  Maximum number of results to retrieve or -1 for unlimited.
	 * @param int   $offset Amount to offset product results.
	 *
	 * @return array
	 */
	public function find_delete_product_ids( array $ids, int $limit = - 1, int $offset = 0 ): array {
		// Default status query args in WC_Product_Query plus status trash.
		$args    = [ 'status' => [ 'draft', 'pending', 'private', 'publish', 'trash' ] ];
		$results = $this->find_by_ids( $ids, $args, $limit, $offset );
		return $this->product_filter->filter_products_for_delete( $results )->get_product_ids();
	}

	/**
	 * @return array
	 */
	protected function get_sync_ready_products_meta_query(): array {
		return [
			'relation' => 'OR',
			[
				'key'     => ProductMetaHandler::KEY_VISIBILITY,
				'compare' => 'NOT EXISTS',
			],
			[
				'key'     => ProductMetaHandler::KEY_VISIBILITY,
				'compare' => '!=',
				'value'   => ChannelVisibility::DONT_SYNC_AND_SHOW,
			],
		];
	}

	/**
	 * @param array $args Array of WooCommerce args (except 'return'), and product metadata.
	 *
	 * @return array
	 */
	protected function get_sync_ready_products_query_args( array $args = [] ): array {
		$args['meta_query'] = $this->get_sync_ready_products_meta_query();

		// don't include variable products in query
		$args['type'] = array_diff( ProductSyncer::get_supported_product_types(), [ 'variable' ] );

		// only include published products
		if ( empty( $args['status'] ) ) {
			$args['status'] = [ 'publish' ];
		}

		return $args;
	}

	/**
	 * @return array
	 */
	protected function get_valid_products_meta_query(): array {
		return [
			'relation' => 'OR',
			[
				'key'     => ProductMetaHandler::KEY_ERRORS,
				'compare' => 'NOT EXISTS',
			],
			[
				'key'     => ProductMetaHandler::KEY_ERRORS,
				'compare' => '=',
				'value'   => '',
			],
		];
	}

	/**
	 * Find and return an array of WooCommerce product IDs nearly expired and ready to be re-submitted to Google Merchant Center.
	 *
	 * @param int $limit  Maximum number of results to retrieve or -1 for unlimited.
	 * @param int $offset Amount to offset product results.
	 *
	 * @return int[] Array of WooCommerce product IDs
	 */
	public function find_expiring_product_ids( int $limit = - 1, int $offset = 0 ): array {
		$args['meta_query'] = [
			'relation' => 'AND',
			$this->get_sync_ready_products_meta_query(),
			$this->get_valid_products_meta_query(),
			[
				[
					'key'     => ProductMetaHandler::KEY_SYNCED_AT,
					'compare' => '<',
					'value'   => strtotime( '-25 days' ),
				],
			],
		];

		return $this->find_ids( $args, $limit, $offset );
	}

	/**
	 * Find all simple and variable product IDs regardless of MC status or visibility.
	 *
	 * @since 2.6.4
	 *
	 * @param int $limit  Maximum number of results to retrieve or -1 for unlimited.
	 * @param int $offset Amount to offset product results.
	 *
	 * @return int[] Array of WooCommerce product IDs
	 */
	public function find_all_product_ids( int $limit = -1, int $offset = 0 ): array {
		$args = [
			'status' => 'publish',
			'return' => 'ids',
			'type'   => 'any',
		];

		return $this->find_ids( $args, $limit, $offset );
	}

	/**
	 * Returns an array of Google Product IDs associated with all synced WooCommerce products.
	 * Note: excludes variable parent products as only the child variation products are actually synced
	 * to Merchant Center
	 *
	 * @since 1.1.0
	 *
	 * @return array Google Product IDS
	 */
	public function find_all_synced_google_ids(): array {
		// Don't include variable parent products as they aren't actually synced to Merchant Center.
		$args['type']        = array_diff( ProductSyncer::get_supported_product_types(), [ 'variable' ] );
		$synced_product_ids  = $this->find_synced_product_ids( $args );
		$google_ids_meta_key = $this->prefix_meta_key( ProductMetaHandler::KEY_GOOGLE_IDS );
		$synced_google_ids   = [];
		foreach ( $synced_product_ids as $product_id ) {
			$meta_google_ids = get_post_meta( $product_id, $google_ids_meta_key, true );
			if ( ! is_array( $meta_google_ids ) ) {
				do_action(
					'woocommerce_gla_debug_message',
					sprintf( 'Invalid Google IDs retrieve for product %d', $product_id ),
					__METHOD__
				);
				continue;
			}
			$synced_google_ids = array_merge( $synced_google_ids, array_values( $meta_google_ids ) );
		}
		return $synced_google_ids;
	}

	/**
	 * Find and return an array of WooCommerce products based on the provided arguments.
	 *
	 * @param array $args   Array of WooCommerce args (see below), and product metadata.
	 * @param int   $limit  Maximum number of results to retrieve or -1 for unlimited.
	 * @param int   $offset Amount to offset product results.
	 *
	 * @link https://github.com/woocommerce/woocommerce/wiki/wc_get_products-and-WC_Product_Query
	 * @see ProductMetaHandler::TYPES For the list of meta data that can be used as query arguments.
	 *
	 * @return WC_Product[]|int[] Array of WooCommerce product objects or IDs, depending on the 'return' argument.
	 */
	protected function execute_woocommerce_query( array $args = [], int $limit = -1, int $offset = 0 ): array {
		$args['limit']  = $limit;
		$args['offset'] = $offset;

		return wc_get_products( $this->prepare_query_args( $args ) );
	}

	/**
	 * @param array $args Array of WooCommerce args (except 'return'), and product metadata.
	 *
	 * @see execute_woocommerce_query For more information about the arguments.
	 *
	 * @return array
	 */
	protected function prepare_query_args( array $args = [] ): array {
		if ( empty( $args ) ) {
			return [];
		}

		if ( ! empty( $args['meta_query'] ) ) {
			$args['meta_query'] = $this->meta_handler->prefix_meta_query_keys( $args['meta_query'] );
		}

		// only include supported product types
		if ( empty( $args['type'] ) ) {
			$args['type'] = ProductSyncer::get_supported_product_types();
		}

		// It'll fetch all products with the post_type of 'product', excluding variations.
		if ( $args['type'] === 'any' ) {
			unset( $args['type'] );
		}

		// use no ordering unless specified in arguments. overrides the default WooCommerce query args
		if ( empty( $args['orderby'] ) ) {
			$args['orderby'] = 'none';
		}

		$args = apply_filters( 'woocommerce_gla_product_query_args', $args );

		return $args;
	}
}
ProductSyncer.php000064400000031111151542451210010056 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;

use Automattic\WooCommerce\GoogleListingsAndAds\Google\BatchInvalidProductEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\BatchProductIDRequestEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\BatchProductRequestEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\BatchProductResponse;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleProductService;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Exception;
use WC_Product;

defined( 'ABSPATH' ) || exit;

/**
 * Class ProductSyncer
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product
 */
class ProductSyncer implements Service {

	public const FAILURE_THRESHOLD        = 5;         // Number of failed attempts allowed per FAILURE_THRESHOLD_WINDOW
	public const FAILURE_THRESHOLD_WINDOW = '3 hours'; // PHP supported Date and Time format: https://www.php.net/manual/en/datetime.formats.php

	/**
	 * @var GoogleProductService
	 */
	protected $google_service;

	/**
	 * @var BatchProductHelper
	 */
	protected $batch_helper;

	/**
	 * @var ProductHelper
	 */
	protected $product_helper;

	/**
	 * @var MerchantCenterService
	 */
	protected $merchant_center;

	/**
	 * @var WC
	 */
	protected $wc;

	/**
	 * @var ProductRepository
	 */
	protected $product_repository;

	/**
	 * ProductSyncer constructor.
	 *
	 * @param GoogleProductService  $google_service
	 * @param BatchProductHelper    $batch_helper
	 * @param ProductHelper         $product_helper
	 * @param MerchantCenterService $merchant_center
	 * @param WC                    $wc
	 * @param ProductRepository     $product_repository
	 */
	public function __construct(
		GoogleProductService $google_service,
		BatchProductHelper $batch_helper,
		ProductHelper $product_helper,
		MerchantCenterService $merchant_center,
		WC $wc,
		ProductRepository $product_repository
	) {
		$this->google_service     = $google_service;
		$this->batch_helper       = $batch_helper;
		$this->product_helper     = $product_helper;
		$this->merchant_center    = $merchant_center;
		$this->wc                 = $wc;
		$this->product_repository = $product_repository;
	}

	/**
	 * Submits an array of WooCommerce products to Google Merchant Center.
	 *
	 * @param WC_Product[] $products
	 *
	 * @return BatchProductResponse Containing both the synced and invalid products.
	 *
	 * @throws ProductSyncerException If there are any errors while syncing products with Google Merchant Center.
	 */
	public function update( array $products ): BatchProductResponse {
		$this->validate_merchant_center_setup();

		// prepare and validate products
		$product_entries = $this->batch_helper->validate_and_generate_update_request_entries( $products );

		return $this->update_by_batch_requests( $product_entries );
	}

	/**
	 * Submits an array of WooCommerce products to Google Merchant Center.
	 *
	 * @param BatchProductRequestEntry[] $product_entries
	 *
	 * @return BatchProductResponse Containing both the synced and invalid products.
	 *
	 * @throws ProductSyncerException If there are any errors while syncing products with Google Merchant Center.
	 */
	public function update_by_batch_requests( array $product_entries ): BatchProductResponse {
		$this->validate_merchant_center_setup();

		// bail if no valid products provided
		if ( empty( $product_entries ) ) {
			return new BatchProductResponse( [], [] );
		}

		$updated_products = [];
		$invalid_products = [];
		foreach ( array_chunk( $product_entries, GoogleProductService::BATCH_SIZE ) as $batch_entries ) {
			try {
				$response = $this->google_service->insert_batch( $batch_entries );

				$updated_products = array_merge( $updated_products, $response->get_products() );
				$invalid_products = array_merge( $invalid_products, $response->get_errors() );

				// update the meta data for the synced and invalid products
				array_walk( $updated_products, [ $this->batch_helper, 'mark_as_synced' ] );
				array_walk( $invalid_products, [ $this->batch_helper, 'mark_as_invalid' ] );
			} catch ( Exception $exception ) {
				do_action( 'woocommerce_gla_exception', $exception, __METHOD__ );

				throw new ProductSyncerException( sprintf( 'Error updating Google products: %s', $exception->getMessage() ), 0, $exception );
			}
		}

		$this->handle_update_errors( $invalid_products );

		do_action(
			'woocommerce_gla_batch_updated_products',
			$updated_products,
			$invalid_products
		);

		do_action(
			'woocommerce_gla_debug_message',
			sprintf(
				"Submitted %s products:\n%s",
				count( $updated_products ),
				wp_json_encode( $updated_products )
			),
			__METHOD__
		);
		if ( ! empty( $invalid_products ) ) {
			do_action(
				'woocommerce_gla_debug_message',
				sprintf(
					"%s products failed to sync with Merchant Center:\n%s",
					count( $invalid_products ),
					wp_json_encode( $invalid_products )
				),
				__METHOD__
			);
		}

		return new BatchProductResponse( $updated_products, $invalid_products );
	}

	/**
	 * Deletes an array of WooCommerce products from Google Merchant Center.
	 *
	 * @param WC_Product[] $products
	 *
	 * @return BatchProductResponse Containing both the deleted and invalid products.
	 *
	 * @throws ProductSyncerException If there are any errors while deleting products from Google Merchant Center.
	 */
	public function delete( array $products ): BatchProductResponse {
		$this->validate_merchant_center_setup();

		$synced_products = $this->batch_helper->filter_synced_products( $products );
		$product_entries = $this->batch_helper->generate_delete_request_entries( $synced_products );

		return $this->delete_by_batch_requests( $product_entries );
	}

	/**
	 * Deletes an array of WooCommerce products from Google Merchant Center.
	 *
	 * Note: This method does not automatically delete variations of a parent product. They each must be provided via the $product_entries argument.
	 *
	 * @param BatchProductIDRequestEntry[] $product_entries
	 *
	 * @return BatchProductResponse Containing both the deleted and invalid products (including their variation).
	 *
	 * @throws ProductSyncerException If there are any errors while deleting products from Google Merchant Center.
	 */
	public function delete_by_batch_requests( array $product_entries ): BatchProductResponse {
		$this->validate_merchant_center_setup();

		// return empty response if no synced product found
		if ( empty( $product_entries ) ) {
			return new BatchProductResponse( [], [] );
		}

		$deleted_products = [];
		$invalid_products = [];
		foreach ( array_chunk( $product_entries, GoogleProductService::BATCH_SIZE ) as $batch_entries ) {
			try {
				$response = $this->google_service->delete_batch( $batch_entries );

				$deleted_products = array_merge( $deleted_products, $response->get_products() );
				$invalid_products = array_merge( $invalid_products, $response->get_errors() );

				array_walk( $deleted_products, [ $this->batch_helper, 'mark_as_unsynced' ] );
			} catch ( Exception $exception ) {
				do_action( 'woocommerce_gla_exception', $exception, __METHOD__ );

				throw new ProductSyncerException( sprintf( 'Error deleting Google products: %s', $exception->getMessage() ), 0, $exception );
			}
		}

		$this->handle_delete_errors( $invalid_products );

		do_action(
			'woocommerce_gla_batch_deleted_products',
			$deleted_products,
			$invalid_products
		);

		do_action(
			'woocommerce_gla_debug_message',
			sprintf(
				"Deleted %s products:\n%s",
				count( $deleted_products ),
				wp_json_encode( $deleted_products ),
			),
			__METHOD__
		);
		if ( ! empty( $invalid_products ) ) {
			do_action(
				'woocommerce_gla_debug_message',
				sprintf(
					"Failed to delete %s products from Merchant Center:\n%s",
					count( $invalid_products ),
					wp_json_encode( $invalid_products )
				),
				__METHOD__
			);
		}

		return new BatchProductResponse( $deleted_products, $invalid_products );
	}

	/**
	 * Return the list of supported product types.
	 *
	 * @return array
	 */
	public static function get_supported_product_types(): array {
		return (array) apply_filters( 'woocommerce_gla_supported_product_types', [ 'simple', 'variable', 'variation' ] );
	}

	/**
	 * @param BatchInvalidProductEntry[] $invalid_products
	 */
	protected function handle_update_errors( array $invalid_products ) {
		$error_products = [];
		foreach ( $invalid_products as $invalid_product ) {
			if ( $invalid_product->has_error( GoogleProductService::INTERNAL_ERROR_REASON ) ) {
				$wc_product_id = $invalid_product->get_wc_product_id();
				$wc_product    = $this->wc->maybe_get_product( $wc_product_id );
				// Only schedule for retry if the failure threshold has not been reached.
				if (
					$wc_product instanceof WC_Product &&
					! $this->product_helper->is_update_failed_threshold_reached( $wc_product )
				) {
					$error_products[ $wc_product_id ] = $wc_product_id;
				}
			}
		}

		if ( ! empty( $error_products ) && apply_filters( 'woocommerce_gla_products_update_retry_on_failure', true, $invalid_products ) ) {
			do_action( 'woocommerce_gla_batch_retry_update_products', $error_products );

			do_action(
				'woocommerce_gla_error',
				sprintf( 'Internal API errors while submitting the following products: %s', join( ', ', $error_products ) ),
				__METHOD__
			);
		}
	}

	/**
	 * @param BatchInvalidProductEntry[] $invalid_products
	 */
	protected function handle_delete_errors( array $invalid_products ) {
		$internal_error_ids = [];
		foreach ( $invalid_products as $invalid_product ) {
			$google_product_id = $invalid_product->get_google_product_id();
			$wc_product_id     = $invalid_product->get_wc_product_id();
			$wc_product        = $this->wc->maybe_get_product( $wc_product_id );
			if ( ! $wc_product instanceof WC_Product || empty( $google_product_id ) ) {
				continue;
			}

			// not found
			if ( $invalid_product->has_error( GoogleProductService::NOT_FOUND_ERROR_REASON ) ) {
				do_action(
					'woocommerce_gla_error',
					sprintf(
						'Attempted to delete product "%s" (WooCommerce Product ID: %s) but it did not exist in Google Merchant Center, removing the synced product ID from database.',
						$google_product_id,
						$wc_product_id
					),
					__METHOD__
				);

				$this->product_helper->remove_google_id( $wc_product, $google_product_id );
			}

			// internal error
			if ( $invalid_product->has_error( GoogleProductService::INTERNAL_ERROR_REASON ) ) {
				$this->product_helper->increment_failed_delete_attempt( $wc_product );

				// Only schedule for retry if the failure threshold has not been reached.
				if ( ! $this->product_helper->is_delete_failed_threshold_reached( $wc_product ) ) {
					$internal_error_ids[ $google_product_id ] = $wc_product_id;
				}
			}
		}

		// Exclude any ID's which are not ready to delete or are not available in the DB.
		$product_ids        = array_values( $internal_error_ids );
		$ready_ids          = $this->product_repository->find_delete_product_ids( $product_ids );
		$internal_error_ids = array_intersect( $internal_error_ids, $ready_ids );

		// call an action to retry if any products with internal errors exist
		if ( ! empty( $internal_error_ids ) && apply_filters( 'woocommerce_gla_products_delete_retry_on_failure', true, $invalid_products ) ) {
			do_action( 'woocommerce_gla_batch_retry_delete_products', $internal_error_ids );

			do_action(
				'woocommerce_gla_error',
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions
				sprintf( 'Internal API errors while deleting the following products: %s', print_r( $internal_error_ids, true ) ),
				__METHOD__
			);
		}
	}

	/**
	 * Validates whether Merchant Center is connected and ready for pushing data.
	 *
	 * @throws ProductSyncerException If the Google Merchant Center connection is not ready or cannot push data.
	 */
	protected function validate_merchant_center_setup(): void {
		if ( ! $this->merchant_center->is_ready_for_syncing() ) {
			do_action( 'woocommerce_gla_error', 'Cannot sync any products before setting up Google Merchant Center.', __METHOD__ );

			throw new ProductSyncerException( __( 'Google Merchant Center has not been set up correctly. Please review your configuration.', 'google-listings-and-ads' ) );
		}

		if ( ! $this->merchant_center->should_push() ) {
			do_action(
				'woocommerce_gla_error',
				'Cannot push any products because they are being fetched automatically.',
				__METHOD__
			);

			throw new ProductSyncerException(
				__(
					'Pushing products will not run if the automatic data fetching is enabled. Please review your configuration in Google Listing and Ads settings.',
					'google-listings-and-ads'
				)
			);
		}
	}
}
ProductSyncerException.php000064400000000661151542451210011743 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;

use Automattic\WooCommerce\GoogleListingsAndAds\Exception\GoogleListingsAndAdsException;
use Exception;

defined( 'ABSPATH' ) || exit;

/**
 * Class ProductSyncerException
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product
 */
class ProductSyncerException extends Exception implements GoogleListingsAndAdsException {
}
SyncerHooks.php000064400000034471151542451210007535 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;

use Automattic\WooCommerce\GoogleListingsAndAds\Google\BatchProductIDRequestEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\API\WP\NotificationsService;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\DeleteProducts;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications\ProductNotificationJob;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateProducts;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\NotificationStatus;
use WC_Product;
use WC_Product_Variable;

defined( 'ABSPATH' ) || exit;

/**
 * Class SyncerHooks
 *
 * Hooks to various WooCommerce and WordPress actions to provide automatic product sync functionality.
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product
 */
class SyncerHooks implements Service, Registerable {

	use PluginHelper;

	protected const SCHEDULE_TYPE_UPDATE = 'update';
	protected const SCHEDULE_TYPE_DELETE = 'delete';

	/**
	 * Array of strings mapped to product IDs indicating that they have been already
	 * scheduled for update or delete during current request. Used to avoid scheduling
	 * duplicate jobs.
	 *
	 * @var string[]
	 */
	protected $already_scheduled = [];

	/**
	 * @var BatchProductIDRequestEntry[][]
	 */
	protected $delete_requests_map;

	/**
	 * @var BatchProductHelper
	 */
	protected $batch_helper;

	/**
	 * @var ProductHelper
	 */
	protected $product_helper;

	/**
	 * @var JobRepository
	 */
	protected $job_repository;

	/**
	 * @var MerchantCenterService
	 */
	protected $merchant_center;

	/**
	 * @var NotificationsService
	 */
	protected $notifications_service;

	/**
	 * @var WC
	 */
	protected $wc;

	/**
	 * SyncerHooks constructor.
	 *
	 * @param BatchProductHelper    $batch_helper
	 * @param ProductHelper         $product_helper
	 * @param JobRepository         $job_repository
	 * @param MerchantCenterService $merchant_center
	 * @param NotificationsService  $notifications_service
	 * @param WC                    $wc
	 */
	public function __construct(
		BatchProductHelper $batch_helper,
		ProductHelper $product_helper,
		JobRepository $job_repository,
		MerchantCenterService $merchant_center,
		NotificationsService $notifications_service,
		WC $wc
	) {
		$this->batch_helper          = $batch_helper;
		$this->product_helper        = $product_helper;
		$this->job_repository        = $job_repository;
		$this->merchant_center       = $merchant_center;
		$this->notifications_service = $notifications_service;
		$this->wc                    = $wc;
	}

	/**
	 * Register a service.
	 */
	public function register(): void {
		// only register the hooks if Merchant Center is connected correctly.
		if ( ! $this->merchant_center->is_ready_for_syncing() ) {
			return;
		}

		// when a product is added / updated, schedule an "update" job.
		add_action( 'woocommerce_new_product', [ $this, 'update_by_id' ], 90 );
		add_action( 'woocommerce_new_product_variation', [ $this, 'update_by_id' ], 90 );
		add_action( 'woocommerce_update_product', [ $this, 'update_by_object' ], 90, 2 );
		add_action( 'woocommerce_update_product_variation', [ $this, 'update_by_object' ], 90, 2 );

		// if we don't attach to these we miss product gallery updates.
		add_action( 'woocommerce_process_product_meta', [ $this, 'update_by_id' ], 90 );

		// when a product is trashed or removed, schedule a "delete" job.
		add_action( 'wp_trash_post', [ $this, 'pre_delete' ], 90 );
		add_action( 'before_delete_post', [ $this, 'pre_delete' ], 90 );
		add_action( 'woocommerce_before_delete_product_variation', [ $this, 'pre_delete' ], 90 );
		add_action( 'trashed_post', [ $this, 'delete' ], 90 );
		add_action( 'deleted_post', [ $this, 'delete' ], 90 );

		// when a product is restored from the trash, schedule an "update" job.
		add_action( 'untrashed_post', [ $this, 'update_by_id' ], 90 );

		// exclude the sync metadata when duplicating the product
		add_filter(
			'woocommerce_duplicate_product_exclude_meta',
			[ $this, 'duplicate_product_exclude_meta' ],
			90
		);
	}

	/**
	 * Update a Product by WC_Product
	 *
	 * @param int        $product_id
	 * @param WC_Product $product
	 */
	public function update_by_object( int $product_id, WC_Product $product ) {
		$this->handle_update_products( [ $product ] );
	}

	/**
	 * Update a Product by the ID
	 *
	 * @param int $product_id
	 */
	public function update_by_id( int $product_id ) {
		$product = $this->wc->maybe_get_product( $product_id );
		$this->handle_update_products( [ $product ] );
	}

	/**
	 * Pre delete a Product by the ID
	 *
	 * @param int $product_id
	 */
	public function pre_delete( int $product_id ) {
		$this->handle_pre_delete_product( $product_id );
	}

	/**
	 * Delete a Product by the ID
	 *
	 * @param int $product_id
	 */
	public function delete( int $product_id ) {
		$this->handle_delete_product( $product_id );
	}

	/**
	 * Filters woocommerce_duplicate_product_exclude_meta adding some custom prefix
	 *
	 * @param array $exclude_meta
	 * @return array
	 */
	public function duplicate_product_exclude_meta( array $exclude_meta ): array {
		return $this->get_duplicated_product_excluded_meta( $exclude_meta );
	}

	/**
	 * Handle updating of a product.
	 *
	 * @param WC_Product[] $products The products being saved.
	 * @param bool         $notify If true. It will try to handle notifications.
	 *
	 * @return void
	 */
	protected function handle_update_products( array $products, $notify = true ) {
		$products_to_update = [];
		$products_to_delete = [];

		foreach ( $products as $product ) {

			if ( ! $product instanceof WC_Product ) {
				continue;
			}

			$product_id = $product->get_id();

			// Avoid to handle variations directly. We handle them from the parent.
			if ( $this->notifications_service->is_ready() && $notify ) {
				$this->handle_update_product_notification( $product );
			}

			// Bail if an event is already scheduled for this product in the current request
			if ( $this->is_already_scheduled_to_update( $product_id ) ) {
				continue;
			}

			// If it's a variable product we handle each variation separately
			if ( $product instanceof WC_Product_Variable ) {
				// This is only for MC Push mechanism. We don't handle notifications here.
				$this->handle_update_products( $product->get_available_variations( 'objects' ), false );
				continue;
			}

			// Schedule an update job if product sync is enabled.
			if ( $this->product_helper->is_sync_ready( $product ) ) {
				$this->product_helper->mark_as_pending( $product );
				$products_to_update[] = $product->get_id();
				$this->set_already_scheduled_to_update( $product_id );
			} elseif ( $this->product_helper->is_product_synced( $product ) ) {
				// Delete the product from Google Merchant Center if it's already synced BUT it is not sync ready after the edit.
				$products_to_delete[] = $product;
				$this->set_already_scheduled_to_delete( $product_id );

				do_action(
					'woocommerce_gla_debug_message',
					sprintf( 'Deleting product (ID: %s) from Google Merchant Center because it is not ready to be synced.', $product->get_id() ),
					__METHOD__
				);
			} else {
				$this->product_helper->mark_as_unsynced( $product );
			}
		}

		if ( ! empty( $products_to_update ) ) {
			$this->job_repository->get( UpdateProducts::class )->schedule( [ $products_to_update ] );
		}

		if ( ! empty( $products_to_delete ) ) {
			$request_entries = $this->batch_helper->generate_delete_request_entries( $products_to_delete );
			$this->job_repository->get( DeleteProducts::class )->schedule( [ BatchProductIDRequestEntry::convert_to_id_map( $request_entries )->get() ] );
		}
	}

	/**
	 * Schedules notifications for an updated product
	 *
	 * @param WC_Product $product
	 */
	protected function handle_update_product_notification( WC_Product $product ) {
		if ( $this->product_helper->should_trigger_create_notification( $product ) ) {
			$this->product_helper->set_notification_status( $product, NotificationStatus::NOTIFICATION_PENDING_CREATE );
			$this->job_repository->get( ProductNotificationJob::class )->schedule(
				[
					'item_id' => $product->get_id(),
					'topic'   => NotificationsService::TOPIC_PRODUCT_CREATED,
				]
			);
		} elseif ( $this->product_helper->should_trigger_update_notification( $product ) ) {
			$this->product_helper->set_notification_status( $product, NotificationStatus::NOTIFICATION_PENDING_UPDATE );
			$this->job_repository->get( ProductNotificationJob::class )->schedule(
				[
					'item_id' => $product->get_id(),
					'topic'   => NotificationsService::TOPIC_PRODUCT_UPDATED,
				]
			);
		} elseif ( $this->product_helper->should_trigger_delete_notification( $product ) ) {
			$this->schedule_delete_notification( $product );
			// Schedule variation deletion when the parent is deleted.
			if ( $product instanceof WC_Product_Variable ) {
				foreach ( $product->get_available_variations( 'objects' ) as $variation ) {
					$this->handle_update_product_notification( $variation );
				}
			}
		}
	}

	/**
	 * Handle deleting of a product.
	 *
	 * @param int $product_id
	 */
	protected function handle_delete_product( int $product_id ) {
		if ( isset( $this->delete_requests_map[ $product_id ] ) ) {
			$product_id_map = BatchProductIDRequestEntry::convert_to_id_map( $this->delete_requests_map[ $product_id ] )->get();
			if ( ! empty( $product_id_map ) && ! $this->is_already_scheduled_to_delete( $product_id ) ) {
				$this->job_repository->get( DeleteProducts::class )->schedule( [ $product_id_map ] );
				$this->set_already_scheduled_to_delete( $product_id );
			}
		}
	}

	/**
	 * Maybe send the product deletion notification
	 * and mark the product as un-synced after.
	 *
	 * @since 2.8.0
	 * @param int $product_id
	 */
	protected function maybe_send_delete_notification( int $product_id ) {
		$product = $this->wc->maybe_get_product( $product_id );
		if ( $product instanceof WC_Product && $this->product_helper->has_notified_creation( $product ) ) {
			$result = $this->notifications_service->notify( NotificationsService::TOPIC_PRODUCT_DELETED, $product_id, [ 'offer_id' => $this->product_helper->get_offer_id( $product_id ) ] );
			if ( $result ) {
				$this->product_helper->set_notification_status( $product, NotificationStatus::NOTIFICATION_DELETED );
				$this->product_helper->mark_as_unsynced( $product );
			}
		}
	}

	/**
	 * Schedules a job to send the product deletion notification
	 *
	 * @since 2.8.0
	 * @param WC_Product $product
	 */
	protected function schedule_delete_notification( $product ) {
		$this->product_helper->set_notification_status( $product, NotificationStatus::NOTIFICATION_PENDING_DELETE );
		$this->job_repository->get( ProductNotificationJob::class )->schedule(
			[
				'item_id' => $product->get_id(),
				'topic'   => NotificationsService::TOPIC_PRODUCT_DELETED,
			]
		);
	}

	/**
	 * Create request entries for the product (containing its Google ID) so that we can schedule a delete job when the
	 * product is actually trashed / deleted.
	 *
	 * @param int $product_id
	 */
	protected function handle_pre_delete_product( int $product_id ) {
		if ( $this->notifications_service->is_ready() ) {
			/**
			 * For deletions, we do send directly the notification instead of scheduling it.
			 * This is because we want to avoid that the product is not in the database anymore when the scheduled action runs.
			 */
			$this->maybe_send_delete_notification( $product_id );
		}

		$product = $this->wc->maybe_get_product( $product_id );

		// each variation is passed to this method separately so we don't need to delete the variable product
		if ( $product instanceof WC_Product && ! $product instanceof WC_Product_Variable && $this->product_helper->is_product_synced( $product ) ) {
			$this->delete_requests_map[ $product_id ] = $this->batch_helper->generate_delete_request_entries( [ $product ] );
		}
	}

	/**
	 * Return the list of metadata keys to be excluded when duplicating a product.
	 *
	 * @param array $exclude_meta The keys to exclude from the duplicate.
	 *
	 * @return array
	 */
	protected function get_duplicated_product_excluded_meta( array $exclude_meta ): array {
		$exclude_meta[] = $this->prefix_meta_key( ProductMetaHandler::KEY_SYNCED_AT );
		$exclude_meta[] = $this->prefix_meta_key( ProductMetaHandler::KEY_GOOGLE_IDS );
		$exclude_meta[] = $this->prefix_meta_key( ProductMetaHandler::KEY_ERRORS );
		$exclude_meta[] = $this->prefix_meta_key( ProductMetaHandler::KEY_FAILED_SYNC_ATTEMPTS );
		$exclude_meta[] = $this->prefix_meta_key( ProductMetaHandler::KEY_SYNC_FAILED_AT );
		$exclude_meta[] = $this->prefix_meta_key( ProductMetaHandler::KEY_SYNC_STATUS );
		$exclude_meta[] = $this->prefix_meta_key( ProductMetaHandler::KEY_MC_STATUS );

		return $exclude_meta;
	}

	/**
	 * @param int    $product_id
	 * @param string $schedule_type
	 *
	 * @return bool
	 */
	protected function is_already_scheduled( int $product_id, string $schedule_type ): bool {
		return isset( $this->already_scheduled[ $product_id ] ) && $this->already_scheduled[ $product_id ] === $schedule_type;
	}

	/**
	 * @param int $product_id
	 *
	 * @return bool
	 */
	protected function is_already_scheduled_to_update( int $product_id ): bool {
		return $this->is_already_scheduled( $product_id, self::SCHEDULE_TYPE_UPDATE );
	}

	/**
	 * @param int $product_id
	 *
	 * @return bool
	 */
	protected function is_already_scheduled_to_delete( int $product_id ): bool {
		return $this->is_already_scheduled( $product_id, self::SCHEDULE_TYPE_DELETE );
	}

	/**
	 * @param int    $product_id
	 * @param string $schedule_type
	 *
	 * @return void
	 */
	protected function set_already_scheduled( int $product_id, string $schedule_type ): void {
		$this->already_scheduled[ $product_id ] = $schedule_type;
	}

	/**
	 * @param int $product_id
	 *
	 * @return void
	 */
	protected function set_already_scheduled_to_update( int $product_id ): void {
		$this->set_already_scheduled( $product_id, self::SCHEDULE_TYPE_UPDATE );
	}

	/**
	 * @param int $product_id
	 *
	 * @return void
	 */
	protected function set_already_scheduled_to_delete( int $product_id ): void {
		$this->set_already_scheduled( $product_id, self::SCHEDULE_TYPE_DELETE );
	}
}
WCProductAdapter.php000064400000113010151542451210010424 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;

use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\AttributeMapping\AttributeMappingHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\Condition;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\SizeSystem;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\SizeType;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\AgeGroup;
use Automattic\WooCommerce\GoogleListingsAndAds\Validator\GooglePriceConstraint;
use Automattic\WooCommerce\GoogleListingsAndAds\Validator\ImageUrlConstraint;
use Automattic\WooCommerce\GoogleListingsAndAds\Validator\Validatable;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Price as GooglePrice;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Product as GoogleProduct;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ProductShipping as GoogleProductShipping;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ProductShippingDimension as GoogleProductShippingDimension;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ProductShippingWeight as GoogleProductShippingWeight;
use DateInterval;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Mapping\ClassMetadata;
use WC_DateTime;
use WC_Product;
use WC_Product_Variable;
use WC_Product_Variation;

defined( 'ABSPATH' ) || exit;

/**
 * Class WCProductAdapter
 *
 * This class adapts the WooCommerce Product class to the Google's Product class by mapping their attributes.
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product
 */
class WCProductAdapter extends GoogleProduct implements Validatable {
	use PluginHelper;

	public const AVAILABILITY_IN_STOCK     = 'in_stock';
	public const AVAILABILITY_OUT_OF_STOCK = 'out_of_stock';
	public const AVAILABILITY_BACKORDER    = 'backorder';
	public const AVAILABILITY_PREORDER     = 'preorder';

	public const IMAGE_SIZE_FULL = 'full';

	public const CHANNEL_ONLINE = 'online';

	/**
	 * @var WC_Product WooCommerce product object
	 */
	protected $wc_product;

	/**
	 * @var WC_Product WooCommerce parent product object if $wc_product is a variation
	 */
	protected $parent_wc_product;

	/**
	 * @var bool Whether tax is excluded from product price
	 */
	protected $tax_excluded;

	/**
	 * @var array Product category ids
	 */
	protected $product_category_ids;

	/**
	 * Initialize this object's properties from an array.
	 *
	 * @param array $properties Used to seed this object's properties.
	 *
	 * @return void
	 *
	 * @throws InvalidValue When a WooCommerce product is not provided or it is invalid.
	 */
	public function mapTypes( $properties ) {
		if ( empty( $properties['wc_product'] ) || ! $properties['wc_product'] instanceof WC_Product ) {
			throw InvalidValue::not_instance_of( WC_Product::class, 'wc_product' );
		}

		// throw an exception if the parent product isn't provided and this is a variation
		if ( $properties['wc_product'] instanceof WC_Product_Variation &&
			( empty( $properties['parent_wc_product'] ) || ! $properties['parent_wc_product'] instanceof WC_Product_Variable )
		) {
			throw InvalidValue::not_instance_of( WC_Product_Variable::class, 'parent_wc_product' );
		}

		if ( empty( $properties['targetCountry'] ) ) {
			throw InvalidValue::is_empty( 'targetCountry' );
		}

		$this->wc_product        = $properties['wc_product'];
		$this->parent_wc_product = $properties['parent_wc_product'] ?? null;

		$mapping_rules  = $properties['mapping_rules'] ?? [];
		$gla_attributes = $properties['gla_attributes'] ?? [];

		// Google doesn't expect extra fields, so it's best to remove them
		unset( $properties['wc_product'] );
		unset( $properties['parent_wc_product'] );
		unset( $properties['gla_attributes'] );
		unset( $properties['mapping_rules'] );

		parent::mapTypes( $properties );
		$this->map_woocommerce_product();
		$this->map_attribute_mapping_rules( $mapping_rules );
		$this->map_gla_attributes( $gla_attributes );
		$this->map_gtin();

		// Allow users to override the product's attributes using a WordPress filter.
		$this->override_attributes();
	}

	/**
	 * Map the WooCommerce product attributes to the current class.
	 *
	 * @return void
	 */
	protected function map_woocommerce_product() {
		$this->setChannel( self::CHANNEL_ONLINE );

		$content_language = empty( get_locale() ) ? 'en' : strtolower( substr( get_locale(), 0, 2 ) ); // ISO 639-1.
		$this->setContentLanguage( $content_language );

		$this->map_wc_product_id()
			->map_wc_general_attributes()
			->map_product_categories()
			->map_wc_product_image( self::IMAGE_SIZE_FULL )
			->map_wc_availability()
			->map_wc_product_shipping()
			->map_wc_prices();
	}

	/**
	 * Overrides the product attributes by applying a filter and setting the provided values.
	 *
	 * @since 1.4.0
	 */
	protected function override_attributes() {
		/**
		 * Filters the list of overridden attributes to set for this product.
		 *
		 * Note: This filter takes precedence over any other filter that modify products attributes. Including
		 *       `woocommerce_gla_product_attribute_value_{$attribute_id}` defined in self::map_gla_attributes.
		 *
		 * @param array            $attributes An array of values for the product properties. All properties of the
		 *                                     `\Google\Service\ShoppingContent\Product` class can be set by providing
		 *                                     the property name as key and its value as array item.
		 *                                     For example:
		 *                                     [ 'imageLink' => 'https://example.com/image.jpg' ] overrides the product's
		 *                                     main image.
		 *
		 * @param WC_Product       $wc_product The WooCommerce product object.
		 * @param WCProductAdapter $this       The Adapted Google product object. All WooCommerce product properties
		 *                                     are already mapped to this object.
		 *
		 * @see \Google\Service\ShoppingContent\Product for the list of product properties that can be overriden.
		 * @see WCProductAdapter::map_gla_attributes for the docuementation of `woocommerce_gla_product_attribute_value_{$attribute_id}`
		 *                                           filter, which allows modifying some attributes such as GTIN, MPN, etc.
		 *
		 * @since 1.4.0
		 */
		$attributes = apply_filters( 'woocommerce_gla_product_attribute_values', [], $this->wc_product, $this );

		if ( ! empty( $attributes ) ) {
			parent::mapTypes( $attributes );
		}
	}

	/**
	 * Map the general WooCommerce product attributes.
	 *
	 * @return $this
	 */
	protected function map_wc_general_attributes() {
		$this->setTitle( $this->wc_product->get_title() );
		$this->setDescription( $this->get_wc_product_description() );
		$this->setLink( $this->wc_product->get_permalink() );

		// set item group id for variations
		if ( $this->is_variation() ) {
			$this->setItemGroupId( $this->parent_wc_product->get_id() );
		}
		return $this;
	}

	/**
	 * Map WooCommerce product categories to Google product types.
	 *
	 * @return $this
	 */
	protected function map_product_categories() {
		// set product type using merchants defined product categories
		$base_product_id = $this->is_variation() ? $this->parent_wc_product->get_id() : $this->wc_product->get_id();

		// Fetch only selected term ids without parents.
		$this->product_category_ids = wc_get_product_term_ids( $base_product_id, 'product_cat' );

		if ( ! empty( $this->product_category_ids ) ) {
			$google_product_types = self::convert_product_types( $this->product_category_ids );
			do_action(
				'woocommerce_gla_debug_message',
				sprintf(
					'Product category (ID: %s): %s.',
					$base_product_id,
					wp_json_encode( $google_product_types )
				),
				__METHOD__
			);
			$google_product_types = array_slice( $google_product_types, 0, 10 );
			$this->setProductTypes( $google_product_types );
		}
		return $this;
	}
	/**
	 * Covert WooCommerce product categories to product_type, which follows Google requirements:
	 * https://support.google.com/merchants/answer/6324406?hl=en#
	 *
	 * @param int[] $category_ids
	 *
	 * @return array
	 */
	public static function convert_product_types( $category_ids ): array {
		$product_types = [];
		foreach ( array_unique( $category_ids ) as $category_id ) {
			if ( ! is_int( $category_id ) ) {
				continue;
			}

			$product_type = self::get_product_type_by_id( $category_id );
			array_push( $product_types, $product_type );
		}

		return $product_types;
	}

	/**
	 * Return category names including ancestors, separated by ">"
	 *
	 * @param int $category_id
	 *
	 * @return string
	 */
	protected static function get_product_type_by_id( int $category_id ): string {
		$category_names = [];
		do {
			$term = get_term_by( 'id', $category_id, 'product_cat', 'ARRAY_A' );
			array_push( $category_names, $term['name'] );
			$category_id = $term['parent'];
		} while ( ! empty( $term['parent'] ) );

		return implode( ' > ', array_reverse( $category_names ) );
	}

	/**
	 * Map the WooCommerce product ID.
	 *
	 * @return $this
	 */
	protected function map_wc_product_id(): WCProductAdapter {
		$this->setOfferId( self::get_google_product_offer_id( $this->get_slug(), $this->wc_product->get_id() ) );
		return $this;
	}

	/**
	 *
	 * @param string $slug
	 * @param int    $product_id
	 * @return string
	 */
	public static function get_google_product_offer_id( string $slug, int $product_id ): string {
		/**
		 * Filters a WooCommerce product ID to be used as the Merchant Center product ID.
		 *
		 * @param string $mc_product_id Default generated Merchant Center product ID.
		 * @param int    $product_id    WooCommerce product ID.
		 * @since 2.4.6
		 *
		 * @return string Merchant Center product ID corresponding to the given WooCommerce product ID.
		 */
		return apply_filters( 'woocommerce_gla_get_google_product_offer_id', "{$slug}_{$product_id}", $product_id );
	}

	/**
	 * Get the description for the WooCommerce product.
	 *
	 * @return string
	 */
	protected function get_wc_product_description(): string {
		/**
		 * Filters whether the short product description should be used for the synced product.
		 *
		 * @param bool $use_short_description
		 */
		$use_short_description = apply_filters( 'woocommerce_gla_use_short_description', false );

		$description = ! empty( $this->wc_product->get_description() ) && ! $use_short_description ?
			$this->wc_product->get_description() :
			$this->wc_product->get_short_description();

		// prepend the parent product description to the variation product
		if ( $this->is_variation() ) {
			$parent_description = ! empty( $this->parent_wc_product->get_description() ) && ! $use_short_description ?
				$this->parent_wc_product->get_description() :
				$this->parent_wc_product->get_short_description();
			$new_line           = ! empty( $description ) && ! empty( $parent_description ) ? PHP_EOL : '';
			$description        = $parent_description . $new_line . $description;
		}

		/**
		 * Filters whether the shortcodes should be applied for product descriptions when syncing a product or be stripped out.
		 *
		 * @since 1.4.0
		 *
		 * @param bool       $apply_shortcodes Shortcodes are applied if set to `true` and stripped out if set to `false`.
		 * @param WC_Product $wc_product       WooCommerce product object.
		 */
		$apply_shortcodes = apply_filters( 'woocommerce_gla_product_description_apply_shortcodes', false, $this->wc_product );
		if ( $apply_shortcodes ) {
			// Apply active shortcodes
			$description = do_shortcode( $description );
		} else {
			// Strip out active shortcodes
			$description = strip_shortcodes( $description );
		}

		// Strip out invalid unicode.
		$description = mb_convert_encoding( $description, 'UTF-8', 'UTF-8' );
		$description = preg_replace(
			'/[\x00-\x08\x0B\x0C\x0E-\x1F\x80-\x9F]/u',
			'',
			$description
		);

		// Strip out invalid HTML tags (e.g. script, style, canvas, etc.) along with attributes of all tags.
		$valid_html_tags   = array_keys( wp_kses_allowed_html( 'post' ) );
		$kses_allowed_tags = array_fill_keys( $valid_html_tags, [] );
		$description       = wp_kses( $description, $kses_allowed_tags );

		// Trim the description if it's more than 5000 characters.
		$description = mb_substr( $description, 0, 5000, 'utf-8' );

		/**
		 * Filters the product's description.
		 *
		 * @param string     $description Product description.
		 * @param WC_Product $wc_product  WooCommerce product object.
		 */
		return apply_filters( 'woocommerce_gla_product_attribute_value_description', $description, $this->wc_product );
	}

	/**
	 * Map the WooCommerce product images.
	 *
	 * @param string $image_size
	 *
	 * @return $this
	 */
	protected function map_wc_product_image( string $image_size ) {
		$image_id          = $this->wc_product->get_image_id();
		$gallery_image_ids = $this->wc_product->get_gallery_image_ids() ?: [];

		// check if we can use the parent product image if it's a variation
		if ( $this->is_variation() ) {
			$image_id              = $image_id ?? $this->parent_wc_product->get_image_id();
			$parent_gallery_images = $this->parent_wc_product->get_gallery_image_ids() ?: [];
			$gallery_image_ids     = ! empty( $gallery_image_ids ) ? $gallery_image_ids : $parent_gallery_images;
		}

		// use a gallery image as the main product image if no main image is available
		if ( empty( $image_id ) && ! empty( $gallery_image_ids[0] ) ) {
			$image_id = $gallery_image_ids[0];

			// remove the recently set main image from the list of gallery images
			unset( $gallery_image_ids[0] );
		}

		// set main image
		$image_link = wp_get_attachment_image_url( $image_id, $image_size, false );
		$this->setImageLink( $image_link );

		// set additional images
		$gallery_image_links = array_map(
			function ( $gallery_image_id ) use ( $image_size ) {
				return wp_get_attachment_image_url( $gallery_image_id, $image_size, false );
			},
			$gallery_image_ids
		);
		// Uniquify the set of additional images
		$gallery_image_links = array_unique( $gallery_image_links, SORT_REGULAR );

		// Limit additional image links up to 10
		$gallery_image_links = array_slice( $gallery_image_links, 0, 10 );

		$this->setAdditionalImageLinks( $gallery_image_links );

		return $this;
	}

	/**
	 * Map the general WooCommerce product attributes.
	 *
	 * @return $this
	 */
	protected function map_wc_availability() {
		if ( ! $this->wc_product->is_in_stock() ) {
			$availability = self::AVAILABILITY_OUT_OF_STOCK;
		} elseif ( $this->wc_product->is_on_backorder( 1 ) ) {
			$availability = self::AVAILABILITY_BACKORDER;
		} else {
			$availability = self::AVAILABILITY_IN_STOCK;
		}

		$this->setAvailability( $availability );

		return $this;
	}

	/**
	 * Map the shipping information for WooCommerce product.
	 *
	 * @return $this
	 */
	protected function map_wc_product_shipping(): WCProductAdapter {
		$this->add_shipping_country( $this->getTargetCountry() );

		if ( ! $this->is_virtual() ) {
			$dimension_unit = apply_filters( 'woocommerce_gla_dimension_unit', get_option( 'woocommerce_dimension_unit' ) );
			$weight_unit    = apply_filters( 'woocommerce_gla_weight_unit', get_option( 'woocommerce_weight_unit' ) );

			$this->map_wc_shipping_dimensions( $dimension_unit )
				->map_wc_shipping_weight( $weight_unit );
		}

		// Set the product's shipping class slug as the shipping label.
		$shipping_class = $this->wc_product->get_shipping_class();
		if ( ! empty( $shipping_class ) ) {
			$this->setShippingLabel( $shipping_class );
		}

		return $this;
	}

	/**
	 * Add a shipping country for the product.
	 *
	 * @param string $country
	 */
	public function add_shipping_country( string $country ): void {
		$product_shipping = [
			'country' => $country,
		];

		// Virtual products should override any country shipping cost.
		if ( $this->is_virtual() ) {
			$product_shipping['price'] = [
				'currency' => get_woocommerce_currency(),
				'value'    => 0,
			];
		}

		$new_shipping = [
			new GoogleProductShipping( $product_shipping ),
		];

		if ( ! $this->shipping_country_exists( $country ) ) {
			$current_shipping = $this->getShipping() ?? [];
			$this->setShipping( array_merge( $current_shipping, $new_shipping ) );
		}
	}

	/**
	 * Remove a shipping country from the product.
	 *
	 * @param string $country
	 *
	 * @since 1.2.0
	 */
	public function remove_shipping_country( string $country ): void {
		$product_shippings = $this->getShipping() ?? [];

		foreach ( $product_shippings as $index => $shipping ) {
			if ( $country === $shipping->getCountry() ) {
				unset( $product_shippings[ $index ] );
			}
		}

		$this->setShipping( $product_shippings );
	}

	/**
	 * @param string $country
	 *
	 * @return bool
	 */
	protected function shipping_country_exists( string $country ): bool {
		$current_shipping = $this->getShipping() ?? [];

		foreach ( $current_shipping as $shipping ) {
			if ( $country === $shipping->getCountry() ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Map the measurements for the WooCommerce product.
	 *
	 * @param string $unit
	 *
	 * @return $this
	 */
	protected function map_wc_shipping_dimensions( string $unit = 'cm' ): WCProductAdapter {
		$length = $this->wc_product->get_length();
		$width  = $this->wc_product->get_width();
		$height = $this->wc_product->get_height();

		// Use cm if the unit isn't supported.
		if ( ! in_array( $unit, [ 'in', 'cm' ], true ) ) {
			$unit = 'cm';
		}
		$length = wc_get_dimension( (float) $length, $unit );
		$width  = wc_get_dimension( (float) $width, $unit );
		$height = wc_get_dimension( (float) $height, $unit );

		if ( $length > 0 && $width > 0 && $height > 0 ) {
			$this->setShippingLength(
				new GoogleProductShippingDimension(
					[
						'unit'  => $unit,
						'value' => $length,
					]
				)
			);
			$this->setShippingWidth(
				new GoogleProductShippingDimension(
					[
						'unit'  => $unit,
						'value' => $width,
					]
				)
			);
			$this->setShippingHeight(
				new GoogleProductShippingDimension(
					[
						'unit'  => $unit,
						'value' => $height,
					]
				)
			);
		}

		return $this;
	}

	/**
	 * Map the weight for the WooCommerce product.
	 *
	 * @param string $unit
	 *
	 * @return $this
	 */
	protected function map_wc_shipping_weight( string $unit = 'g' ): WCProductAdapter {
		// Use g if the unit isn't supported.
		if ( ! in_array( $unit, [ 'g', 'lbs', 'oz' ], true ) ) {
			$unit = 'g';
		}

		$weight = wc_get_weight( $this->wc_product->get_weight(), $unit );

		// Use lb if the unit is lbs, since GMC uses lb.
		if ( 'lbs' === $unit ) {
			$unit = 'lb';
		}

		$this->setShippingWeight(
			new GoogleProductShippingWeight(
				[
					'unit'  => $unit,
					'value' => $weight,
				]
			)
		);

		return $this;
	}

	/**
	 * Sets whether tax is excluded from product price.
	 *
	 * @return $this
	 */
	protected function map_tax_excluded(): WCProductAdapter {
		// tax is excluded from price in US and CA
		$this->tax_excluded = in_array( $this->getTargetCountry(), [ 'US', 'CA' ], true );
		$this->tax_excluded = boolval( apply_filters( 'woocommerce_gla_tax_excluded', $this->tax_excluded ) );

		return $this;
	}

	/**
	 * Map the prices (base and sale price) for the product.
	 *
	 * @return $this
	 */
	protected function map_wc_prices(): WCProductAdapter {
		$this->map_tax_excluded();
		$this->map_wc_product_price( $this->wc_product );

		return $this;
	}

	/**
	 * Map the prices (base and sale price) for a given WooCommerce product.
	 *
	 * @param WC_Product $product
	 *
	 * @return $this
	 */
	protected function map_wc_product_price( WC_Product $product ): WCProductAdapter {
		// set regular price
		$regular_price = $product->get_regular_price();
		if ( '' !== $regular_price ) {
			$price = $this->tax_excluded ?
				wc_get_price_excluding_tax( $product, [ 'price' => $regular_price ] ) :
				wc_get_price_including_tax( $product, [ 'price' => $regular_price ] );

			/**
			 * Filters the calculated product price.
			 *
			 * @param float      $price        Calculated price of the product
			 * @param WC_Product $product      WooCommerce product
			 * @param bool       $tax_excluded Whether tax is excluded from product price
			 */
			$price = apply_filters( 'woocommerce_gla_product_attribute_value_price', $price, $product, $this->tax_excluded );

			$this->setPrice(
				new GooglePrice(
					[
						'currency' => get_woocommerce_currency(),
						'value'    => $price,
					]
				)
			);
		}
		// set sale price
		$this->map_wc_product_sale_price( $product );

		return $this;
	}

	/**
	 * Map the sale price and sale effective date for a given WooCommerce product.
	 *
	 * @param WC_Product $product
	 *
	 * @return $this
	 */
	protected function map_wc_product_sale_price( WC_Product $product ): WCProductAdapter {
		// Grab the sale price of the base product. Some plugins (Dynamic
		// pricing as an example) filter the active price, but not the sale
		// price. If the active price < the regular price treat it as a sale
		// price.
		$regular_price = $product->get_regular_price();
		$sale_price    = $product->get_sale_price();
		$active_price  = $product->get_price();
		if (
			( empty( $sale_price ) && $active_price < $regular_price ) ||
			( ! empty( $sale_price ) && $active_price < $sale_price )
		) {
			$sale_price = $active_price;
		}

		// set sale price and sale effective date if any
		if ( '' !== $sale_price ) {
			$sale_price = $this->tax_excluded ?
				wc_get_price_excluding_tax( $product, [ 'price' => $sale_price ] ) :
				wc_get_price_including_tax( $product, [ 'price' => $sale_price ] );

			/**
			 * Filters the calculated product sale price.
			 *
			 * @param float      $sale_price   Calculated sale price of the product
			 * @param WC_Product $product      WooCommerce product
			 * @param bool       $tax_excluded Whether tax is excluded from product price
			 */
			$sale_price = apply_filters( 'woocommerce_gla_product_attribute_value_sale_price', $sale_price, $product, $this->tax_excluded );

			// If the sale price dates no longer apply, make sure we don't include a sale price.
			$now                 = new WC_DateTime();
			$sale_price_end_date = $product->get_date_on_sale_to();
			if ( empty( $sale_price_end_date ) || $sale_price_end_date >= $now ) {
				$this->setSalePrice(
					new GooglePrice(
						[
							'currency' => get_woocommerce_currency(),
							'value'    => $sale_price,
						]
					)
				);

				$this->setSalePriceEffectiveDate( $this->get_wc_product_sale_price_effective_date( $product ) );
			}
		}

		return $this;
	}

	/**
	 * Return the sale effective dates for the WooCommerce product.
	 *
	 * @param WC_Product $product
	 *
	 * @return string|null
	 */
	protected function get_wc_product_sale_price_effective_date( WC_Product $product ): ?string {
		$start_date = $product->get_date_on_sale_from();
		$end_date   = $product->get_date_on_sale_to();

		$now = new WC_DateTime();
		// if we have a sale end date in the future, but no start date, set the start date to now()
		if (
			! empty( $end_date ) &&
			$end_date > $now &&
			empty( $start_date )
		) {
			$start_date = $now;
		}
		// if we have a sale start date in the past, but no end date, do not include the start date.
		if (
			! empty( $start_date ) &&
			$start_date < $now &&
			empty( $end_date )
		) {
			$start_date = null;
		}
		// if we have a start date in the future, but no end date, assume a one-day sale.
		if (
			! empty( $start_date ) &&
			$start_date > $now &&
			empty( $end_date )
		) {
			$end_date = clone $start_date;
			$end_date->add( new DateInterval( 'P1D' ) );
		}

		if ( empty( $start_date ) && empty( $end_date ) ) {
			return null;
		}

		return sprintf( '%s/%s', (string) $start_date, (string) $end_date );
	}

	/**
	 * Return whether the WooCommerce product is a variation.
	 *
	 * @return bool
	 */
	public function is_variation(): bool {
		return $this->wc_product instanceof WC_Product_Variation;
	}

	/**
	 * Return whether the WooCommerce product is virtual.
	 *
	 * @return bool
	 */
	public function is_virtual(): bool {
		$is_virtual = $this->wc_product->is_virtual();

		/**
		 * Filters the virtual property value of a product.
		 *
		 * @param bool       $is_virtual Whether a product is virtual
		 * @param WC_Product $product    WooCommerce product
		 */
		$is_virtual = apply_filters( 'woocommerce_gla_product_property_value_is_virtual', $is_virtual, $this->wc_product );

		return false !== $is_virtual;
	}

	/**
	 * @param ClassMetadata $metadata
	 */
	public static function load_validator_metadata( ClassMetadata $metadata ) {
		$metadata->addPropertyConstraint( 'offerId', new Assert\NotBlank() );
		$metadata->addPropertyConstraint( 'title', new Assert\NotBlank() );
		$metadata->addPropertyConstraint( 'description', new Assert\NotBlank() );

		$metadata->addPropertyConstraint( 'link', new Assert\NotBlank() );
		$metadata->addPropertyConstraint( 'link', new Assert\Url() );

		$metadata->addPropertyConstraint( 'imageLink', new Assert\NotBlank() );
		$metadata->addPropertyConstraint( 'imageLink', new ImageUrlConstraint() );
		$metadata->addPropertyConstraint(
			'additionalImageLinks',
			new Assert\All(
				[
					'constraints' => [ new ImageUrlConstraint() ],
				]
			)
		);

		$metadata->addGetterConstraint( 'price', new Assert\NotNull() );
		$metadata->addGetterConstraint( 'price', new GooglePriceConstraint() );
		$metadata->addGetterConstraint( 'salePrice', new GooglePriceConstraint() );

		$metadata->addConstraint( new Assert\Callback( 'validate_item_group_id' ) );
		$metadata->addConstraint( new Assert\Callback( 'validate_availability' ) );

		$metadata->addPropertyConstraint( 'gtin', new Assert\Regex( '/^\d{8}(?:\d{4,6})?$/' ) );
		$metadata->addPropertyConstraint( 'mpn', new Assert\Type( 'string' ) );
		$metadata->addPropertyConstraint( 'mpn', new Assert\Length( null, 0, 70 ) ); // maximum 70 characters

		$metadata->addPropertyConstraint(
			'sizes',
			new Assert\All(
				[
					'constraints' => [
						new Assert\Type( 'string' ),
						new Assert\Length( null, 0, 100 ), // maximum 100 characters
					],
				]
			)
		);
		$metadata->addPropertyConstraint( 'sizeSystem', new Assert\Choice( array_keys( SizeSystem::get_value_options() ) ) );
		$metadata->addPropertyConstraint( 'sizeType', new Assert\Choice( array_keys( SizeType::get_value_options() ) ) );

		$metadata->addPropertyConstraint( 'color', new Assert\Length( null, 0, 100 ) ); // maximum 100 characters
		$metadata->addPropertyConstraint( 'material', new Assert\Length( null, 0, 200 ) ); // maximum 200 characters
		$metadata->addPropertyConstraint( 'pattern', new Assert\Length( null, 0, 100 ) ); // maximum 200 characters

		$metadata->addPropertyConstraint( 'ageGroup', new Assert\Choice( array_keys( AgeGroup::get_value_options() ) ) );
		$metadata->addPropertyConstraint( 'adult', new Assert\Type( 'boolean' ) );

		$metadata->addPropertyConstraint( 'condition', new Assert\Choice( array_keys( Condition::get_value_options() ) ) );

		$metadata->addPropertyConstraint( 'multipack', new Assert\Type( 'integer' ) );
		$metadata->addPropertyConstraint( 'multipack', new Assert\PositiveOrZero() );

		$metadata->addPropertyConstraint( 'isBundle', new Assert\Type( 'boolean' ) );
	}

	/**
	 * Used by the validator to check if the variation product has an itemGroupId
	 *
	 * @param ExecutionContextInterface $context
	 */
	public function validate_item_group_id( ExecutionContextInterface $context ) {
		if ( $this->is_variation() && empty( $this->getItemGroupId() ) ) {
			$context->buildViolation( 'ItemGroupId needs to be set for variable products.' )
					->atPath( 'itemGroupId' )
					->addViolation();
		}
	}

	/**
	 * Used by the validator to check if the availability date is set for product available as `backorder` or
	 * `preorder`.
	 *
	 * @param ExecutionContextInterface $context
	 */
	public function validate_availability( ExecutionContextInterface $context ) {
		if (
			( self::AVAILABILITY_BACKORDER === $this->getAvailability() || self::AVAILABILITY_PREORDER === $this->getAvailability() ) &&
			empty( $this->getAvailabilityDate() )
		) {
			$context->buildViolation( 'Availability date is required if you set the product\'s availability to backorder or pre-order.' )
					->atPath( 'availabilityDate' )
					->addViolation();
		}
	}

	/**
	 * @return WC_Product
	 */
	public function get_wc_product(): WC_Product {
		return $this->wc_product;
	}

	/**
	 * @param array $attributes Attribute values
	 *
	 * @return $this
	 */
	protected function map_gla_attributes( array $attributes ): WCProductAdapter {
		$gla_attributes = [];
		foreach ( $attributes as $attribute_id => $attribute_value ) {
			if ( property_exists( $this, $attribute_id ) ) {
				/**
				 * Filters a product attribute's value.
				 *
				 * This only applies to the extra attributes defined in `AttributeManager::ATTRIBUTES`
				 * like GTIN, MPN, Brand, Size, etc. and it cannot modify other product attributes.
				 *
				 * This filter also cannot add or set a new attribute or modify one that isn't currently
				 * set for the product through WooCommerce's edit product page
				 *
				 * In order to override all product attributes and/or set new ones for the product use the
				 * `woocommerce_gla_product_attribute_values` filter.
				 *
				 * Note that the `woocommerce_gla_product_attribute_values` filter takes precedence over
				 * this filter, and it can be used to override any values defined here.
				 *
				 * @param mixed      $attribute_value The attribute's current value
				 * @param WC_Product $wc_product      The WooCommerce product object.
				 *
				 * @see AttributeManager::ATTRIBUTES for the list of attributes that their values can be modified using this filter.
				 * @see WCProductAdapter::override_attributes for the documentation of the `woocommerce_gla_product_attribute_values` filter.
				 */
				$gla_attributes[ $attribute_id ] = apply_filters( "woocommerce_gla_product_attribute_value_{$attribute_id}", $attribute_value, $this->get_wc_product() );
			}
		}

		parent::mapTypes( $gla_attributes );

		// Size
		if ( ! empty( $attributes['size'] ) ) {
			$this->setSizes( [ $attributes['size'] ] );
		}

		return $this;
	}

	/**
	 * Map the WooCommerce core global unique ID (GTIN) value if it's available.
	 *
	 * @since 2.9.0
	 *
	 * @return $this
	 */
	protected function map_gtin(): WCProductAdapter {
		// compatibility-code "WC < 9.2" -- Core global unique ID field was added in 9.2
		if ( ! method_exists( $this->wc_product, 'get_global_unique_id' ) ) {
			return $this;
		}

		// avoid dashes and other unsupported format
		$global_unique_id = preg_replace( '/[^0-9]/', '', $this->wc_product->get_global_unique_id() );

		if ( ! empty( $global_unique_id ) ) {
			$this->setGtin( $global_unique_id );
		}

		return $this;
	}

	/**
	 * @param string $targetCountry
	 *
	 * phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
	 */
	public function setTargetCountry( $targetCountry ) {
		// remove shipping for current target country
		$this->remove_shipping_country( $this->getTargetCountry() );

		// set the new target country
		parent::setTargetCountry( $targetCountry );

		// we need to reset the prices because tax is based on the country
		$this->map_wc_prices();

		// product shipping information is also country based
		$this->map_wc_product_shipping();
	}

	/**
	 * Performs the attribute mapping.
	 * This function applies rules setting values for the different attributes in the product.
	 *
	 * @param array $mapping_rules The set of rules to apply
	 */
	protected function map_attribute_mapping_rules( array $mapping_rules ) {
		$attributes = [];

		if ( empty( $mapping_rules ) ) {
			return $this;
		}

		foreach ( $mapping_rules as $mapping_rule ) {
			if ( $this->rule_match_conditions( $mapping_rule ) ) {
				$attribute_id                = $mapping_rule['attribute'];
				$attributes[ $attribute_id ] = $this->format_attribute(
					apply_filters(
						"woocommerce_gla_product_attribute_value_{$attribute_id}",
						$this->get_source( $mapping_rule['source'] ),
						$this->get_wc_product()
					),
					$attribute_id
				);
			}
		}

		parent::mapTypes( $attributes );

		// Size
		if ( ! empty( $attributes['size'] ) ) {
			$this->setSizes( [ $attributes['size'] ] );
		}

		return $this;
	}


	/**
	 * Get a source value for attribute mapping
	 *
	 * @param string $source The source to get the value
	 * @return string The source value for this product
	 */
	protected function get_source( string $source ) {
		$source_type = null;

		$type_separator = strpos( $source, ':' );

		if ( $type_separator ) {
			$source_type  = substr( $source, 0, $type_separator );
			$source_value = substr( $source, $type_separator + 1 );
		}

		// Detect if the source_type is kind of product, taxonomy or attribute. Otherwise, we take it the full source as a static value.
		switch ( $source_type ) {
			case 'product':
				return $this->get_product_field( $source_value );
			case 'taxonomy':
				return $this->get_product_taxonomy( $source_value );
			case 'attribute':
				return $this->get_custom_attribute( $source_value );
			default:
				return $source;
		}
	}

	/**
	 * Check if the current product match the conditions for applying the Attribute mapping rule.
	 * For now the conditions are just matching with the product category conditions.
	 *
	 * @param array $rule The attribute mapping rule
	 * @return bool True if the rule is applicable
	 */
	protected function rule_match_conditions( array $rule ): bool {
		$attribute               = $rule['attribute'];
		$category_condition_type = $rule['category_condition_type'];

		if ( $category_condition_type === AttributeMappingHelper::CATEGORY_CONDITION_TYPE_ALL ) {
			return true;
		}

		// size is not the real attribute, the real attribute is sizes
		if ( ! property_exists( $this, $attribute ) && $attribute !== 'size' ) {
			return false;
		}

		$categories                = explode( ',', $rule['categories'] );
		$contains_rules_categories = ! empty( array_intersect( $categories, $this->product_category_ids ) );

		if ( $category_condition_type === AttributeMappingHelper::CATEGORY_CONDITION_TYPE_ONLY ) {
			return $contains_rules_categories;
		}

		return ! $contains_rules_categories;
	}

	/**
	 * Get taxonomy source type for attribute mapping
	 *
	 * @param string $taxonomy The taxonomy to get
	 * @return string The taxonomy value
	 */
	protected function get_product_taxonomy( $taxonomy ) {
		$product = $this->get_wc_product();

		if ( $product->is_type( 'variation' ) ) {
			$values = $product->get_attribute( $taxonomy );

			if ( ! $values ) { // if taxonomy is not a global attribute (ie product_tag), attempt to get is with wc_get_product_terms
				$values = $this->get_taxonomy_term_names( $product->get_id(), $taxonomy );
			}

			if ( ! $values ) { // if the value is still not available at this point, we try to get it from the parent
				$parent = wc_get_product( $product->get_parent_id() );
				$values = $parent->get_attribute( $taxonomy );

				if ( ! $values ) {
					$values = $this->get_taxonomy_term_names( $parent->get_id(), $taxonomy );
				}
			}

			if ( is_string( $values ) ) {
				$values = explode( ', ', $values );
			}
		} else {
			$values = $this->get_taxonomy_term_names( $product->get_id(), $taxonomy );
		}

		if ( empty( $values ) || is_wp_error( $values ) ) {
			return '';
		}

		return $values[0];
	}

	/**
	 * Get product source type  for attribute mapping.
	 * Those are fields belonging to the product core data. Like title, weight, SKU...
	 *
	 * @param string $field The field to get
	 * @return string|null The field value (null if data is not available)
	 */
	protected function get_product_field( $field ) {
		$product = $this->get_wc_product();

		if ( 'weight_with_unit' === $field ) {
			$weight = $product->get_weight();
			return $weight ? $weight . ' ' . get_option( 'woocommerce_weight_unit' ) : null;
		}

		if ( is_callable( [ $product, 'get_' . $field ] ) ) {
			$getter = 'get_' . $field;
			return $product->$getter();
		}

		return '';
	}

	/**
	 *
	 * Formats the attribute for sending it via Google API
	 *
	 * @param string $value The value to format
	 * @param string $attribute_id The attribute ID for which this value belongs
	 * @return string|bool|int The attribute formatted based on theit attribute type
	 */
	protected function format_attribute( $value, $attribute_id ) {
		$attribute = AttributeMappingHelper::get_attribute_by_id( $attribute_id );

		if ( in_array( $attribute::get_value_type(), [ 'bool', 'boolean' ], true ) ) {
			return wc_string_to_bool( $value );
		}

		if ( in_array( $attribute::get_value_type(), [ 'int', 'integer' ], true ) ) {
			return (int) $value;
		}

		return $value;
	}

	/**
	 * Gets a custom attribute from a product
	 *
	 * @param string $attribute_name - The attribute name to get.
	 * @return string|null The attribute value or null if no value is found
	 */
	protected function get_custom_attribute( $attribute_name ) {
		$product = $this->get_wc_product();

		$attribute_value = $product->get_attribute( $attribute_name );

		if ( ! $attribute_value ) {
			$attribute_value = $product->get_meta( $attribute_name );
		}

		// We only support scalar values.
		if ( ! is_scalar( $attribute_value ) ) {
			return '';
		}

		$values = explode( WC_DELIMITER, (string) $attribute_value );
		$values = array_filter( array_map( 'trim', $values ) );
		return empty( $values ) ? '' : $values[0];
	}

	/**
	 * Get a taxonomy term names from a product using
	 *
	 * @param int    $product_id - The product ID to get the taxonomy term
	 * @param string $taxonomy - The taxonomy to get.
	 * @return string[] An array of term names.
	 */
	protected function get_taxonomy_term_names( $product_id, $taxonomy ) {
		$values = wc_get_product_terms( $product_id, $taxonomy );
		return wp_list_pluck( $values, 'name' );
	}
}
Attributes/AttributesForm.php000064400000017455151546531100012372 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Form;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\FormException;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\InputInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Select;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\SelectWithTextInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input\GTINInput;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ValidateInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\AttributeInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\WithValueOptionsInterface;

defined( 'ABSPATH' ) || exit;

/**
 * Class AttributesForm
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes
 */
class AttributesForm extends Form {

	use ValidateInterface;

	/**
	 * @var string[]
	 */
	protected $attribute_types = [];

	/**
	 * AttributesForm constructor.
	 *
	 * @param string[] $attribute_types The names of the attribute classes extending AttributeInterface.
	 * @param array    $data
	 */
	public function __construct( array $attribute_types, array $data = [] ) {
		foreach ( $attribute_types as $attribute_type ) {
			$this->add_attribute( $attribute_type );
		}

		parent::__construct( $data );
	}

	/**
	 * Return the data used for the input's view.
	 *
	 * @return array
	 */
	public function get_view_data(): array {
		$view_data = parent::get_view_data();

		// add classes to hide/display attributes based on product type
		foreach ( $view_data['children'] as $index => $input ) {
			if ( ! isset( $this->attribute_types[ $index ] ) ) {
				continue;
			}

			$attribute_type          = $this->attribute_types[ $index ];
			$attribute_product_types = self::get_attribute_product_types( $attribute_type );

			$hidden_types  = $attribute_product_types['hidden'];
			$visible_types = $attribute_product_types['visible'];

			$input['gla_wrapper_class'] = $input['gla_wrapper_class'] ?? '';

			if ( ! empty( $visible_types ) ) {
				$input['gla_wrapper_class'] .= ' show_if_' . join( ' show_if_', $visible_types );
			}

			if ( ! empty( $hidden_types ) ) {
				$input['gla_wrapper_class'] .= ' hide_if_' . join( ' hide_if_', $hidden_types );
			}

			$view_data['children'][ $index ] = $input;
		}

		return $view_data;
	}

	/**
	 * Get the hidden and visible types of an attribute's applicable product types.
	 *
	 * @param string $attribute_type The name of an attribute class extending AttributeInterface.
	 *
	 * @return array
	 */
	public static function get_attribute_product_types( string $attribute_type ): array {
		$attribute_id             = call_user_func( [ $attribute_type, 'get_id' ] );
		$applicable_product_types = call_user_func( [ $attribute_type, 'get_applicable_product_types' ] );

		/**
		 * This filter is documented in AttributeManager::map_attribute_types
		 *
		 * @see AttributeManager::map_attribute_types
		 */
		$applicable_product_types = apply_filters( "woocommerce_gla_attribute_applicable_product_types_{$attribute_id}", $applicable_product_types, $attribute_type );

		/**
		 * Filters the list of product types to hide the attribute for.
		 */
		$hidden_product_types = apply_filters( "woocommerce_gla_attribute_hidden_product_types_{$attribute_id}", [] );

		$visible_product_types = array_diff( $applicable_product_types, $hidden_product_types );

		return [
			'hidden'  => $hidden_product_types,
			'visible' => $visible_product_types,
		];
	}

	/**
	 * @param InputInterface     $input
	 * @param AttributeInterface $attribute
	 *
	 * @return InputInterface
	 */
	public static function init_input( InputInterface $input, AttributeInterface $attribute ) {
		$input->set_id( $attribute::get_id() )
			->set_name( $attribute::get_id() );

		$value_options = [];
		if ( $attribute instanceof WithValueOptionsInterface ) {
			$value_options = $attribute::get_value_options();
		}
		$value_options = apply_filters( "woocommerce_gla_product_attribute_value_options_{$attribute::get_id()}", $value_options );

		if ( ! empty( $value_options ) ) {
			if ( ! $input instanceof Select && ! $input instanceof SelectWithTextInput ) {
				$new_input = new SelectWithTextInput();
				$new_input->set_label( $input->get_label() )
					->set_description( $input->get_description() );

				// When GTIN uses the SelectWithTextInput field, copy the readonly/hidden attributes from the GTINInput field.
				if ( $input->name === 'gtin' ) {
					$gtin_input = new GTINInput();
					$new_input->set_hidden( $gtin_input->is_hidden() );
					$new_input->set_readonly( $gtin_input->is_readonly() );
				}

				return self::init_input( $new_input, $attribute );
			}

			// add a 'default' value option
			$value_options = [ '' => __( 'Default', 'google-listings-and-ads' ) ] + $value_options;

			$input->set_options( $value_options );
		}

		return $input;
	}

	/**
	 * Add an attribute to the form
	 *
	 * @param string      $attribute_type The name of an attribute class extending AttributeInterface.
	 * @param string|null $input_type     The name of an input class extending InputInterface to use for attribute input.
	 *
	 * @return AttributesForm
	 *
	 * @throws InvalidValue  If the attribute type is invalid or an invalid input type is specified for the attribute.
	 * @throws FormException If form is already submitted.
	 */
	public function add_attribute( string $attribute_type, ?string $input_type = null ): AttributesForm {
		$this->validate_interface( $attribute_type, AttributeInterface::class );

		// use the attribute's default input type if none provided.
		if ( empty( $input_type ) ) {
			$input_type = call_user_func( [ $attribute_type, 'get_input_type' ] );
		}

		$this->validate_interface( $input_type, InputInterface::class );

		$attribute_input = self::init_input( new $input_type(), new $attribute_type() );

		if ( ! $attribute_input->is_hidden() ) {
			$this->add( $attribute_input );

			$attribute_id                           = call_user_func( [ $attribute_type, 'get_id' ] );
			$this->attribute_types[ $attribute_id ] = $attribute_type;
		}

		return $this;
	}

	/**
	 * Remove an attribute from the form
	 *
	 * @param string $attribute_type The name of an attribute class extending AttributeInterface.
	 *
	 * @return AttributesForm
	 *
	 * @throws InvalidValue  If the attribute type is invalid or an invalid input type is specified for the attribute.
	 * @throws FormException If form is already submitted.
	 */
	public function remove_attribute( string $attribute_type ): AttributesForm {
		$this->validate_interface( $attribute_type, AttributeInterface::class );

		$attribute_id = call_user_func( [ $attribute_type, 'get_id' ] );
		unset( $this->attribute_types[ $attribute_id ] );
		$this->remove( $attribute_id );

		return $this;
	}

	/**
	 * Sets the input type for the given attribute.
	 *
	 * @param string $attribute_type The name of an attribute class extending AttributeInterface.
	 * @param string $input_type     The name of an input class extending InputInterface to use for attribute input.
	 *
	 * @return $this
	 *
	 * @throws FormException If form is already submitted.
	 */
	public function set_attribute_input( string $attribute_type, string $input_type ): AttributesForm {
		if ( $this->is_submitted ) {
			throw FormException::cannot_modify_submitted();
		}

		$this->validate_interface( $attribute_type, AttributeInterface::class );
		$this->validate_interface( $input_type, InputInterface::class );

		$attribute_id = call_user_func( [ $attribute_type, 'get_id' ] );
		if ( $this->has( $attribute_id ) ) {
			$this->children[ $attribute_id ] = self::init_input( new $input_type(), new $attribute_type() );
		}

		return $this;
	}
}
Attributes/AttributesTab.php000064400000011704151546531100012164 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Admin;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\AdminConditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Conditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\AttributeManager;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductSyncer;
use WC_Product;

defined( 'ABSPATH' ) || exit;

/**
 * Class AttributesTab
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes
 */
class AttributesTab implements Service, Registerable, Conditional {

	use AdminConditional;
	use AttributesTrait;

	/**
	 * @var Admin
	 */
	protected $admin;

	/**
	 * @var AttributeManager
	 */
	protected $attribute_manager;

	/**
	 * @var MerchantCenterService
	 */
	protected $merchant_center;

	/**
	 * AttributesTab constructor.
	 *
	 * @param Admin                 $admin
	 * @param AttributeManager      $attribute_manager
	 * @param MerchantCenterService $merchant_center
	 */
	public function __construct( Admin $admin, AttributeManager $attribute_manager, MerchantCenterService $merchant_center ) {
		$this->admin             = $admin;
		$this->attribute_manager = $attribute_manager;
		$this->merchant_center   = $merchant_center;
	}

	/**
	 * Register a service.
	 */
	public function register(): void {
		// Register the hooks only if Merchant Center is set up.
		if ( ! $this->merchant_center->is_setup_complete() ) {
			return;
		}

		add_action(
			'woocommerce_new_product',
			function ( int $product_id, WC_Product $product ) {
				$this->handle_update_product( $product );
			},
			10,
			2
		);
		add_action(
			'woocommerce_update_product',
			function ( int $product_id, WC_Product $product ) {
				$this->handle_update_product( $product );
			},
			10,
			2
		);

		add_action(
			'woocommerce_product_data_tabs',
			function ( array $tabs ) {
				return $this->add_tab( $tabs );
			}
		);
		add_action(
			'woocommerce_product_data_panels',
			function () {
				$this->render_panel();
			}
		);
	}

	/**
	 * Adds the Google for WooCommerce tab to the WooCommerce product data box.
	 *
	 * @param array $tabs The current product data tabs.
	 *
	 * @return array An array with product tabs with the Yoast SEO tab added.
	 */
	private function add_tab( array $tabs ): array {
		$tabs['gla_attributes'] = [
			'label'  => 'Google for WooCommerce',
			'class'  => 'gla',
			'target' => 'gla_attributes',
		];

		return $tabs;
	}

	/**
	 * Render the product attributes tab.
	 */
	private function render_panel() {
		$product = wc_get_product( get_the_ID() );
		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		echo $this->admin->get_view( 'attributes/tab-panel', [ 'form' => $this->get_form( $product )->get_view_data() ] );
	}

	/**
	 * Handle form submission and update the product attributes.
	 *
	 * @param WC_Product $product
	 */
	private function handle_update_product( WC_Product $product ) {
		/**
		 * Array of `true` values for each product IDs already handled by this method. Used to prevent double submission.
		 *
		 * @var bool[] $already_updated
		 */
		static $already_updated = [];
		if ( isset( $already_updated[ $product->get_id() ] ) ) {
			return;
		}

		$form           = $this->get_form( $product );
		$form_view_data = $form->get_view_data();

		// phpcs:disable WordPress.Security.NonceVerification
		if ( empty( $_POST[ $form_view_data['name'] ] ) ) {
			return;
		}
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$submitted_data = (array) wc_clean( wp_unslash( $_POST[ $form_view_data['name'] ] ) );
		// phpcs:enable WordPress.Security.NonceVerification

		$form->submit( $submitted_data );
		$this->update_data( $product, $form->get_data() );

		$already_updated[ $product->get_id() ] = true;
	}

	/**
	 * @param WC_Product $product
	 *
	 * @return AttributesForm
	 */
	protected function get_form( WC_Product $product ): AttributesForm {
		$attribute_types = $this->attribute_manager->get_attribute_types_for_product_types( $this->get_applicable_product_types() );

		$form = new AttributesForm( $attribute_types, $this->attribute_manager->get_all_values( $product ) );
		$form->set_name( 'attributes' );

		return $form;
	}

	/**
	 * @param WC_Product $product
	 * @param array      $data
	 *
	 * @return void
	 */
	protected function update_data( WC_Product $product, array $data ): void {
		foreach ( $this->attribute_manager->get_attribute_types_for_product( $product ) as $attribute_id => $attribute_type ) {
			if ( isset( $data[ $attribute_id ] ) ) {
				$this->attribute_manager->update( $product, new $attribute_type( $data[ $attribute_id ] ) );
			}
		}
	}
}
Attributes/AttributesTrait.php000064400000001172151546531100012537 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes;

/**
 * Trait AttributesTrait
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes
 */
trait AttributesTrait {
	/**
	 * Return an array of WooCommerce product types that the Google for WooCommerce tab can be displayed for.
	 *
	 * @return array of WooCommerce product types (e.g. 'simple', 'variable', etc.)
	 */
	protected function get_applicable_product_types(): array {
		return apply_filters( 'woocommerce_gla_attributes_tab_applicable_product_types', [ 'simple', 'variable' ] );
	}
}
Attributes/Input/AdultInput.php000064400000001267151546531100012602 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\BooleanSelect;

defined( 'ABSPATH' ) || exit;

/**
 * Class Adult
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 *
 * @since 1.5.0
 */
class AdultInput extends BooleanSelect {

	/**
	 * AdultInput constructor.
	 */
	public function __construct() {
		parent::__construct();

		$this->set_label( __( 'Adult content', 'google-listings-and-ads' ) );
		$this->set_description( __( 'Whether the product contains nudity or sexually suggestive content', 'google-listings-and-ads' ) );
	}
}
Attributes/Input/AgeGroupInput.php000064400000001211151546531100013227 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Select;

defined( 'ABSPATH' ) || exit;

/**
 * Class AgeGroup
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 *
 * @since 1.5.0
 */
class AgeGroupInput extends Select {

	/**
	 * AgeGroupInput constructor.
	 */
	public function __construct() {
		parent::__construct();

		$this->set_label( __( 'Age Group', 'google-listings-and-ads' ) );
		$this->set_description( __( 'Target age group of the item.', 'google-listings-and-ads' ) );
	}
}
Attributes/Input/AttributeInputInterface.php000064400000001433151546531100015310 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;

defined( 'ABSPATH' ) || exit;

/**
 * Class AttributeInputInterface
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input
 *
 * @since 1.5.0
 */
interface AttributeInputInterface {

	/**
	 * Returns a name for the attribute input.
	 *
	 * @return string
	 */
	public static function get_name(): string;

	/**
	 * Returns a short description for the attribute input.
	 *
	 * @return string
	 */
	public static function get_description(): string;

	/**
	 * Returns the input class used for the attribute input.
	 *
	 * Must be an instance of `InputInterface`.
	 *
	 * @return string
	 */
	public static function get_input_type(): string;
}
Attributes/Input/AvailabilityDateInput.php000064400000001426151546531100014736 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\DateTime;

defined( 'ABSPATH' ) || exit;

/**
 * Class AvailabilityDate
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 *
 * @since 1.5.0
 */
class AvailabilityDateInput extends DateTime {

	/**
	 * AvailabilityDateInput constructor.
	 */
	public function __construct() {
		parent::__construct();

		$this->set_label( __( 'Availability Date', 'google-listings-and-ads' ) );
		$this->set_description( __( 'The date a preordered or backordered product becomes available for delivery. Required if product availability is preorder or backorder', 'google-listings-and-ads' ) );
	}
}
Attributes/Input/BrandInput.php000064400000001160151546531100012547 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Text;

defined( 'ABSPATH' ) || exit;

/**
 * Class Brand
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 *
 * @since 1.5.0
 */
class BrandInput extends Text {

	/**
	 * BrandInput constructor.
	 */
	public function __construct() {
		parent::__construct();

		$this->set_label( __( 'Brand', 'google-listings-and-ads' ) );
		$this->set_description( __( 'Brand of the product.', 'google-listings-and-ads' ) );
	}
}
Attributes/Input/ColorInput.php000064400000001160151546531100012577 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Text;

defined( 'ABSPATH' ) || exit;

/**
 * Class Color
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 *
 * @since 1.5.0
 */
class ColorInput extends Text {

	/**
	 * ColorInput constructor.
	 */
	public function __construct() {
		parent::__construct();

		$this->set_label( __( 'Color', 'google-listings-and-ads' ) );
		$this->set_description( __( 'Color of the product.', 'google-listings-and-ads' ) );
	}
}
Attributes/Input/ConditionInput.php000064400000001216151546531100013451 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Select;

defined( 'ABSPATH' ) || exit;

/**
 * Class Condition
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 *
 * @since 1.5.0
 */
class ConditionInput extends Select {

	/**
	 * ConditionInput constructor.
	 */
	public function __construct() {
		parent::__construct();

		$this->set_label( __( 'Condition', 'google-listings-and-ads' ) );
		$this->set_description( __( 'Condition or state of the item.', 'google-listings-and-ads' ) );
	}
}
Attributes/Input/GTINInput.php000064400000003125151546531100012265 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Text;
use Automattic\WooCommerce\GoogleListingsAndAds\HelperTraits\GTINMigrationUtilities;

defined( 'ABSPATH' ) || exit;

/**
 * Class GTIN
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 *
 * @since 1.5.0
 */
class GTINInput extends Text {

	use GTINMigrationUtilities;

	/**
	 * GTINInput constructor.
	 */
	public function __construct() {
		parent::__construct();

		$this->set_label( __( 'Global Trade Item Number (GTIN)', 'google-listings-and-ads' ) );
		$this->set_description( __( 'Global Trade Item Number (GTIN) for your item. These identifiers include UPC (in North America), EAN (in Europe), JAN (in Japan), and ISBN (for books)', 'google-listings-and-ads' ) );
		$this->set_field_visibility();
	}

	/**
	 * Controls the inputs visibility based on the WooCommerce version and the
	 * initial version of Google for WooCommerce at the time of installation.
	 *
	 * @since 2.9.0
	 * @return void
	 */
	public function set_field_visibility(): void {
		if ( $this->is_gtin_available_in_core() ) {
			// For versions after the GTIN changes are published. Hide the GTIN field from G4W tab. Otherwise, set as readonly.
			if ( $this->should_hide_gtin() ) {
				$this->set_hidden( true );
			} else {
				$this->set_readonly( true );
				$this->set_description( __( 'The Global Trade Item Number (GTIN) for your item can now be entered on the "Inventory" tab', 'google-listings-and-ads' ) );
			}
		}
	}
}
Attributes/Input/GenderInput.php000064400000001221151546531100012723 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Select;

defined( 'ABSPATH' ) || exit;

/**
 * Class Gender
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 *
 * @since 1.5.0
 */
class GenderInput extends Select {

	/**
	 * GenderInput constructor.
	 */
	public function __construct() {
		parent::__construct();

		$this->set_label( __( 'Gender', 'google-listings-and-ads' ) );
		$this->set_description( __( 'The gender for which your product is intended.', 'google-listings-and-ads' ) );
	}
}
Attributes/Input/IsBundleInput.php000064400000001377151546531100013240 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\BooleanSelect;

defined( 'ABSPATH' ) || exit;

/**
 * Class IsBundle
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 *
 * @since 1.5.0
 */
class IsBundleInput extends BooleanSelect {

	/**
	 * IsBundleInput constructor.
	 */
	public function __construct() {
		parent::__construct();

		$this->set_label( __( 'Is Bundle?', 'google-listings-and-ads' ) );
		$this->set_description( __( 'Whether the item is a bundle of products. A bundle is a custom grouping of different products sold by a merchant for a single price.', 'google-listings-and-ads' ) );
	}
}
Attributes/Input/MPNInput.php000064400000001254151546531100012157 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Text;

defined( 'ABSPATH' ) || exit;

/**
 * Class MPN
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 *
 * @since 1.5.0
 */
class MPNInput extends Text {

	/**
	 * MPNInput constructor.
	 */
	public function __construct() {
		parent::__construct();

		$this->set_label( __( 'Manufacturer Part Number (MPN)', 'google-listings-and-ads' ) );
		$this->set_description( __( 'This code uniquely identifies the product to its manufacturer.', 'google-listings-and-ads' ) );
	}
}
Attributes/Input/MaterialInput.php000064400000001216151546531100013261 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Text;

defined( 'ABSPATH' ) || exit;

/**
 * Class Material
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 *
 * @since 1.5.0
 */
class MaterialInput extends Text {

	/**
	 * MaterialInput constructor.
	 */
	public function __construct() {
		parent::__construct();

		$this->set_label( __( 'Material', 'google-listings-and-ads' ) );
		$this->set_description( __( 'The material of which the item is made.', 'google-listings-and-ads' ) );
	}
}
Attributes/Input/MultipackInput.php000064400000001500151546531100013450 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Integer;

defined( 'ABSPATH' ) || exit;

/**
 * Class Multipack
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 *
 * @since 1.5.0
 */
class MultipackInput extends Integer {

	/**
	 * MultipackInput constructor.
	 */
	public function __construct() {
		parent::__construct();

		$this->set_label( __( 'Multipack', 'google-listings-and-ads' ) );
		$this->set_description( __( 'The number of identical products in a multipack. Use this attribute to indicate that you\'ve grouped multiple identical products for sale as one item.', 'google-listings-and-ads' ) );
		$this->set_block_attribute( 'min', [ 'value' => 0 ] );
	}
}
Attributes/Input/PatternInput.php000064400000001211151546531100013133 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Text;

defined( 'ABSPATH' ) || exit;

/**
 * Class Pattern
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 *
 * @since 1.5.0
 */
class PatternInput extends Text {

	/**
	 * PatternInput constructor.
	 */
	public function __construct() {
		parent::__construct();

		$this->set_label( __( 'Pattern', 'google-listings-and-ads' ) );
		$this->set_description( __( 'The item\'s pattern (e.g. polka dots).', 'google-listings-and-ads' ) );
	}
}
Attributes/Input/SizeInput.php000064400000001153151546531100012435 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Text;

defined( 'ABSPATH' ) || exit;

/**
 * Class Size
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 *
 * @since 1.5.0
 */
class SizeInput extends Text {

	/**
	 * SizeInput constructor.
	 */
	public function __construct() {
		parent::__construct();

		$this->set_label( __( 'Size', 'google-listings-and-ads' ) );
		$this->set_description( __( 'Size of the product.', 'google-listings-and-ads' ) );
	}
}
Attributes/Input/SizeSystemInput.php000064400000001271151546531100013643 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Select;

defined( 'ABSPATH' ) || exit;

/**
 * Class SizeSystem
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 *
 * @since 1.5.0
 */
class SizeSystemInput extends Select {

	/**
	 * SizeSystemInput constructor.
	 */
	public function __construct() {
		parent::__construct();

		$this->set_label( __( 'Size system', 'google-listings-and-ads' ) );
		$this->set_description( __( 'System in which the size is specified. Recommended for apparel items.', 'google-listings-and-ads' ) );
	}
}
Attributes/Input/SizeTypeInput.php000064400000001237151546531100013302 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes\Input;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Select;

defined( 'ABSPATH' ) || exit;

/**
 * Class SizeType
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes
 *
 * @since 1.5.0
 */
class SizeTypeInput extends Select {

	/**
	 * SizeTypeInput constructor.
	 */
	public function __construct() {
		parent::__construct();

		$this->set_label( __( 'Size type', 'google-listings-and-ads' ) );
		$this->set_description( __( 'The cut of the item. Recommended for apparel items.', 'google-listings-and-ads' ) );
	}
}
Attributes/VariationsAttributes.php000064400000012114151546531100013571 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes;

use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Admin;
use Automattic\WooCommerce\GoogleListingsAndAds\Admin\Input\Form;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\AdminConditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Conditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\Attributes\AttributeManager;
use WC_Product_Variation;
use WP_Post;

defined( 'ABSPATH' ) || exit;

/**
 * Class VariationsAttributes
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product\Attributes
 */
class VariationsAttributes implements Service, Registerable, Conditional {

	use AdminConditional;

	/**
	 * @var Admin
	 */
	protected $admin;

	/**
	 * @var AttributeManager
	 */
	protected $attribute_manager;

	/**
	 * @var MerchantCenterService
	 */
	protected $merchant_center;

	/**
	 * VariationsAttributes constructor.
	 *
	 * @param Admin                 $admin
	 * @param AttributeManager      $attribute_manager
	 * @param MerchantCenterService $merchant_center
	 */
	public function __construct( Admin $admin, AttributeManager $attribute_manager, MerchantCenterService $merchant_center ) {
		$this->admin             = $admin;
		$this->attribute_manager = $attribute_manager;
		$this->merchant_center   = $merchant_center;
	}
	/**
	 * Register a service.
	 */
	public function register(): void {
		// Register the hooks only if Merchant Center is set up.
		if ( ! $this->merchant_center->is_setup_complete() ) {
			return;
		}

		add_action(
			'woocommerce_product_after_variable_attributes',
			function ( int $variation_index, array $variation_data, WP_Post $variation ) {
				$this->render_attributes_form( $variation_index, $variation );
			},
			90,
			3
		);
		add_action(
			'woocommerce_save_product_variation',
			function ( int $variation_id, int $variation_index ) {
				$this->handle_save_variation( $variation_id, $variation_index );
			},
			10,
			2
		);
	}

	/**
	 * Render the attributes form for variations.
	 *
	 * @param int     $variation_index Position in the loop.
	 * @param WP_Post $variation       Post data.
	 */
	private function render_attributes_form( int $variation_index, WP_Post $variation ) {
		/**
		 * @var WC_Product_Variation $product
		 */
		$product = wc_get_product( $variation->ID );

		$data = $this->get_form( $product, $variation_index )->get_view_data();

		// Do not render the form if it doesn't contain any child attributes.
		$attributes = reset( $data['children'] );
		if ( empty( $data['children'] ) || empty( $attributes['children'] ) ) {
			return;
		}

		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		echo $this->admin->get_view( 'attributes/variations-form', $data );
	}

	/**
	 * Handle form submission and update the product attributes.
	 *
	 * @param int $variation_id
	 * @param int $variation_index
	 */
	private function handle_save_variation( int $variation_id, int $variation_index ) {
		/**
		 * @var WC_Product_Variation $variation
		 */
		$variation = wc_get_product( $variation_id );

		$form           = $this->get_form( $variation, $variation_index );
		$form_view_data = $form->get_view_data();

		// phpcs:disable WordPress.Security.NonceVerification
		if ( empty( $_POST[ $form_view_data['name'] ] ) ) {
			return;
		}
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$submitted_data = (array) wc_clean( wp_unslash( $_POST[ $form_view_data['name'] ] ) );
		// phpcs:enable WordPress.Security.NonceVerification

		$form->submit( $submitted_data );
		$form_data = $form->get_data();

		if ( ! empty( $form_data[ $variation_index ] ) ) {
			$this->update_data( $variation, $form_data[ $variation_index ] );
		}
	}

	/**
	 * @param WC_Product_Variation $variation
	 * @param int                  $variation_index
	 *
	 * @return Form
	 */
	protected function get_form( WC_Product_Variation $variation, int $variation_index ): Form {
		$attribute_types = $this->attribute_manager->get_attribute_types_for_product( $variation );
		$attribute_form  = new AttributesForm( $attribute_types );
		$attribute_form->set_name( (string) $variation_index );

		$form = new Form();
		$form->set_name( 'variation_attributes' )
			->add( $attribute_form )
			->set_data( [ (string) $variation_index => $this->attribute_manager->get_all_values( $variation ) ] );

		return $form;
	}

	/**
	 * @param WC_Product_Variation $variation
	 * @param array                $data
	 *
	 * @return void
	 */
	protected function update_data( WC_Product_Variation $variation, array $data ): void {
		foreach ( $this->attribute_manager->get_attribute_types_for_product( $variation ) as $attribute_id => $attribute_type ) {
			if ( isset( $data[ $attribute_id ] ) ) {
				$this->attribute_manager->update( $variation, new $attribute_type( $data[ $attribute_id ] ) );
			}
		}
	}
}
ChannelVisibilityBlock.php000064400000012043151546531100011651 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product;

use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductSyncer;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\SyncStatus;
use WC_Data;
use WC_Product;
use WP_REST_Request as Request;
use WP_REST_Response as Response;

defined( 'ABSPATH' ) || exit;

/**
 * Class ChannelVisibilityBlock
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Admin\Product
 */
class ChannelVisibilityBlock implements Service, Registerable {

	public const PROPERTY = 'google_listings_and_ads__channel_visibility';

	/**
	 * @var ProductHelper
	 */
	protected $product_helper;

	/**
	 * @var MerchantCenterService
	 */
	protected $merchant_center;

	/**
	 * ChannelVisibilityBlock constructor.
	 *
	 * @param ProductHelper         $product_helper
	 * @param MerchantCenterService $merchant_center
	 */
	public function __construct( ProductHelper $product_helper, MerchantCenterService $merchant_center ) {
		$this->product_helper  = $product_helper;
		$this->merchant_center = $merchant_center;
	}

	/**
	 * Register hooks for querying and updating product via REST APIs.
	 */
	public function register(): void {
		if ( ! $this->merchant_center->is_setup_complete() ) {
			return;
		}

		// https://github.com/woocommerce/woocommerce/blob/8.6.0/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php#L182-L192
		add_filter( 'woocommerce_rest_prepare_product_object', [ $this, 'prepare_data' ], 10, 2 );

		// https://github.com/woocommerce/woocommerce/blob/8.6.0/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-crud-controller.php#L200-L207
		// https://github.com/woocommerce/woocommerce/blob/8.6.0/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-crud-controller.php#L247-L254
		add_action( 'woocommerce_rest_insert_product_object', [ $this, 'update_data' ], 10, 2 );
	}

	/**
	 * Get channel visibility data from the given product and add it to the given response.
	 *
	 * @param Response           $response Response to be added channel visibility data.
	 * @param WC_Product|WC_Data $product  WooCommerce product to get data.
	 *
	 * @return Response
	 */
	public function prepare_data( Response $response, WC_Data $product ): Response {
		if ( ! $product instanceof WC_Product ) {
			return $response;
		}

		$response->data[ self::PROPERTY ] = [
			'is_visible'         => $product->is_visible(),
			'channel_visibility' => $this->product_helper->get_channel_visibility( $product ),
			'sync_status'        => $this->product_helper->get_sync_status( $product ),
			'issues'             => $this->product_helper->get_validation_errors( $product ),
		];

		return $response;
	}

	/**
	 * Get channel visibility data from the given request and update it to the given product.
	 *
	 * @param WC_Product|WC_Data $product WooCommerce product to be updated.
	 * @param Request            $request Response to get the channel visibility data.
	 */
	public function update_data( WC_Data $product, Request $request ): void {
		if ( ! $product instanceof WC_Product || ! in_array( $product->get_type(), $this->get_visible_product_types(), true ) ) {
			return;
		}

		$params = $request->get_params();

		if ( ! isset( $params[ self::PROPERTY ] ) ) {
			return;
		}

		$channel_visibility = $params[ self::PROPERTY ]['channel_visibility'];

		if ( $channel_visibility !== $this->product_helper->get_channel_visibility( $product ) ) {
			$this->product_helper->update_channel_visibility( $product, $channel_visibility );
		}
	}

	/**
	 * Return the visible product types to control the hidden condition of the channel visibility block
	 * in the Product Block Editor.
	 *
	 * @return array
	 */
	public function get_visible_product_types(): array {
		return array_diff( ProductSyncer::get_supported_product_types(), [ 'variation' ] );
	}

	/**
	 * Return the config used for the input's block within the Product Block Editor.
	 *
	 * @return array
	 */
	public function get_block_config(): array {
		$options = [];

		foreach ( ChannelVisibility::get_value_options() as $key => $value ) {
			$options[] = [
				'label' => $value,
				'value' => $key,
			];
		}

		$attributes = [
			'property'          => self::PROPERTY,
			'options'           => $options,
			'valueOfSync'       => ChannelVisibility::SYNC_AND_SHOW,
			'valueOfDontSync'   => ChannelVisibility::DONT_SYNC_AND_SHOW,
			'statusOfSynced'    => SyncStatus::SYNCED,
			'statusOfHasErrors' => SyncStatus::HAS_ERRORS,
		];

		return [
			'id'         => 'google-listings-and-ads-product-channel-visibility',
			'blockName'  => 'google-listings-and-ads/product-channel-visibility',
			'attributes' => $attributes,
		];
	}
}