$fields As returned by SyncApi::fields(). * @param bool $image_last_try An image failure no longer throws: an image never strands the copy. * @return array{before: array, after: array} * @throws SyncWriteException With the contract's reason. */ public static function write( int $product_id, array $fields, bool $image_last_try = false ): array { $product = self::product( $product_id ); $alts = isset( $fields['image_alts'] ) && is_array( $fields['image_alts'] ) ? $fields['image_alts'] : array(); if ( array() !== $alts ) { $own = ProductImages::of_product( $product_id ); foreach ( $alts as $item ) { $image_id = (int) $item['image_id']; if ( 'attachment' !== get_post_type( $image_id ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message for logs, not rendered output. throw new SyncWriteException( 'image_not_found', 'image ' . $image_id ); } if ( ! ProductImages::contains( $own, $image_id ) ) { // Approved for this product, so it may only touch this product's images: nothing is written. Log::error( sprintf( 'Approved-product sync: product %d names image %d, which is not one of its images; nothing written.', $product_id, $image_id ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message for logs, not rendered output. throw new SyncWriteException( 'image_not_found', 'image ' . $image_id . ' is not an image of product ' . $product_id ); } } } $seo = SeoAdapters::detect(); $before = self::snapshot( $product, $fields, $seo, false ); self::write_text( $product, $fields ); if ( isset( $fields['tags'] ) && is_array( $fields['tags'] ) ) { self::write_tags( $product_id, $fields['tags'] ); } if ( isset( $fields['seo'] ) && is_array( $fields['seo'] ) ) { self::write_seo( $product_id, $fields['seo'], $seo ); } foreach ( $alts as $item ) { // update_post_meta() unslashes; slash first so backslashes survive. False means "unchanged", not an error. update_post_meta( (int) $item['image_id'], self::ALT_META, wp_slash( self::plain( (string) $item['alt'] ) ) ); } $image = null; if ( isset( $fields['new_image'] ) && is_array( $fields['new_image'] ) ) { SyncProduct::mark_image_step( $product_id ); $image = self::write_image( $product_id, $fields['new_image'], $image_last_try ); } $after = self::snapshot( self::product( $product_id ), $fields, $seo, true ); if ( null !== $image ) { $after['new_image'] = $image; } return array( 'before' => $before, 'after' => $after, ); } /** * @param array{url: string, alt: string, image_id: string} $new_image The approved image. * @return array{image_id: string, attachment_id: int, url: string}|null * @throws SyncWriteException image_write_failed, unless this is the last try. */ private static function write_image( int $product_id, array $new_image, bool $last_try ): ?array { try { return SyncImageWriter::write( $product_id, $new_image ); } catch ( SyncWriteException $e ) { if ( ! $last_try ) { throw $e; } Log::warning( sprintf( 'Approved-product sync: gave up on the new image of product %d (%s); its copy goes live without it.', $product_id, $e->reason() ) ); return null; } } /** * A live product (not a variation, not in the trash), freshly read. * * @throws SyncWriteException When there is none. */ private static function product( int $product_id ): \WC_Product { $post = get_post( $product_id ); $product = $post instanceof \WP_Post && 'product' === $post->post_type && 'trash' !== $post->post_status ? wc_get_product( $product_id ) : null; if ( ! $product instanceof \WC_Product ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message for logs, not rendered output. throw new SyncWriteException( 'product_not_found', 'product ' . $product_id ); } return $product; } /** * @param array $fields Fields. * @throws SyncWriteException save_failed. */ private static function write_text( \WC_Product $product, array $fields ): void { if ( array() === array_intersect_key( $fields, array_flip( SyncApi::TEXT_FIELDS ) ) ) { return; } // Post-content HTML filtering as WooCommerce's REST controller does (wp_filter_post_kses), without its // stripslashes of the value. The product keeps the exact, UNslashed values, so hooks and the variation-title // sync see the real name. wp_update_post() expects slashed data, so the post fields are slashed on their way // in by a filter scoped to this one product and removed again whatever happens. $values = array(); foreach ( array( 'name' => 'post_title', 'description' => 'post_content', 'short_description' => 'post_excerpt', ) as $key => $column ) { if ( isset( $fields[ $key ] ) ) { $values[ $column ] = wp_kses_post( (string) $fields[ $key ] ); } } // What each column will hold: core encodes emoji on a table that is not utf8mb4 before wp_insert_post_data, // and this filter runs after that pass, so it encodes them the same way (or the update would fail). $stored = array(); foreach ( $values as $column => $value ) { $stored[ $column ] = self::storable( $column, $value ); } $product_id = $product->get_id(); $slasher = static function ( $data, $postarr ) use ( $stored, $product_id ) { if ( is_array( $data ) && is_array( $postarr ) && (int) ( $postarr['ID'] ?? 0 ) === $product_id ) { foreach ( $stored as $column => $value ) { $data[ $column ] = wp_slash( $value ); } } return $data; }; add_filter( 'wp_insert_post_data', $slasher, PHP_INT_MAX, 2 ); try { if ( isset( $values['post_title'] ) ) { $product->set_name( $values['post_title'] ); } if ( isset( $values['post_content'] ) ) { $product->set_description( $values['post_content'] ); } if ( isset( $values['post_excerpt'] ) ) { $product->set_short_description( $values['post_excerpt'] ); } $product->save(); } catch ( \Throwable $e ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message for logs, not rendered output. throw new SyncWriteException( 'save_failed', $e->getMessage() ); } finally { remove_filter( 'wp_insert_post_data', $slasher, PHP_INT_MAX ); } self::verify_stored( $product_id, $stored ); } /** * $value as core would store it in $column: emoji encoded as HTML entities when the column is not utf8mb4, * mirroring wp_insert_post(). */ private static function storable( string $column, string $value ): string { global $wpdb; return 'utf8' === $wpdb->get_col_charset( $wpdb->posts, $column ) ? wp_encode_emoji( $value ) : $value; } /** * WC_Product::save() ignores a failed wp_update_post() (for example a value the table's charset refuses), so the * post is read back from the database and compared with what was written. * * @param array $stored Column => the value it should now hold. * @throws SyncWriteException save_failed, naming the columns that did not take. */ private static function verify_stored( int $product_id, array $stored ): void { clean_post_cache( $product_id ); $post = get_post( $product_id ); $dropped = array(); foreach ( $stored as $column => $value ) { if ( ! $post instanceof \WP_Post || $value !== $post->$column ) { $dropped[] = $column; } } if ( array() !== $dropped ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message for logs, not rendered output. throw new SyncWriteException( 'save_failed', 'not stored: ' . implode( ', ', $dropped ) ); } } /** * Tag names lose backslashes (wp_insert_term() unslashes them), as with any tag created through WordPress. * * @param array $tags Approved tag names. * @throws SyncWriteException tags_write_failed. */ private static function write_tags( int $product_id, array $tags ): void { $names = array(); foreach ( $tags as $tag ) { $name = self::plain( $tag ); if ( '' !== $name ) { $names[] = $name; } } if ( array() === $names ) { return; } try { $result = wp_set_object_terms( $product_id, $names, 'product_tag', true ); } catch ( \Throwable $e ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message for logs, not rendered output. throw new SyncWriteException( 'tags_write_failed', $e->getMessage() ); } if ( is_wp_error( $result ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message for logs, not rendered output. throw new SyncWriteException( 'tags_write_failed', $result->get_error_message() ); } wc_delete_product_transients( $product_id ); } /** * @param array $values title and/or description. * @throws SyncWriteException seo_write_failed. */ private static function write_seo( int $product_id, array $values, SeoAdapter $seo ): void { try { $seo->set( $product_id, isset( $values['title'] ) ? self::plain( $values['title'] ) : null, isset( $values['description'] ) ? self::plain( $values['description'] ) : null ); } catch ( \Throwable $e ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message for logs, not rendered output. throw new SyncWriteException( 'seo_write_failed', $e->getMessage() ); } } /** * Plain text, as PUT cortex/v1/products/{id}/seo and PUT cortex/v1/media/{id}/alt store it. */ public static function plain( string $value ): string { return trim( sanitize_text_field( $value ) ); } /** * The named fields as they are now in WordPress. * * @param array $fields Fields being written. * @param bool $as_push Report name and descriptions as the catalog push sends them (wc/v3 view context), so * the backend's content hash of `after` matches the next push. * @return array */ private static function snapshot( \WC_Product $product, array $fields, SeoAdapter $seo, bool $as_push ): array { $text = array( 'name' => $product->get_name( 'edit' ), 'description' => $product->get_description( 'edit' ), 'short_description' => $product->get_short_description( 'edit' ), ); // The shape of `fields`, plus `name` always: the backend labels every report with the product's name. $snapshot = array_intersect_key( $text, $fields + array( 'name' => true ) ); if ( $as_push && array() !== array_intersect_key( $fields, array_flip( array( 'name', 'description', 'short_description' ) ) ) ) { $snapshot = array_merge( $snapshot, self::as_pushed( $product->get_id(), array_keys( $snapshot ) ) ); } if ( isset( $fields['tags'] ) ) { $names = wp_get_object_terms( $product->get_id(), 'product_tag', array( 'fields' => 'names' ) ); $snapshot['tags'] = is_array( $names ) ? $names : array(); } if ( isset( $fields['seo'] ) && is_array( $fields['seo'] ) ) { $snapshot['seo'] = array_intersect_key( $seo->get( $product->get_id() ), $fields['seo'] ); } if ( isset( $fields['image_alts'] ) && is_array( $fields['image_alts'] ) ) { $snapshot['image_alts'] = array(); foreach ( $fields['image_alts'] as $item ) { $snapshot['image_alts'][] = array( 'image_id' => (int) $item['image_id'], 'alt' => (string) get_post_meta( (int) $item['image_id'], self::ALT_META, true ), ); } } return $snapshot; } /** * The given text fields from the same in-process wc/v3 read the catalog push uses. During a sync this runs with * read_private_products on this product only (SyncPermissions). A failed read keeps the edit-context value: a write * never fails because of this. * * @param array $keys name, description and/or short_description. * @return array */ private static function as_pushed( int $product_id, array $keys ): array { try { $pushed = ProductPayload::product( $product_id ); } catch ( \Throwable $e ) { Log::warning( sprintf( 'Approved-product sync: product %d could not be read through wc/v3 to report what was written (%s); the edit-context values are reported instead.', $product_id, get_class( $e ) ) ); return array(); } $out = array(); foreach ( $keys as $key ) { if ( isset( $pushed[ $key ] ) && is_string( $pushed[ $key ] ) ) { $out[ $key ] = $pushed[ $key ]; } } return $out; } }