'create', 'fields' => $canonical]; } $diff = []; foreach (ProductMetadata::values($canonical, $ownership) as $key => $after) { $before = $product->get_meta($key, true, 'edit'); $exists = $product->meta_exists($key); // WordPress stores scalar metadata as strings, including WooCommerce // values that were originally numbers or booleans. $same = (is_scalar($before) && is_scalar($after)) ? (string) $before === (string) $after : $before === $after; // WooCommerce removes raw metadata whose supplied value is null. $same = $after === null ? !$exists : ($exists && $same); if (!$same) { $field = ProductMetadata::field($key, $canonical); $diff[$field]['before'][$key] = $before; $diff[$field]['after'][$key] = $after; } } foreach (self::FIELDS as $field) { if (!array_key_exists($field, $canonical) || ($ownership[$field] ?? 'source') === 'merchant') { continue; } if ($product instanceof \WC_Product_Variable && in_array($field, ['regular_price', 'sale_price'], true)) { continue; } if ($product instanceof \WC_Product_Variation && $field === 'name' && $canonical[$field] === '') { continue; } $getter = 'get_' . $field; if (!is_callable([$product, $getter])) { continue; } $before = $field === 'cogs_value' ? $product->{$getter}() : $product->{$getter}('edit'); $after = $canonical[$field]; $same = $field === 'cogs_value' && is_numeric($before) && is_numeric($after) ? (float) $before === (float) $after : (string) $before === (string) $after; if (!$same) { $diff[$field] = ['before' => $before, 'after' => $after]; } } if (array_key_exists('language', $canonical) && ($ownership['language'] ?? 'source') !== 'merchant') { $adapter = MultilingualAdapterFactory::fromCanonical($canonical); if ($adapter->available()) { $before = $adapter->productLanguage((int) $product->get_id()); $after = sanitize_key((string) $canonical['language']); if ($before !== $after) { $diff['language'] = ['before' => $before, 'after' => $after]; } } } if (($ownership['categories'] ?? 'source') !== 'merchant') { $categoryIds = $product->get_category_ids('edit'); $afterIds = $this->canonicalCategoryIds($canonical, $product); if ($afterIds !== null) { $beforeIds = array_values(array_map('intval', $categoryIds)); sort($beforeIds); sort($afterIds); if ($beforeIds !== $afterIds) { $diff['categories'] = ['before' => $beforeIds, 'after' => $afterIds]; } } else { $currentCategories = array_values(array_filter(array_map(static function (int $id): string { $term = get_term($id, 'product_cat'); if (!$term || is_wp_error($term)) { return ''; } $names = array_map(static function (int $ancestor): string { $item = get_term($ancestor, 'product_cat'); return $item && !is_wp_error($item) ? html_entity_decode($item->name, ENT_QUOTES | ENT_HTML5, 'UTF-8') : ''; }, array_reverse(get_ancestors($id, 'product_cat', 'taxonomy'))); $names[] = html_entity_decode($term->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'); return implode(' > ', array_filter($names)); }, $categoryIds))); $afterCategories = array_values(array_map('strval', (array) ($canonical['categories'] ?? []))); sort($afterCategories); sort($currentCategories); if ($afterCategories !== $currentCategories) { $diff['categories'] = ['before' => $currentCategories, 'after' => $canonical['categories'] ?? []]; } } } if ( in_array(($canonical['type'] ?? $product->get_type()), ['simple', 'variable'], true) && array_key_exists('brands', $canonical) && ($ownership['brands'] ?? 'source') !== 'merchant' ) { $beforeBrands = ProductBrands::namesForProduct((int) $product->get_id()); $afterBrands = ProductBrands::normalize((array) $canonical['brands']); sort($afterBrands, SORT_STRING); if ($beforeBrands !== $afterBrands) { $diff['brands'] = ['before' => $beforeBrands, 'after' => $afterBrands]; } } if ( isset($canonical['attributes']) && ( ($ownership['attributes'] ?? 'source') !== 'merchant' || array_key_exists('_attribute_ownership', $canonical) ) ) { $attributeOwnership = $this->attributeOwnership($canonical); // Both sides are compared by the identity this store keeps the // attribute under. Comparing labels reported a difference on every // run after a rename, which no publication could ever settle. $currentAttributes = []; $currentAxes = []; if ($product instanceof \WC_Product_Variation) { foreach ($product->get_attributes('edit') as $name => $value) { $key = AttributeIdentity::fromProductAttribute((string) $name); $term = taxonomy_exists((string) $name) ? get_term_by('slug', (string) $value, (string) $name) : false; $currentAttributes[$key] = [(string) ($term && !is_wp_error($term) ? $term->name : $value)]; $currentAxes[] = $key; } } else { foreach ($product->get_attributes('edit') as $attribute) { if (!$attribute instanceof \WC_Product_Attribute) { continue; } $key = AttributeIdentity::fromProductAttribute((string) $attribute->get_name()); $currentAttributes[$key] = array_values(array_map( 'strval', $attribute->is_taxonomy() ? wc_get_product_terms($product->get_id(), $attribute->get_name(), ['fields' => 'names']) : $attribute->get_options() )); if ($attribute->get_variation()) { $currentAxes[] = $key; } } } $normalizeName = static fn (string $name): string => AttributeIdentity::slug($name); $normalize = static function (array $attributes) use ($normalizeName): array { $normalized = []; foreach ($attributes as $name => $values) { $values = array_values(array_map('strval', (array) $values)); sort($values); $normalized[$normalizeName((string) $name)] = $values; } ksort($normalized); return $normalized; }; $afterAttributes = []; foreach (AttributeIdentity::rows((array) $canonical['attributes']) as $row) { if (($attributeOwnership[$row['key']] ?? 'source') === 'merchant') { continue; } $values = array_values(array_map('strval', $row['values'])); sort($values); $afterAttributes[$row['key']] = $values; } $currentAttributes = $normalize($currentAttributes); foreach ($currentAttributes as $key => $values) { if (($attributeOwnership[$key] ?? 'source') === 'merchant') { $afterAttributes[$key] = $values; } } ksort($afterAttributes); if ($afterAttributes !== $currentAttributes) { $diff['attributes'] = ['before' => $currentAttributes, 'after' => $afterAttributes]; } $defaultAxes = in_array(($canonical['type'] ?? ''), ['variable', 'variation'], true) ? AttributeIdentity::keys((array) $canonical['attributes']) : []; $afterAxes = array_values(array_map( $normalizeName, array_map('strval', (array) ($canonical['variation_attributes'] ?? $defaultAxes)) )); $currentAxes = array_values(array_map($normalizeName, $currentAxes)); $afterAxes = array_values(array_filter( $afterAxes, static fn (string $key): bool => ($attributeOwnership[$key] ?? 'source') !== 'merchant' )); foreach ($currentAxes as $key) { if (($attributeOwnership[$key] ?? 'source') === 'merchant') { $afterAxes[] = $key; } } $afterAxes = array_values(array_unique($afterAxes)); sort($afterAxes); sort($currentAxes); if ($afterAxes !== $currentAxes) { $diff['variation_attributes'] = ['before' => $currentAxes, 'after' => $afterAxes]; } } if (($ownership['images'] ?? 'source') !== 'merchant' && isset($canonical['image_ids'])) { $beforeImages = array_values(array_filter(array_merge([$product->get_image_id('edit')], $product->get_gallery_image_ids('edit')))); $afterImages = array_values(array_filter(array_map('intval', (array) $canonical['image_ids']))); if ($beforeImages !== $afterImages) { $diff['image_ids'] = ['before' => $beforeImages, 'after' => $afterImages]; } } if (!empty($canonical['translations']) && ($canonical['type'] ?? '') !== 'variation') { $adapter = MultilingualAdapterFactory::fromCanonical($canonical); $missing = []; foreach ((array) $canonical['translations'] as $translation) { if (!is_array($translation)) { continue; } $language = sanitize_key((string) ($translation['language'] ?? '')); if ($language === '') { continue; } $translated = $adapter->translatedProductId((int) $product->get_id(), $language); if (!$translated || $translated === (int) $product->get_id()) { $missing[] = $language; } elseif (($translation['mode'] ?? 'duplicate') === 'translate') { // Protocol 3 carries independent translated content. Its // content_hash is stable and publication is idempotent, so // queue the existing translated member for exact overwrite // instead of treating the primary product diff as proof that // a different language is current. $missing[] = $language; } } if ($missing) { $diff['translations'] = ['before' => [], 'after' => $missing]; } } return ['action' => $diff ? 'update' : 'unchanged', 'fields' => $diff]; } /** @return array stable attribute key => source|merchant */ private function attributeOwnership(array $canonical): array { $ownership = []; foreach ((array) ($canonical['_attribute_ownership'] ?? []) as $key => $owner) { $key = AttributeIdentity::slug((string) $key); if ($key !== '' && in_array($owner, ['source', 'merchant'], true)) { $ownership[$key] = (string) $owner; } } return $ownership; } /** @return list|null */ private function canonicalCategoryIds(array $canonical, \WC_Product $product): ?array { $entries = (array) ($canonical['categories'] ?? []); if ($entries === [] || !is_array($entries[0] ?? null) || !isset($entries[0]['id'])) { return null; } $language = sanitize_key((string) ($canonical['language'] ?? '')); if ($language === '') { $adapter = MultilingualAdapterFactory::fromCanonical($canonical); $language = $adapter->productLanguage((int) $product->get_id()); } $ids = []; foreach ($entries as $entry) { if (!is_array($entry) || !isset($entry['id'])) { continue; } $id = (int) $entry['id']; $translated = (int) ($entry['translations'][$language] ?? 0); if ($language !== '' && $translated > 0) { $id = $translated; } if ($id > 0) { $ids[] = $id; } } return array_values(array_unique($ids)); } }