$raw * @return array> */ public static function normalizeOperations(array $raw): array { if ($raw === []) { throw new RuntimeException('No operations supplied.'); } if (count($raw) > 100) { throw new RuntimeException('Too many operations in one batch; send at most 100.'); } $operations = []; foreach ($raw as $index => $entry) { $position = (int) $index + 1; if (!is_array($entry)) { throw new RuntimeException(sprintf('Operation %d is not an object.', $position)); } $op = (string) ($entry['op'] ?? ''); if (!in_array($op, self::OPERATIONS, true)) { throw new RuntimeException(sprintf( 'Operation %d has unknown op "%s"; expected one of %s.', $position, $op, implode(', ', self::OPERATIONS) )); } $operations[] = match ($op) { 'rename_attribute' => self::normalizeRenameAttribute($entry, $position), 'rename_term' => self::normalizeRenameTerm($entry, $position), 'merge_terms' => self::normalizeMergeTerms($entry, $position), 'reassign_term_usage' => self::normalizeReassignTermUsage($entry, $position), 'move_term_usage' => self::normalizeMoveTermUsage($entry, $position), 'delete_term' => self::normalizeDeleteTerm($entry, $position), 'delete_attribute' => self::normalizeDeleteAttribute($entry, $position), }; } return $operations; } /** * @param array $entry * @return array */ private static function normalizeRenameAttribute(array $entry, int $position): array { $from = self::text($entry['from'] ?? '', 'from', $position); $to = self::text($entry['to'] ?? '', 'to', $position); $slug = trim((string) ($entry['slug'] ?? '')); return ['op' => 'rename_attribute', 'from' => $from, 'to' => $to, 'slug' => $slug]; } /** * @param array $entry * @return array */ private static function normalizeRenameTerm(array $entry, int $position): array { $taxonomy = self::text($entry['taxonomy'] ?? '', 'taxonomy', $position); if (!str_starts_with($taxonomy, 'pa_') || sanitize_key($taxonomy) !== $taxonomy) { throw new RuntimeException(sprintf( 'Operation %d needs an exact global-attribute taxonomy such as "pa_colour".', $position )); } return [ 'op' => 'rename_term', 'taxonomy' => $taxonomy, 'term' => self::normalizeTermSelector($entry['term'] ?? null, 'term', $position), 'to' => self::text($entry['to'] ?? '', 'to', $position), ]; } /** * @param array $entry * @return array */ private static function normalizeMergeTerms(array $entry, int $position): array { $attribute = self::text($entry['attribute'] ?? '', 'attribute', $position); $into = self::text($entry['into'] ?? '', 'into', $position); $from = array_values(array_filter(array_map( static fn ($value): string => trim((string) $value), (array) ($entry['from'] ?? []) ), static fn (string $value): bool => $value !== '')); if ($from === []) { throw new RuntimeException(sprintf('Operation %d needs at least one value in "from".', $position)); } $from = array_values(array_filter($from, static fn (string $value): bool => $value !== $into)); if ($from === []) { throw new RuntimeException(sprintf( 'Operation %d merges "%s" into itself.', $position, $into )); } return ['op' => 'merge_terms', 'attribute' => $attribute, 'from' => $from, 'into' => $into]; } /** * @param array $entry * @return array */ private static function normalizeReassignTermUsage(array $entry, int $position): array { $taxonomy = self::text($entry['taxonomy'] ?? '', 'taxonomy', $position); if (!str_starts_with($taxonomy, 'pa_') || sanitize_key($taxonomy) !== $taxonomy) { throw new RuntimeException(sprintf( 'Operation %d needs an exact global-attribute taxonomy such as "pa_colour".', $position )); } $source = self::normalizeTermSelector($entry['source'] ?? null, 'source', $position); $target = self::normalizeTermSelector($entry['target'] ?? null, 'target', $position); if ($source === $target) { throw new RuntimeException(sprintf('Operation %d reassigns a term to itself.', $position)); } return [ 'op' => 'reassign_term_usage', 'taxonomy' => $taxonomy, 'source' => $source, 'target' => $target, 'delete_source' => (bool) ($entry['delete_source'] ?? false), ]; } /** * @param array $entry * @return array */ private static function normalizeMoveTermUsage(array $entry, int $position): array { $source = self::normalizeTermEndpoint($entry['source'] ?? null, 'source', $position); $target = self::normalizeTermEndpoint($entry['target'] ?? null, 'target', $position); if ($source['taxonomy'] === $target['taxonomy']) { throw new RuntimeException(sprintf( 'Operation %d needs different source and target taxonomies; use reassign_term_usage inside one taxonomy.', $position )); } return [ 'op' => 'move_term_usage', 'source' => $source, 'target' => $target, 'delete_source_term' => (bool) ($entry['delete_source_term'] ?? false), ]; } /** * @return array{taxonomy:string,term_id?:int,slug?:string} */ private static function normalizeTermEndpoint(mixed $raw, string $field, int $position): array { if (!is_array($raw)) { throw new RuntimeException(sprintf('Operation %d field "%s" must be an object.', $position, $field)); } $taxonomy = self::text($raw['taxonomy'] ?? '', $field . '.taxonomy', $position); if (!str_starts_with($taxonomy, 'pa_') || sanitize_key($taxonomy) !== $taxonomy) { throw new RuntimeException(sprintf( 'Operation %d field "%s.taxonomy" needs an exact global-attribute taxonomy such as "pa_colour".', $position, $field )); } return ['taxonomy' => $taxonomy] + self::normalizeTermSelector($raw, $field, $position); } /** * @return array{term_id:int}|array{slug:string} */ private static function normalizeTermSelector(mixed $raw, string $field, int $position): array { if (!is_array($raw)) { throw new RuntimeException(sprintf('Operation %d field "%s" must be an object.', $position, $field)); } $hasId = array_key_exists('term_id', $raw); $hasSlug = array_key_exists('slug', $raw); if ($hasId === $hasSlug) { throw new RuntimeException(sprintf( 'Operation %d field "%s" needs exactly one of "term_id" or "slug".', $position, $field )); } if ($hasId) { $termId = (int) $raw['term_id']; if ($termId <= 0) { throw new RuntimeException(sprintf('Operation %d field "%s.term_id" must be positive.', $position, $field)); } return ['term_id' => $termId]; } $slug = trim((string) $raw['slug']); if ($slug === '' || strlen($slug) > 200 || sanitize_title($slug) !== $slug) { throw new RuntimeException(sprintf('Operation %d field "%s.slug" is not an exact valid term slug.', $position, $field)); } return ['slug' => $slug]; } /** * @param array $entry * @return array */ private static function normalizeDeleteTerm(array $entry, int $position): array { return [ 'op' => 'delete_term', 'attribute' => self::text($entry['attribute'] ?? '', 'attribute', $position), 'name' => self::text($entry['name'] ?? '', 'name', $position), ]; } /** * @param array $entry * @return array */ private static function normalizeDeleteAttribute(array $entry, int $position): array { return [ 'op' => 'delete_attribute', 'name' => self::text($entry['name'] ?? '', 'name', $position), 'force' => (bool) ($entry['force'] ?? false), ]; } private static function text(mixed $value, string $field, int $position): string { $value = trim((string) $value); if ($value === '') { throw new RuntimeException(sprintf('Operation %d is missing "%s".', $position, $field)); } return $value; } /** * Current global attributes with their term and product counts. * * @return array> */ public function inventory(): array { $rows = []; foreach ($this->taxonomies() as $attribute) { $taxonomy = wc_attribute_taxonomy_name($attribute->attribute_name); $terms = $this->terms($taxonomy); $rows[] = [ 'id' => (int) $attribute->attribute_id, 'label' => (string) $attribute->attribute_label, 'slug' => (string) $attribute->attribute_name, 'taxonomy' => $taxonomy, 'terms' => count($terms), 'products' => count($this->productsUsing($taxonomy)), 'term_items' => array_map(fn ($term): array => $this->termInventory($term, $taxonomy), $terms), ]; } return $rows; } /** * Report what each operation would do, without changing anything. * * @param array> $operations * @return array */ public function plan(array $operations): array { $steps = array_map(fn (array $op): array => $this->planOne($op), $operations); return [ 'applied' => false, 'ready' => $this->countStatus($steps, self::STATUS_READY), 'skipped' => $this->countStatus($steps, self::STATUS_SKIPPED), 'blocked' => $this->countStatus($steps, self::STATUS_BLOCKED), 'products_affected' => $this->totalProducts($steps), 'steps' => $steps, ]; } /** * Plan, then execute if nothing is blocked. * * @param array> $operations * @return array */ public function apply(array $operations): array { $plan = $this->plan($operations); if ($plan['blocked'] > 0) { $reasons = []; foreach ($plan['steps'] as $step) { if ($step['status'] === self::STATUS_BLOCKED) { $reasons[] = $step['reason']; } } throw new RuntimeException(sprintf( 'Nothing was applied. %d of %d operations are blocked: %s', $plan['blocked'], count($operations), implode(' ', array_slice($reasons, 0, 5)) )); } $steps = []; $touched = []; foreach ($plan['steps'] as $step) { if ($step['status'] === self::STATUS_SKIPPED) { $steps[] = $step; continue; } $applied = $this->applyOne($step); $steps[] = $applied; $touched = array_merge($touched, $applied['product_ids'] ?? []); } $refreshed = $this->flush(array_values(array_unique(array_map('intval', $touched)))); $result = [ 'applied' => true, 'ready' => $this->countStatus($steps, 'applied'), 'skipped' => $this->countStatus($steps, self::STATUS_SKIPPED), 'blocked' => 0, 'products_affected' => $this->totalProducts($steps), 'lookup_rebuild_recommended' => false, 'lookup_refreshed_product_ids' => $refreshed, 'steps' => $steps, ]; $this->record($result); return $result; } /** * @param array $op * @return array */ private function planOne(array $op): array { return match ($op['op']) { 'rename_attribute' => $this->planRenameAttribute($op), 'rename_term' => $this->planRenameTerm($op), 'merge_terms' => $this->planMergeTerms($op), 'reassign_term_usage' => $this->planReassignTermUsage($op), 'move_term_usage' => $this->planMoveTermUsage($op), 'delete_term' => $this->planDeleteTerm($op), 'delete_attribute' => $this->planDeleteAttribute($op), default => $this->step($op, self::STATUS_BLOCKED, 'Unsupported operation.'), }; } /** * @param array $op * @return array */ private function planRenameAttribute(array $op): array { $attribute = $this->resolveAttribute($op['from']); if (!$attribute) { return $this->step($op, self::STATUS_SKIPPED, sprintf('No attribute named "%s".', $op['from'])); } $slugChange = $op['slug'] !== '' && $op['slug'] !== $attribute->attribute_name; if ((string) $attribute->attribute_label === $op['to'] && !$slugChange) { return $this->step($op, self::STATUS_SKIPPED, 'Already named this.'); } if ($slugChange) { $existing = wc_attribute_taxonomy_id_by_name(wc_attribute_taxonomy_name($op['slug'])); if ($existing) { return $this->step($op, self::STATUS_BLOCKED, sprintf( 'Slug "%s" is already used by another attribute.', $op['slug'] )); } } $taxonomy = wc_attribute_taxonomy_name($attribute->attribute_name); $products = $this->productsUsing($taxonomy); return $this->step($op, self::STATUS_READY, $slugChange ? 'Renames the label and the slug. The storefront URL for this filter changes.' : 'Renames the label. Slug, terms and product links are untouched.', [ 'attribute_id' => (int) $attribute->attribute_id, 'taxonomy' => $taxonomy, 'from_label' => (string) $attribute->attribute_label, 'to_label' => $op['to'], 'from_slug' => (string) $attribute->attribute_name, 'to_slug' => $slugChange ? $op['slug'] : (string) $attribute->attribute_name, 'slug_change' => $slugChange, 'type' => (string) ($attribute->attribute_type ?? 'select'), 'order_by' => (string) ($attribute->attribute_orderby ?? 'menu_order'), 'has_archives' => (bool) ($attribute->attribute_public ?? false), ], $slugChange ? $products : []); } /** * @param array $op * @return array */ private function planRenameTerm(array $op): array { $taxonomy = (string) $op['taxonomy']; if (!taxonomy_exists($taxonomy) || wc_attribute_taxonomy_id_by_name($taxonomy) <= 0) { return $this->step($op, self::STATUS_BLOCKED, sprintf( 'Taxonomy "%s" is not a registered WooCommerce global attribute.', $taxonomy )); } $term = $this->exactTerm($taxonomy, $op['term']); if (!$term) { return $this->step($op, self::STATUS_BLOCKED, sprintf( 'The exact term does not exist in "%s".', $taxonomy )); } $detail = [ 'taxonomy' => $taxonomy, 'term' => $this->termDetail($term, $taxonomy, MultilingualAdapterFactory::make()), 'to_name' => $op['to'], ]; if ($term['name'] === $op['to']) { return $this->step($op, self::STATUS_SKIPPED, 'This value already has that display name.', $detail); } return $this->step( $op, self::STATUS_READY, 'Changes the value display name. Its term ID, slug, translation identity and product links are untouched.', $detail ); } /** * @param array $op * @return array */ private function planMergeTerms(array $op): array { $attribute = $this->resolveAttribute($op['attribute']); if (!$attribute) { return $this->step($op, self::STATUS_SKIPPED, sprintf('No attribute named "%s".', $op['attribute'])); } $taxonomy = wc_attribute_taxonomy_name($attribute->attribute_name); $sources = []; $productIds = []; foreach ($op['from'] as $name) { $term = get_term_by('name', $name, $taxonomy); if (!$term) { continue; } $objects = get_objects_in_term([(int) $term->term_id], $taxonomy); $objects = is_array($objects) ? array_map('intval', $objects) : []; $sources[] = ['name' => $name, 'term_id' => (int) $term->term_id, 'products' => count($objects)]; $productIds = array_merge($productIds, $objects); } if ($sources === []) { return $this->step($op, self::STATUS_SKIPPED, 'None of these values exist on this attribute.'); } $target = get_term_by('name', $op['into'], $taxonomy); return $this->step($op, self::STATUS_READY, sprintf( 'Moves %d product(s) onto "%s", then removes %d old value(s).', count(array_unique($productIds)), $op['into'], count($sources) ), [ 'taxonomy' => $taxonomy, 'into' => $op['into'], 'into_exists' => (bool) $target, 'sources' => $sources, ], $productIds); } /** * @param array $op * @return array */ private function planReassignTermUsage(array $op): array { $taxonomy = (string) $op['taxonomy']; if (!taxonomy_exists($taxonomy) || wc_attribute_taxonomy_id_by_name($taxonomy) <= 0) { return $this->step($op, self::STATUS_BLOCKED, sprintf( 'Taxonomy "%s" is not a registered WooCommerce global attribute.', $taxonomy )); } $source = $this->exactTerm($taxonomy, $op['source']); $target = $this->exactTerm($taxonomy, $op['target']); if (!$source || !$target) { return $this->step($op, self::STATUS_BLOCKED, sprintf( 'The exact %s term does not exist in "%s".', !$source ? 'source' : 'target', $taxonomy )); } if ($source['term_id'] === $target['term_id']) { return $this->step($op, self::STATUS_BLOCKED, 'The exact source and target resolve to the same term ID.'); } $multilingual = MultilingualAdapterFactory::make(); $sourceLanguage = $multilingual->termLanguage($source['term_id'], $taxonomy); $targetLanguage = $multilingual->termLanguage($target['term_id'], $taxonomy); if ($sourceLanguage !== '' && $targetLanguage !== '' && $sourceLanguage !== $targetLanguage) { return $this->step($op, self::STATUS_BLOCKED, sprintf( 'Source language "%s" and target language "%s" differ. Reassign terms inside one language.', $sourceLanguage, $targetLanguage )); } $relationshipIds = $this->termObjectIds($source['term_id'], $taxonomy); $classified = $this->classifyTermObjects($relationshipIds); $variationIds = $this->variationsUsingSlug($taxonomy, $source['slug'], $sourceLanguage, $multilingual); $activeIds = array_values(array_unique(array_merge( $classified['product_ids'], $classified['variation_ids'], $variationIds ))); sort($activeIds); $detail = [ 'taxonomy' => $taxonomy, 'source' => $this->termDetail($source, $taxonomy, $multilingual), 'target' => $this->termDetail($target, $taxonomy, $multilingual), 'delete_source' => (bool) $op['delete_source'], 'relationship_product_ids' => $classified['product_ids'], 'relationship_variation_ids' => $classified['variation_ids'], 'variation_attribute_ids' => $variationIds, 'stale_relationship_object_ids' => $classified['stale_ids'], ]; $workCount = count($activeIds) + count($classified['stale_ids']) + ($op['delete_source'] ? 1 : 0); if ($workCount === 0) { return $this->step($op, self::STATUS_SKIPPED, 'The exact source term has no product use.', $detail); } if ($activeIds !== [] && !$this->lookupRefreshAvailable()) { return $this->step( $op, self::STATUS_BLOCKED, 'This WooCommerce version cannot refresh the product attribute lookup data.', $detail, $activeIds ); } return $this->step($op, self::STATUS_READY, sprintf( 'Reassigns %d active product or variation ID(s), cleans %d stale relationship(s), and %s the source term.', count($activeIds), count($classified['stale_ids']), $op['delete_source'] ? 'deletes' : 'keeps' ), $detail, $activeIds); } /** * @param array $op * @return array */ private function planMoveTermUsage(array $op): array { $sourceTaxonomy = (string) $op['source']['taxonomy']; $targetTaxonomy = (string) $op['target']['taxonomy']; foreach ([$sourceTaxonomy, $targetTaxonomy] as $taxonomy) { if (!taxonomy_exists($taxonomy) || wc_attribute_taxonomy_id_by_name($taxonomy) <= 0) { return $this->step($op, self::STATUS_BLOCKED, sprintf( 'Taxonomy "%s" is not a registered WooCommerce global attribute.', $taxonomy )); } } $sourceSelector = array_diff_key($op['source'], ['taxonomy' => true]); $targetSelector = array_diff_key($op['target'], ['taxonomy' => true]); $source = $this->exactTerm($sourceTaxonomy, $sourceSelector); $target = $this->exactTerm($targetTaxonomy, $targetSelector); if (!$source || !$target) { return $this->step($op, self::STATUS_BLOCKED, sprintf( 'The exact %s term is missing or ambiguous in "%s".', !$source ? 'source' : 'target', !$source ? $sourceTaxonomy : $targetTaxonomy )); } $multilingual = MultilingualAdapterFactory::make(); $sourceLanguage = $multilingual->termLanguage($source['term_id'], $sourceTaxonomy); $targetLanguage = $multilingual->termLanguage($target['term_id'], $targetTaxonomy); if ($sourceLanguage !== '' && $targetLanguage !== '' && $sourceLanguage !== $targetLanguage) { return $this->step($op, self::STATUS_BLOCKED, sprintf( 'Source language "%s" and target language "%s" differ. Move terms inside one language.', $sourceLanguage, $targetLanguage )); } $relationshipIds = $this->termObjectIds($source['term_id'], $sourceTaxonomy); $classified = $this->classifyTermObjects($relationshipIds); $variationAttributeIds = $this->variationsUsingSlug( $sourceTaxonomy, $source['slug'], $sourceLanguage, $multilingual ); $activeVariationIds = array_values(array_unique(array_merge( $classified['variation_ids'], $variationAttributeIds ))); sort($activeVariationIds); $activeProductIds = $classified['product_ids']; foreach ($activeVariationIds as $variationId) { $parentId = (int) wp_get_post_parent_id($variationId); if ($parentId > 0 && get_post_type($parentId) === 'product') { $activeProductIds[] = $parentId; } } $activeProductIds = array_values(array_unique(array_map('intval', $activeProductIds))); sort($activeProductIds); $activeIds = array_values(array_unique(array_merge($activeProductIds, $activeVariationIds))); sort($activeIds); $detail = [ 'source_taxonomy' => $sourceTaxonomy, 'target_taxonomy' => $targetTaxonomy, 'source' => $this->termDetail($source, $sourceTaxonomy, $multilingual), 'target' => $this->termDetail($target, $targetTaxonomy, $multilingual), 'delete_source_term' => (bool) $op['delete_source_term'], 'active_product_ids' => $activeProductIds, 'active_variation_ids' => $activeVariationIds, 'relationship_product_ids' => $classified['product_ids'], 'relationship_variation_ids' => $classified['variation_ids'], 'variation_attribute_ids' => $variationAttributeIds, 'stale_relationship_object_ids' => $classified['stale_ids'], ]; $workCount = count($activeIds) + count($classified['stale_ids']) + ($op['delete_source_term'] ? 1 : 0); if ($workCount === 0) { return $this->step($op, self::STATUS_SKIPPED, 'The exact source term has no product use.', $detail); } if ($activeIds !== [] && !$this->lookupRefreshAvailable()) { return $this->step( $op, self::STATUS_BLOCKED, 'This WooCommerce version cannot refresh the product attribute lookup data.', $detail, $activeIds ); } return $this->step($op, self::STATUS_READY, sprintf( 'Moves %d active product(s) and %d active variation(s), cleans %d stale relationship(s), and %s the source term.', count($activeProductIds), count($activeVariationIds), count($classified['stale_ids']), $op['delete_source_term'] ? 'deletes' : 'keeps' ), $detail, $activeIds); } /** * @param array $op * @return array */ private function planDeleteTerm(array $op): array { $attribute = $this->resolveAttribute($op['attribute']); if (!$attribute) { return $this->step($op, self::STATUS_SKIPPED, sprintf('No attribute named "%s".', $op['attribute'])); } $taxonomy = wc_attribute_taxonomy_name($attribute->attribute_name); $term = get_term_by('name', $op['name'], $taxonomy); if (!$term) { return $this->step($op, self::STATUS_SKIPPED, sprintf('No value "%s" on this attribute.', $op['name'])); } $objects = get_objects_in_term([(int) $term->term_id], $taxonomy); $objects = is_array($objects) ? array_map('intval', $objects) : []; return $this->step($op, self::STATUS_READY, sprintf( 'Removes this value from %d product(s). They keep the attribute, with nothing selected.', count($objects) ), ['taxonomy' => $taxonomy, 'term_id' => (int) $term->term_id], $objects); } /** * @param array $op * @return array */ private function planDeleteAttribute(array $op): array { $attribute = $this->resolveAttribute($op['name']); if (!$attribute) { return $this->step($op, self::STATUS_SKIPPED, sprintf('No attribute named "%s".', $op['name'])); } $taxonomy = wc_attribute_taxonomy_name($attribute->attribute_name); $products = $this->productsUsing($taxonomy); if ($products !== [] && !$op['force']) { return $this->step($op, self::STATUS_BLOCKED, sprintf( 'Attribute "%s" is still used by %d product(s). Publish the replacement first, or resend with force.', $op['name'], count($products) ), ['taxonomy' => $taxonomy], $products); } return $this->step($op, self::STATUS_READY, sprintf( 'Deletes the attribute, its %d value(s), and removes it from %d product(s). This cannot be undone.', count($this->terms($taxonomy)), count($products) ), [ 'attribute_id' => (int) $attribute->attribute_id, 'taxonomy' => $taxonomy, 'forced' => (bool) $op['force'], ], $products); } /** * @param array $step * @return array */ private function applyOne(array $step): array { $detail = $step['detail']; match ($step['op']) { 'rename_attribute' => $this->doRenameAttribute($detail), 'rename_term' => $this->doRenameTerm($detail), 'merge_terms' => $this->doMergeTerms($detail), 'reassign_term_usage' => null, 'move_term_usage' => null, 'delete_term' => $this->doDeleteTerm($detail), 'delete_attribute' => $this->doDeleteAttribute($detail), default => throw new RuntimeException('Unsupported operation.'), }; if ($step['op'] === 'reassign_term_usage') { $result = $this->doReassignTermUsage($detail); $step['detail']['result'] = $result; $step['product_ids'] = $result['changed_product_ids']; $step['products_affected'] = count($result['changed_product_ids']); } if ($step['op'] === 'move_term_usage') { $result = $this->doMoveTermUsage($detail); $step['detail']['result'] = $result; $step['product_ids'] = $result['changed_product_ids']; $step['products_affected'] = count($result['changed_product_ids']); } $step['status'] = 'applied'; return $step; } /** @param array $detail */ private function doRenameAttribute(array $detail): void { // wc_update_attribute() overwrites every column it is given, so the // settings we are not changing have to be passed back verbatim. $result = wc_update_attribute((int) $detail['attribute_id'], [ 'name' => $detail['to_label'], 'slug' => $detail['to_slug'], 'type' => $detail['type'], 'order_by' => $detail['order_by'], 'has_archives' => $detail['has_archives'], ]); if (is_wp_error($result)) { throw new RuntimeException(esc_html(sanitize_text_field($result->get_error_message()))); } delete_transient('wc_attribute_taxonomies'); } /** @param array $detail */ private function doRenameTerm(array $detail): void { $taxonomy = (string) $detail['taxonomy']; $planned = $detail['term']; $current = $this->exactTerm($taxonomy, ['term_id' => (int) $planned['term_id']]); if ( !$current || $current['name'] !== $planned['name'] || $current['slug'] !== $planned['slug'] || $current['term_taxonomy_id'] !== $planned['term_taxonomy_id'] ) { throw new RuntimeException('The exact term changed after planning; request a new plan.'); } // Do not resubmit the unchanged slug. WPML translations can legitimately // share it, while WordPress revalidates an explicitly supplied slug. $result = wp_update_term((int) $current['term_id'], $taxonomy, [ 'name' => (string) $detail['to_name'], ]); if (is_wp_error($result)) { throw new RuntimeException(esc_html(sanitize_text_field($result->get_error_message()))); } $updated = $this->exactTerm($taxonomy, ['term_id' => (int) $current['term_id']]); if ( !$updated || $updated['name'] !== $detail['to_name'] || $updated['slug'] !== $current['slug'] || $updated['term_taxonomy_id'] !== $current['term_taxonomy_id'] ) { throw new RuntimeException('The value display name could not be verified after the update.'); } } /** @param array $detail */ private function doMergeTerms(array $detail): void { $taxonomy = (string) $detail['taxonomy']; $targetId = $this->ensureTerm($taxonomy, (string) $detail['into']); foreach ($detail['sources'] as $source) { $sourceId = (int) $source['term_id']; $objects = get_objects_in_term([$sourceId], $taxonomy); foreach (is_array($objects) ? $objects : [] as $objectId) { $assigned = wp_set_object_terms((int) $objectId, [$targetId], $taxonomy, true); if (is_wp_error($assigned)) { throw new RuntimeException(esc_html(sanitize_text_field($assigned->get_error_message()))); } } $deleted = wp_delete_term($sourceId, $taxonomy); if (is_wp_error($deleted)) { throw new RuntimeException(esc_html(sanitize_text_field($deleted->get_error_message()))); } } } /** * @param array $detail * @return array */ private function doReassignTermUsage(array $detail): array { $taxonomy = (string) $detail['taxonomy']; $sourceId = (int) $detail['source']['term_id']; $targetId = (int) $detail['target']['term_id']; $changed = []; $staleCleaned = []; $relationshipIds = array_values(array_unique(array_merge( $detail['relationship_product_ids'], $detail['relationship_variation_ids'] ))); foreach ($relationshipIds as $objectId) { $objectId = (int) $objectId; $assigned = wp_set_object_terms($objectId, [$targetId], $taxonomy, true); if (is_wp_error($assigned)) { throw new RuntimeException(esc_html(sanitize_text_field($assigned->get_error_message()))); } $removed = wp_remove_object_terms($objectId, [$sourceId], $taxonomy); if (is_wp_error($removed)) { throw new RuntimeException(esc_html(sanitize_text_field($removed->get_error_message()))); } $changed[] = $objectId; } $variationMetaKey = 'attribute_' . $taxonomy; foreach ($detail['variation_attribute_ids'] as $variationId) { $variationId = (int) $variationId; if (get_post_meta($variationId, $variationMetaKey, true) !== $detail['source']['slug']) { continue; } update_post_meta($variationId, $variationMetaKey, $detail['target']['slug']); $changed[] = $variationId; } foreach ($detail['stale_relationship_object_ids'] as $objectId) { $removed = wp_remove_object_terms((int) $objectId, [$sourceId], $taxonomy); if (is_wp_error($removed)) { throw new RuntimeException(esc_html(sanitize_text_field($removed->get_error_message()))); } $staleCleaned[] = (int) $objectId; } $remainingRelationships = $this->termObjectIds($sourceId, $taxonomy); $remainingVariations = $this->variationsUsingSlug( $taxonomy, (string) $detail['source']['slug'], '', MultilingualAdapterFactory::make() ); if ($remainingRelationships !== [] || $remainingVariations !== []) { throw new RuntimeException('The source term still has product or variation use after reassignment.'); } $sourceDeleted = false; if ($detail['delete_source']) { $deleted = wp_delete_term($sourceId, $taxonomy); if (is_wp_error($deleted)) { throw new RuntimeException(esc_html(sanitize_text_field($deleted->get_error_message()))); } $sourceDeleted = true; } $changed = array_values(array_unique(array_map('intval', $changed))); $staleCleaned = array_values(array_unique(array_map('intval', $staleCleaned))); sort($changed); sort($staleCleaned); return [ 'changed_product_ids' => $changed, 'stale_relationship_object_ids_cleaned' => $staleCleaned, 'source_deleted' => $sourceDeleted, ]; } /** * @param array $detail * @return array */ private function doMoveTermUsage(array $detail): array { $sourceTaxonomy = (string) $detail['source_taxonomy']; $targetTaxonomy = (string) $detail['target_taxonomy']; $sourceId = (int) $detail['source']['term_id']; $targetId = (int) $detail['target']['term_id']; $plannedRelationships = array_values(array_unique(array_merge( $detail['relationship_product_ids'], $detail['relationship_variation_ids'], $detail['stale_relationship_object_ids'] ))); sort($plannedRelationships); if ($this->termObjectIds($sourceId, $sourceTaxonomy) !== $plannedRelationships) { throw new RuntimeException('The exact source-term relationship scope changed after planning; request a new plan.'); } foreach ($detail['active_product_ids'] as $objectId) { $objectId = (int) $objectId; if ( get_post_type($objectId) !== 'product' || in_array(get_post_status($objectId), [false, 'trash', 'auto-draft'], true) ) { throw new RuntimeException(sprintf('Planned product %d is no longer active.', $objectId)); } } foreach ($detail['active_variation_ids'] as $objectId) { $objectId = (int) $objectId; if ( get_post_type($objectId) !== 'product_variation' || in_array(get_post_status($objectId), [false, 'trash', 'auto-draft'], true) ) { throw new RuntimeException(sprintf('Planned variation %d is no longer active.', $objectId)); } } $plannedVariations = array_values(array_unique(array_map('intval', $detail['variation_attribute_ids']))); sort($plannedVariations); $sourceVariationKey = 'attribute_' . $sourceTaxonomy; $targetVariationKey = 'attribute_' . $targetTaxonomy; if ( $this->variationsUsingSlug( $sourceTaxonomy, (string) $detail['source']['slug'], (string) $detail['source']['language'], MultilingualAdapterFactory::make() ) !== $plannedVariations ) { throw new RuntimeException('The exact source variation scope changed after planning; request a new plan.'); } foreach ($plannedVariations as $variationId) { $sourceValue = get_post_meta($variationId, $sourceVariationKey, true); if ( $sourceValue !== $detail['source']['slug'] && ($sourceValue !== '' || !metadata_exists('post', $variationId, $targetVariationKey)) ) { throw new RuntimeException(sprintf( 'Variation %d no longer has the planned source or an existing target value.', $variationId )); } } if ($detail['delete_source_term']) { $unexpectedRelationships = array_diff( $this->termObjectIds($sourceId, $sourceTaxonomy), $plannedRelationships ); $unexpectedVariations = array_diff( $this->variationsUsingSlug( $sourceTaxonomy, (string) $detail['source']['slug'], '', MultilingualAdapterFactory::make() ), $plannedVariations ); if ($unexpectedRelationships !== [] || $unexpectedVariations !== []) { throw new RuntimeException('The source term gained use outside the planned scope; request a new plan.'); } } $changed = []; foreach ($detail['active_product_ids'] as $productId) { $productId = (int) $productId; $this->addExactTermRelationship($productId, (int) $detail['target']['term_taxonomy_id']); $this->mergeProductAttributeDefinition($productId, $sourceTaxonomy, $targetTaxonomy); $changed[] = $productId; } foreach ($detail['active_variation_ids'] as $variationId) { $variationId = (int) $variationId; $this->addExactTermRelationship($variationId, (int) $detail['target']['term_taxonomy_id']); $changed[] = $variationId; } foreach ($plannedVariations as $variationId) { if (get_post_meta($variationId, $sourceVariationKey, true) === $detail['source']['slug']) { if (!metadata_exists('post', $variationId, $targetVariationKey)) { update_post_meta($variationId, $targetVariationKey, (string) $detail['target']['slug']); } delete_post_meta($variationId, $sourceVariationKey); } } $activeRelationshipIds = array_values(array_unique(array_merge( $detail['relationship_product_ids'], $detail['relationship_variation_ids'] ))); foreach ($activeRelationshipIds as $objectId) { $this->removeExactTermRelationship((int) $objectId, (int) $detail['source']['term_taxonomy_id']); } foreach ($detail['active_product_ids'] as $productId) { $this->removeEmptyProductAttributeDefinition((int) $productId, $sourceTaxonomy); } $staleCleaned = []; foreach ($detail['stale_relationship_object_ids'] as $objectId) { $this->removeExactTermRelationship((int) $objectId, (int) $detail['source']['term_taxonomy_id']); $staleCleaned[] = (int) $objectId; } wp_update_term_count_now([$sourceId], $sourceTaxonomy); wp_update_term_count_now([$targetId], $targetTaxonomy); clean_term_cache([$sourceId], $sourceTaxonomy); clean_term_cache([$targetId], $targetTaxonomy); $sourceDeleted = false; if ($detail['delete_source_term']) { if ( $this->termObjectIds($sourceId, $sourceTaxonomy) !== [] || $this->variationsUsingSlug( $sourceTaxonomy, (string) $detail['source']['slug'], '', MultilingualAdapterFactory::make() ) !== [] ) { throw new RuntimeException('The source term still has product or variation use after the move.'); } $deleted = wp_delete_term($sourceId, $sourceTaxonomy); if (is_wp_error($deleted)) { throw new RuntimeException(esc_html(sanitize_text_field($deleted->get_error_message()))); } $sourceDeleted = true; } $changed = array_values(array_unique(array_map('intval', $changed))); $staleCleaned = array_values(array_unique(array_map('intval', $staleCleaned))); sort($changed); sort($staleCleaned); return [ 'changed_product_ids' => $changed, 'stale_relationship_object_ids_cleaned' => $staleCleaned, 'source_deleted' => $sourceDeleted, ]; } private function mergeProductAttributeDefinition( int $productId, string $sourceTaxonomy, string $targetTaxonomy ): void { $meta = get_post_meta($productId, '_product_attributes', true); $meta = is_array($meta) ? $meta : []; if (array_key_exists($targetTaxonomy, $meta)) { return; } $source = is_array($meta[$sourceTaxonomy] ?? null) ? $meta[$sourceTaxonomy] : null; if ($source) { $target = $source; $target['name'] = $targetTaxonomy; $target['value'] = ''; $target['is_taxonomy'] = 1; } else { $positions = array_map( static fn ($attribute): int => is_array($attribute) ? (int) ($attribute['position'] ?? 0) : 0, $meta ); $target = [ 'name' => $targetTaxonomy, 'value' => '', 'position' => $positions === [] ? 0 : max($positions) + 1, 'is_visible' => 1, 'is_variation' => 0, 'is_taxonomy' => 1, ]; } $meta[$targetTaxonomy] = $target; update_post_meta($productId, '_product_attributes', $meta); } private function removeEmptyProductAttributeDefinition(int $productId, string $taxonomy): void { $remaining = $this->objectTermIds($productId, $taxonomy); if ($remaining !== []) { return; } $meta = get_post_meta($productId, '_product_attributes', true); if (!is_array($meta) || !array_key_exists($taxonomy, $meta)) { return; } unset($meta[$taxonomy]); update_post_meta($productId, '_product_attributes', $meta); } private function addExactTermRelationship(int $objectId, int $termTaxonomyId): void { // Core term-assignment hooks let WPML/WCML synchronize translated // products. This maintenance operation receives every language as an // exact, reviewed scope, so write only that relationship and perform // the term-count, object-cache, and lookup refreshes explicitly below. global $wpdb; $exists = $wpdb->get_var($wpdb->prepare( "SELECT object_id FROM {$wpdb->term_relationships} WHERE object_id=%d AND term_taxonomy_id=%d", $objectId, $termTaxonomyId )); if ($exists !== null) { return; } if ( $wpdb->insert($wpdb->term_relationships, [ 'object_id' => $objectId, 'term_taxonomy_id' => $termTaxonomyId, 'term_order' => 0, ], ['%d', '%d', '%d']) === false ) { throw new RuntimeException('The exact target term relationship could not be added.'); } } private function removeExactTermRelationship(int $objectId, int $termTaxonomyId): void { global $wpdb; if ( $wpdb->delete($wpdb->term_relationships, [ 'object_id' => $objectId, 'term_taxonomy_id' => $termTaxonomyId, ], ['%d', '%d']) === false ) { throw new RuntimeException('The exact source term relationship could not be removed.'); } } /** @return array */ private function objectTermIds(int $objectId, string $taxonomy): array { global $wpdb; $ids = $wpdb->get_col($wpdb->prepare( "SELECT tt.term_id FROM {$wpdb->term_relationships} tr INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id=tr.term_taxonomy_id WHERE tr.object_id=%d AND tt.taxonomy=%s ORDER BY tt.term_id ASC", $objectId, $taxonomy )); return array_values(array_map('intval', is_array($ids) ? $ids : [])); } /** @param array $detail */ private function doDeleteTerm(array $detail): void { $deleted = wp_delete_term((int) $detail['term_id'], (string) $detail['taxonomy']); if (is_wp_error($deleted)) { throw new RuntimeException(esc_html(sanitize_text_field($deleted->get_error_message()))); } } /** @param array $detail */ private function doDeleteAttribute(array $detail): void { $taxonomy = (string) $detail['taxonomy']; $this->stripProductAttributeMeta($taxonomy, $this->productsUsing($taxonomy)); $result = wc_delete_attribute((int) $detail['attribute_id']); if ($result === false) { throw new RuntimeException('WooCommerce refused to delete this attribute.'); } delete_transient('wc_attribute_taxonomies'); } /** * Deleting a taxonomy leaves the per-product attribute definition behind, * which would render as an empty row on the product page. Remove it. * * @param array $productIds */ private function stripProductAttributeMeta(string $taxonomy, array $productIds): void { foreach ($productIds as $productId) { $meta = get_post_meta((int) $productId, '_product_attributes', true); if (!is_array($meta) || !array_key_exists($taxonomy, $meta)) { continue; } unset($meta[$taxonomy]); update_post_meta((int) $productId, '_product_attributes', $meta); } } private function ensureTerm(string $taxonomy, string $name): int { $term = get_term_by('name', $name, $taxonomy); if ($term) { return (int) $term->term_id; } $created = wp_insert_term($name, $taxonomy); if (is_wp_error($created)) { throw new RuntimeException(esc_html(sanitize_text_field($created->get_error_message()))); } return (int) $created['term_id']; } /** * @param array $productIds * @return array */ private function flush(array $productIds): array { $lookupIds = []; foreach ($productIds as $productId) { $productId = (int) $productId; if (function_exists('wc_delete_product_transients')) { wc_delete_product_transients($productId); } clean_post_cache($productId); $lookupId = get_post_type($productId) === 'product_variation' ? (int) wp_get_post_parent_id($productId) : $productId; if ($lookupId > 0 && get_post_type($lookupId) === 'product') { $lookupIds[] = $lookupId; } } $lookupIds = array_values(array_unique($lookupIds)); sort($lookupIds); if ($lookupIds !== [] && !$this->lookupRefreshAvailable()) { throw new RuntimeException('WooCommerce product attribute lookup refresh is unavailable.'); } if ($lookupIds !== []) { /** @var LookupDataStore $lookup */ $lookup = wc_get_container()->get(LookupDataStore::class); $multilingual = MultilingualAdapterFactory::make(); foreach ($lookupIds as $lookupId) { $previousLanguage = $multilingual->switchLanguage( $multilingual->productLanguage($lookupId) ); try { $lookup->create_data_for_product($lookupId); if ( is_callable([$lookup, 'get_last_create_operation_failed']) && $lookup->get_last_create_operation_failed() ) { throw new RuntimeException(sprintf( 'WooCommerce attribute lookup refresh failed for product %d.', $lookupId )); } } catch (Throwable $error) { throw new RuntimeException(sprintf( 'WooCommerce attribute lookup refresh failed for product %d: %s', $lookupId, sanitize_text_field($error->getMessage()) )); } finally { $multilingual->restoreLanguage($previousLanguage); } } } delete_transient('wc_attribute_taxonomies'); return $lookupIds; } private function lookupRefreshAvailable(): bool { return function_exists('wc_get_container') && class_exists(LookupDataStore::class); } /** @return array */ private function taxonomies(): array { $taxonomies = function_exists('wc_get_attribute_taxonomies') ? wc_get_attribute_taxonomies() : []; return is_array($taxonomies) ? $taxonomies : []; } private function resolveAttribute(string $name): ?object { $needle = strtolower($name); foreach ($this->taxonomies() as $attribute) { if (strtolower((string) $attribute->attribute_label) === $needle) { return $attribute; } } foreach ($this->taxonomies() as $attribute) { if (strtolower((string) $attribute->attribute_name) === $needle) { return $attribute; } } return null; } /** @return array */ private function terms(string $taxonomy): array { if (!taxonomy_exists($taxonomy)) { register_taxonomy($taxonomy, ['product']); } global $wpdb; $terms = $wpdb->get_results($wpdb->prepare( "SELECT t.term_id,t.name,t.slug,tt.term_taxonomy_id,tt.taxonomy,tt.count FROM {$wpdb->terms} t INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_id=t.term_id WHERE tt.taxonomy=%s ORDER BY t.name ASC,t.term_id ASC", $taxonomy )); return is_array($terms) ? $terms : []; } /** * Resolve a term without language display filters changing its identity. * * @param array{term_id:int}|array{slug:string} $selector * @return array|null */ private function exactTerm(string $taxonomy, array $selector): ?array { global $wpdb; if (isset($selector['term_id'])) { $rows = $wpdb->get_results($wpdb->prepare( "SELECT t.term_id,t.name,t.slug,tt.term_taxonomy_id,tt.count FROM {$wpdb->terms} t INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_id=t.term_id WHERE tt.taxonomy=%s AND t.term_id=%d LIMIT 2", $taxonomy, $selector['term_id'] ), ARRAY_A); } else { $rows = $wpdb->get_results($wpdb->prepare( "SELECT t.term_id,t.name,t.slug,tt.term_taxonomy_id,tt.count FROM {$wpdb->terms} t INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_id=t.term_id WHERE tt.taxonomy=%s AND t.slug=%s LIMIT 2", $taxonomy, $selector['slug'] ), ARRAY_A); } if (!is_array($rows) || count($rows) !== 1 || !is_array($rows[0])) { return null; } $row = $rows[0]; return [ 'term_id' => (int) $row['term_id'], 'term_taxonomy_id' => (int) $row['term_taxonomy_id'], 'name' => (string) $row['name'], 'slug' => (string) $row['slug'], 'count' => (int) $row['count'], ]; } /** @return array */ private function termObjectIds(int $termId, string $taxonomy): array { $objects = get_objects_in_term([$termId], $taxonomy); $ids = is_array($objects) ? array_values(array_unique(array_map('intval', $objects))) : []; sort($ids); return $ids; } /** * @param array $objectIds * @return array{product_ids:array,variation_ids:array,stale_ids:array} */ private function classifyTermObjects(array $objectIds): array { $products = []; $variations = []; $stale = []; foreach ($objectIds as $objectId) { $objectId = (int) $objectId; $type = get_post_type($objectId); $status = get_post_status($objectId); if ( ($type !== 'product' && $type !== 'product_variation') || !$status || in_array($status, ['trash', 'auto-draft'], true) ) { $stale[] = $objectId; continue; } if ($type === 'product') { $products[] = $objectId; } else { $variations[] = $objectId; } } sort($products); sort($variations); sort($stale); return ['product_ids' => $products, 'variation_ids' => $variations, 'stale_ids' => $stale]; } /** * @return array */ private function variationsUsingSlug( string $taxonomy, string $slug, string $language, \CrocoAI\DataBridge\Contracts\MultilingualAdapter $multilingual ): array { $statuses = array_values(array_diff(get_post_stati(), ['trash', 'auto-draft'])); $ids = get_posts([ 'post_type' => 'product_variation', 'post_status' => $statuses, 'posts_per_page' => -1, 'fields' => 'ids', 'meta_key' => 'attribute_' . $taxonomy, 'meta_value' => $slug, 'suppress_filters' => true, 'no_found_rows' => true, ]); $ids = is_array($ids) ? array_values(array_unique(array_map('intval', $ids))) : []; if ($language !== '' && $multilingual->available()) { $ids = array_values(array_filter($ids, static function (int $variationId) use ($language, $multilingual): bool { $productId = (int) wp_get_post_parent_id($variationId); return $productId > 0 && $multilingual->productLanguage($productId) === $language; })); } sort($ids); return $ids; } /** @return array */ private function termInventory(object $term, string $taxonomy): array { $exact = $this->exactTerm($taxonomy, ['term_id' => (int) $term->term_id]); if (!$exact) { return ['term_id' => (int) $term->term_id, 'missing' => true]; } return $this->termDetail($exact, $taxonomy, MultilingualAdapterFactory::make()); } /** * @param array $term * @return array */ private function termDetail( array $term, string $taxonomy, \CrocoAI\DataBridge\Contracts\MultilingualAdapter $multilingual ): array { return [ 'term_id' => (int) $term['term_id'], 'term_taxonomy_id' => (int) $term['term_taxonomy_id'], 'name' => (string) $term['name'], 'slug' => (string) $term['slug'], 'count' => (int) $term['count'], 'language' => $multilingual->termLanguage((int) $term['term_id'], $taxonomy), 'translations' => $multilingual->termTranslationMap((int) $term['term_id'], $taxonomy), ]; } /** @return array */ private function productsUsing(string $taxonomy): array { $termIds = array_map(static fn ($term): int => (int) $term->term_id, $this->terms($taxonomy)); if ($termIds === []) { return []; } $objects = get_objects_in_term($termIds, $taxonomy); return is_array($objects) ? array_values(array_unique(array_map('intval', $objects))) : []; } /** * @param array $op * @param array $detail * @param array $productIds * @return array */ private function step(array $op, string $status, string $reason, array $detail = [], array $productIds = []): array { $productIds = array_values(array_unique(array_map('intval', $productIds))); return [ 'op' => $op['op'], 'request' => $op, 'status' => $status, 'reason' => $reason, 'detail' => $detail, 'products_affected' => count($productIds), 'product_ids' => $productIds, ]; } /** @param array> $steps */ private function countStatus(array $steps, string $status): int { return count(array_filter($steps, static fn (array $step): bool => $step['status'] === $status)); } /** @param array> $steps */ private function totalProducts(array $steps): int { $ids = []; foreach ($steps as $step) { if ($step['status'] === self::STATUS_SKIPPED) { continue; } $ids = array_merge($ids, $step['product_ids'] ?? []); } return count(array_unique($ids)); } /** @param array $result */ private function record(array $result): void { global $wpdb; $summary = []; foreach ($result['steps'] as $step) { $summary[] = [ 'op' => $step['op'], 'status' => $step['status'], 'reason' => $step['reason'], 'request' => $step['request'], 'products_affected' => $step['products_affected'], ]; } $wpdb->insert(Database::table('attribute_ops'), [ 'user_id' => get_current_user_id(), 'operations' => count($result['steps']), 'products_affected' => $result['products_affected'], 'payload' => wp_json_encode($summary), 'created_at' => current_time('mysql', true), ]); } }