= 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(); } // The `product-attributes` generator wraps its list as { attributes }. if ( isset( $list['attributes'] ) && is_array( $list['attributes'] ) ) { $list = $list['attributes']; } $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; } // `values` is the wire shape; the generator emits one `value` per // attribute, so accept that too rather than dropping every one. $raw_values = isset( $entry['values'] ) ? $entry['values'] : ( isset( $entry['value'] ) ? $entry['value'] : 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; } /** * Write attributes, REPLACING the ones this plugin added itself (4.18.0). * * WHY A SECOND WRITER. {@see write_attributes()} never overwrites a key, * which is right for a first approval and wrong for an edit: a merchant who * corrects "Material: Steal" to "Steel" on something that is already live * would see nothing change. So an edit, and an approval from Rezolve Ai that * carries one, may replace a key, but ONLY a key the ledger says Rezolve Ai * created. Everything else keeps the add-only rule: * * - a key not on the product is added and recorded, as before; * - a key the ledger owns is replaced, keeping its position; * - a key the merchant owns (not in the ledger) is left exactly as it is; * - a ledger key the merchant has since made a variation axis is theirs * now too, because replacing its options would break every variation. * * `$remove_keys` takes away owned keys the caller knows were deleted from * the edit. The web apply path passes none: one queue field serves two * content types, so a payload's silence about a key is not a deletion. * * `Meta::BACKUP` is not involved, and a revert still removes every key in * the ledger, so the product goes back to what the merchant had before. * Does NOT save; the caller saves once. * * @param \WC_Product $product Product. * @param array $attributes Already normalized. * @param string[] $remove_keys Owned keys to remove when absent from $attributes. * @return array{added:string[],replaced:string[],unchanged:string[],kept:string[],removed:string[]} */ public static function write_owned_attributes( $product, array $attributes, array $remove_keys = array() ) { $result = array( 'added' => array(), 'replaced' => array(), 'unchanged' => array(), 'kept' => array(), 'removed' => array(), ); if ( ! is_object( $product ) ) { return $result; } $product_id = (int) $product->get_id(); $owned = self::owned_attribute_keys( $product_id ); $existing = $product->get_attributes(); $existing = is_array( $existing ) ? $existing : array(); $position = count( $existing ); $wanted = array(); foreach ( $attributes as $attribute ) { $key = sanitize_title( $attribute['name'] ); if ( '' === $key || isset( $wanted[ $key ] ) ) { continue; } $wanted[ $key ] = true; if ( ! isset( $existing[ $key ] ) ) { $existing[ $key ] = self::attribute_object( $attribute, $position ); $result['added'][] = $key; ++$position; continue; } $current = $existing[ $key ]; if ( ! in_array( $key, $owned, true ) || self::is_variation_axis( $current ) ) { $result['kept'][] = $key; continue; } $same_name = is_object( $current ) && method_exists( $current, 'get_name' ) && $current->get_name() === $attribute['name']; $same_options = is_object( $current ) && method_exists( $current, 'get_options' ) && array_values( (array) $current->get_options() ) === array_values( $attribute['values'] ); if ( $same_name && $same_options ) { $result['unchanged'][] = $key; continue; } $at = is_object( $current ) && method_exists( $current, 'get_position' ) ? (int) $current->get_position() : $position; $existing[ $key ] = self::attribute_object( $attribute, $at ); $result['replaced'][] = $key; } foreach ( $remove_keys as $key ) { $key = (string) $key; if ( isset( $wanted[ $key ] ) || ! isset( $existing[ $key ] ) || ! in_array( $key, $owned, true ) || self::is_variation_axis( $existing[ $key ] ) ) { continue; } unset( $existing[ $key ] ); $result['removed'][] = $key; } if ( empty( $result['added'] ) && empty( $result['replaced'] ) && empty( $result['removed'] ) ) { return $result; } $product->set_attributes( $existing ); if ( ! empty( $result['added'] ) ) { self::record( $product_id, 'attributes', $result['added'], array() ); } if ( ! empty( $result['removed'] ) ) { self::forget_attribute_keys( $product_id, $result['removed'] ); } return $result; } /** * Attribute keys the ledger says this plugin added to a product. * * @param int $product_id Product post id. * @return string[] */ public static function owned_attribute_keys( $product_id ) { $ledger = get_post_meta( (int) $product_id, Meta::NATIVE_WRITES, true ); $keys = is_array( $ledger ) && isset( $ledger['attributes']['keys'] ) && is_array( $ledger['attributes']['keys'] ) ? $ledger['attributes']['keys'] : array(); return array_values( array_map( 'strval', $keys ) ); } /** * A custom (non-taxonomy) attribute object for a normalized attribute. * * @param array $attribute Normalized attribute. * @param int $position Position. * @return \WC_Product_Attribute */ private static function attribute_object( array $attribute, $position ) { $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( (int) $position ); $object->set_visible( (bool) $attribute['visible'] ); $object->set_variation( false ); return $object; } /** * @param mixed $attribute Attribute object. * @return bool */ private static function is_variation_axis( $attribute ) { return is_object( $attribute ) && method_exists( $attribute, 'get_variation' ) && (bool) $attribute->get_variation(); } /** * Drop attribute keys from the ledger once they are no longer on the product. * * @param int $product_id Product post id. * @param string[] $keys Keys. * @return void */ private static function forget_attribute_keys( $product_id, array $keys ) { $ledger = get_post_meta( $product_id, Meta::NATIVE_WRITES, true ); if ( ! is_array( $ledger ) || ! isset( $ledger['attributes']['keys'] ) || ! is_array( $ledger['attributes']['keys'] ) ) { return; } $ledger['attributes']['keys'] = array_values( array_diff( array_map( 'strval', $ledger['attributes']['keys'] ), array_map( 'strval', $keys ) ) ); if ( empty( $ledger['attributes']['keys'] ) ) { unset( $ledger['attributes'] ); } if ( empty( $ledger ) ) { delete_post_meta( $product_id, Meta::NATIVE_WRITES ); return; } update_post_meta( $product_id, Meta::NATIVE_WRITES, $ledger ); } /** * 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' ); } } } // Alt text goes back only where it is still exactly what we wrote. An // image whose alt the merchant has since edited is theirs now, and an // attachment that no longer exists has nothing to restore. $alts = isset( $ledger['image_alt'] ) && is_array( $ledger['image_alt'] ) ? $ledger['image_alt'] : array(); foreach ( $alts as $attachment_id => $entry ) { $attachment_id = (int) $attachment_id; if ( $attachment_id <= 0 || ! is_array( $entry ) || ! isset( $entry['written'] ) ) { continue; } $current = (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ); if ( $current !== (string) $entry['written'] ) { continue; } $previous = isset( $entry['previous'] ) ? (string) $entry['previous'] : ''; if ( '' === $previous ) { delete_post_meta( $attachment_id, '_wp_attachment_image_alt' ); } else { update_post_meta( $attachment_id, '_wp_attachment_image_alt', $previous ); } } $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 ); } /** * The product's own images, with every URL each one is known by. * * Featured image first, then the gallery, the same order the serializer * sends to Rezolve Ai. Several URLs per attachment because the one the alt * text was generated from may be the full size, the original before * WordPress scaled it, or the attachment URL itself. * * @param \WC_Product $product Product. * @return array attachment_id => URLs. */ public static function image_candidates( $product ) { if ( ! is_object( $product ) ) { return array(); } $ids = array(); if ( $product->get_image_id() ) { $ids[] = (int) $product->get_image_id(); } foreach ( (array) $product->get_gallery_image_ids() as $gallery_id ) { $ids[] = (int) $gallery_id; } $out = array(); foreach ( array_unique( array_filter( $ids ) ) as $id ) { $urls = array( wp_get_attachment_image_url( $id, 'full' ), wp_get_attachment_url( $id ), ); if ( function_exists( 'wp_get_original_image_url' ) ) { $urls[] = wp_get_original_image_url( $id ); } $urls = array_values( array_unique( array_filter( $urls, 'is_string' ) ) ); if ( ! empty( $urls ) ) { $out[ $id ] = $urls; } } return $out; } /** * Write approved alt text onto attachments, recording what each held first. * * OVERWRITES, unlike everything else in this class: the merchant saw each * image's current and proposed alt text in Review and approved the change. * An image already carrying exactly the proposed text is left alone. * * @param int $product_id Product post id (owns the ledger). * @param array $alt_by_id attachment_id => alt text. * @return int How many images were written. */ public static function write_image_alts( $product_id, array $alt_by_id ) { $product_id = (int) $product_id; $written = array(); foreach ( $alt_by_id as $attachment_id => $alt ) { $attachment_id = (int) $attachment_id; $alt = trim( sanitize_text_field( (string) $alt ) ); if ( $attachment_id <= 0 || '' === $alt ) { continue; } $current = (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ); if ( $current === $alt ) { continue; } update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt ); $written[ $attachment_id ] = array( 'previous' => $current, 'written' => $alt, ); } if ( ! empty( $written ) ) { self::record_image_alts( $product_id, $written ); } return count( $written ); } /** * Merge alt writes into the ledger, keeping each image's EARLIEST previous * value so a second apply cannot make our own first write look like the * merchant's original. * * @param int $product_id Product post id. * @param array $written attachment_id => { previous, written }. * @return void */ private static function record_image_alts( $product_id, array $written ) { $ledger = get_post_meta( $product_id, Meta::NATIVE_WRITES, true ); $ledger = is_array( $ledger ) ? $ledger : array(); $alts = isset( $ledger['image_alt'] ) && is_array( $ledger['image_alt'] ) ? $ledger['image_alt'] : array(); foreach ( $written as $attachment_id => $entry ) { $previous = isset( $alts[ $attachment_id ]['previous'] ) ? $alts[ $attachment_id ]['previous'] : $entry['previous']; $alts[ $attachment_id ] = array( 'previous' => (string) $previous, 'written' => (string) $entry['written'], ); } $ledger['image_alt'] = $alts; update_post_meta( $product_id, Meta::NATIVE_WRITES, $ledger ); } /** * 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; } }