= self::MAX_TAGS ) { break; } } return $out; } /** * Clean a list of custom attributes. * * PURE. Accepts the canonical wire shape, an array of * `{ name, values, visible, variation }`, and tolerates a scalar `values`. * `variation` is forced false whatever the payload claims: a variation axis * is catalog structure, and writing over a variable product's axis breaks * every variation on it. * * @param mixed $raw JSON string or array. * @return array */ public static function normalize_attributes( $raw ) { $list = self::to_list( $raw ); if ( ! is_array( $list ) ) { return array(); } $out = array(); $seen = array(); foreach ( $list as $entry ) { if ( ! is_array( $entry ) ) { continue; } $name = isset( $entry['name'] ) && is_scalar( $entry['name'] ) ? self::clean_term( (string) $entry['name'] ) : ''; if ( '' === $name ) { continue; } // The global attribute-taxonomy namespace. Never ours to create. if ( 0 === strpos( strtolower( $name ), 'pa_' ) ) { continue; } $raw_values = isset( $entry['values'] ) ? $entry['values'] : null; if ( is_scalar( $raw_values ) ) { $raw_values = array( $raw_values ); } if ( ! is_array( $raw_values ) ) { continue; } $values = array(); $seen_value = array(); foreach ( $raw_values as $value ) { if ( ! is_scalar( $value ) ) { continue; } $clean = self::clean_term( (string) $value ); if ( '' === $clean ) { continue; } $key = strtolower( $clean ); if ( isset( $seen_value[ $key ] ) ) { continue; } $seen_value[ $key ] = true; $values[] = $clean; if ( count( $values ) >= self::MAX_VALUES_PER_ATTRIBUTE ) { break; } } if ( empty( $values ) ) { continue; } $key = strtolower( $name ); if ( isset( $seen[ $key ] ) ) { continue; } $seen[ $key ] = true; $out[] = array( 'name' => $name, 'values' => $values, 'visible' => ! isset( $entry['visible'] ) || false !== $entry['visible'], 'variation' => false, ); if ( count( $out ) >= self::MAX_ATTRIBUTES ) { break; } } return $out; } /** * Append product tags, recording exactly what was added. * * @param int $product_id Product post id. * @param string[] $tags Already normalized. * @return bool True when at least one term was newly attached. */ public static function write_tags( $product_id, array $tags ) { if ( empty( $tags ) ) { return false; } $product_id = (int) $product_id; $before = self::term_ids( $product_id ); // Which of these names had no term at all before we asked. Checked // BEFORE the write, because wp_set_object_terms creates one silently and // afterwards we could no longer tell ours from the merchant's. $new_terms = array(); foreach ( $tags as $name ) { if ( ! term_exists( $name, 'product_tag' ) ) { $new_terms[ strtolower( $name ) ] = true; } } // $append = true. The whole point: never remove what is already there. $result = wp_set_object_terms( $product_id, $tags, 'product_tag', true ); if ( is_wp_error( $result ) ) { return false; } $after = self::term_ids( $product_id ); $added = array_values( array_diff( $after, $before ) ); if ( empty( $added ) ) { return false; } $created = array(); foreach ( $added as $term_id ) { $term = get_term( $term_id, 'product_tag' ); if ( $term && ! is_wp_error( $term ) && isset( $new_terms[ strtolower( $term->name ) ] ) ) { $created[] = (int) $term_id; } } self::record( $product_id, 'tags', $added, $created ); return true; } /** * Merge custom attributes onto a product, recording exactly which keys were * added. Does NOT save; the caller saves once. * * NEVER OVERWRITES AN EXISTING KEY. A merchant's own attribute is theirs, * and one with variation=true is a variable product's axis, so replacing * either is a data loss the merchant did not ask for. * * @param \WC_Product $product Product. * @param array $attributes Already normalized. * @return bool True when at least one attribute was added. */ public static function write_attributes( $product, array $attributes ) { if ( empty( $attributes ) || ! is_object( $product ) ) { return false; } $existing = $product->get_attributes(); $existing = is_array( $existing ) ? $existing : array(); $added = array(); $position = count( $existing ); foreach ( $attributes as $attribute ) { $key = sanitize_title( $attribute['name'] ); if ( '' === $key || isset( $existing[ $key ] ) ) { continue; } $object = new \WC_Product_Attribute(); $object->set_id( 0 ); // 0 = custom (local), not a taxonomy. $object->set_name( $attribute['name'] ); $object->set_options( $attribute['values'] ); $object->set_position( $position ); $object->set_visible( (bool) $attribute['visible'] ); $object->set_variation( false ); $existing[ $key ] = $object; $added[] = $key; ++$position; } if ( empty( $added ) ) { return false; } $product->set_attributes( $existing ); self::record( (int) $product->get_id(), 'attributes', $added, array() ); return true; } /** * Remove exactly what this class wrote, and nothing else. * * Does NOT save the product; the caller saves once, so revert stays a single * write like the rest of {@see Apply::revert()}. * * @param int $product_id Product post id. * @param \WC_Product|null $product Loaded product, when the caller has one. * @return void */ public static function undo( $product_id, $product = null ) { $product_id = (int) $product_id; $ledger = get_post_meta( $product_id, Meta::NATIVE_WRITES, true ); if ( ! is_array( $ledger ) || empty( $ledger ) ) { return; } $tags = isset( $ledger['tags'] ) && is_array( $ledger['tags'] ) ? $ledger['tags'] : array(); $added = isset( $tags['added'] ) && is_array( $tags['added'] ) ? array_map( 'intval', $tags['added'] ) : array(); if ( ! empty( $added ) ) { wp_remove_object_terms( $product_id, $added, 'product_tag' ); // Delete a term we created ONLY if nothing points at it any more. A // term another product has picked up since is now the merchant's. $created = isset( $tags['created'] ) && is_array( $tags['created'] ) ? array_map( 'intval', $tags['created'] ) : array(); foreach ( $created as $term_id ) { $term = get_term( $term_id, 'product_tag' ); if ( $term && ! is_wp_error( $term ) && 0 === (int) $term->count ) { wp_delete_term( $term_id, 'product_tag' ); } } } $attributes = isset( $ledger['attributes'] ) && is_array( $ledger['attributes'] ) ? $ledger['attributes'] : array(); $keys = isset( $attributes['keys'] ) && is_array( $attributes['keys'] ) ? $attributes['keys'] : array(); if ( ! empty( $keys ) && is_object( $product ) ) { $current = $product->get_attributes(); $current = is_array( $current ) ? $current : array(); foreach ( $keys as $key ) { unset( $current[ (string) $key ] ); } $product->set_attributes( $current ); } delete_post_meta( $product_id, Meta::NATIVE_WRITES ); } /** * Is there anything recorded to undo? Used by {@see Apply::revert()}, which * would otherwise bail on a product that only ever received a queue payload * and therefore has a ledger but no backup. * * @param int $product_id Product post id. * @return bool */ public static function has_writes( $product_id ) { $ledger = get_post_meta( (int) $product_id, Meta::NATIVE_WRITES, true ); return is_array( $ledger ) && ! empty( $ledger ); } /** * Merge one write into the ledger. De-duplicated, because apply can run * twice for the same row and the second pass must not double the entry. * * @param int $product_id Product post id. * @param string $bucket 'tags' or 'attributes'. * @param array $added Term ids, or attribute keys. * @param array $created Term ids this write brought into existence. * @return void */ private static function record( $product_id, $bucket, array $added, array $created ) { $ledger = get_post_meta( $product_id, Meta::NATIVE_WRITES, true ); $ledger = is_array( $ledger ) ? $ledger : array(); if ( 'attributes' === $bucket ) { $keys = isset( $ledger['attributes']['keys'] ) && is_array( $ledger['attributes']['keys'] ) ? $ledger['attributes']['keys'] : array(); $ledger['attributes'] = array( 'keys' => array_values( array_unique( array_merge( $keys, $added ) ) ) ); } else { $prev_added = isset( $ledger['tags']['added'] ) && is_array( $ledger['tags']['added'] ) ? $ledger['tags']['added'] : array(); $prev_created = isset( $ledger['tags']['created'] ) && is_array( $ledger['tags']['created'] ) ? $ledger['tags']['created'] : array(); $ledger['tags'] = array( 'added' => array_values( array_unique( array_merge( $prev_added, $added ) ) ), 'created' => array_values( array_unique( array_merge( $prev_created, $created ) ) ), ); } update_post_meta( $product_id, Meta::NATIVE_WRITES, $ledger ); } /** * Current product_tag term ids for a product. * * @param int $product_id Product post id. * @return int[] */ private static function term_ids( $product_id ) { $terms = get_the_terms( $product_id, 'product_tag' ); if ( ! is_array( $terms ) ) { return array(); } $ids = array(); foreach ( $terms as $term ) { if ( isset( $term->term_id ) ) { $ids[] = (int) $term->term_id; } } return $ids; } /** * Decode a wire value into a list. Accepts a JSON array, an array, or a * comma-separated string (which the Rezolve Ai side does not send, but the Shopify * app's tag handling has always tolerated and a hand-built payload might use). * * @param mixed $raw Wire value. * @return array|null Null when it cannot be read as a list. */ private static function to_list( $raw ) { if ( is_array( $raw ) ) { return $raw; } if ( ! is_string( $raw ) ) { return null; } $trimmed = trim( $raw ); if ( '' === $trimmed ) { return null; } if ( '[' === $trimmed[0] || '{' === $trimmed[0] ) { $decoded = json_decode( $trimmed, true ); return is_array( $decoded ) ? $decoded : null; } return explode( ',', $trimmed ); } /** * Strip markup, sanitize, trim, and refuse anything over the term cap. * * @param string $value Raw value. * @return string Empty when nothing usable survives. */ private static function clean_term( $value ) { $clean = trim( sanitize_text_field( wp_strip_all_tags( $value ) ) ); if ( '' === $clean ) { return ''; } $length = function_exists( 'mb_strlen' ) ? mb_strlen( $clean ) : strlen( $clean ); return $length > self::MAX_TERM_LENGTH ? '' : $clean; } }