plugin = $plugin; } /** * Items awaiting review (status = enriched). * * @param int $page 1-based page. * @param int $per Page size. * @return array */ public function pending_items( $page = 1, $per = 20 ) { return $this->items_for_status( Meta::STATUS_ENRICHED, $page, $per ); } /** * Items already applied (for the Applied tab / revert). * * @param int $page 1-based page. * @param int $per Page size. * @return array */ public function applied_items( $page = 1, $per = 20 ) { return $this->items_for_status( Meta::STATUS_APPLIED, $page, $per ); } /** * Build a per-field before/after diff for a product. * * @param int $product_id Product id. * @return array List of { key, label, before, after, changed }. */ public function diff_for( $product_id ) { $product_id = (int) $product_id; $pending = get_post_meta( $product_id, Meta::PENDING, true ); if ( ! is_array( $pending ) ) { return array(); } $product = wc_get_product( $product_id ); if ( ! $product ) { return array(); } $before = array( 'title' => $product->get_name(), 'short_description' => $product->get_short_description(), 'description' => $product->get_description(), 'seo_title' => $this->plugin->seo()->read_seo_title( $product_id ), 'seo_description' => $this->plugin->seo()->read_seo_description( $product_id ), ); $diff = array(); foreach ( Meta::fields() as $key => $label ) { if ( ! isset( $pending[ $key ] ) || '' === $pending[ $key ] ) { continue; } $after = (string) $pending[ $key ]; $source = isset( $before[ $key ] ) ? (string) $before[ $key ] : ''; $diff[] = array( 'key' => $key, 'label' => $label, 'before' => $source, 'after' => $after, 'changed' => trim( wp_strip_all_tags( $source ) ) !== trim( wp_strip_all_tags( $after ) ), ); } return $diff; } /** * What approving would do with highlights, Q&A, attributes and alt text, * computed here rather than in the browser so the preview uses the same * reading and the same image matching as {@see Apply}. A merchant approving * alt text sees each image, its current alt and what replaces it. * * `$live` (4.18.0) reads a live product's highlights and Q&A from what is on * the store rather than from the stored proposal, so "Edit live" opens with * what shoppers see. Each image row carries the URL its alt text was written * for, and `attributePairs` lists specifications one value per row, which is * the shape the editor sends back. * * @param int $product_id Product id. * @param bool $live Read the live values where they exist. * @return array{highlights:string[],qa:array,attributes:array,attributePairs:array,imageAlt:array} */ public function extras_for( $product_id, $live = false ) { $product_id = (int) $product_id; $content = get_post_meta( $product_id, Meta::CONTENT, true ); $content = is_array( $content ) ? $content : array(); $product = wc_get_product( $product_id ); $extras = array( 'highlights' => isset( $content['highlights'] ) ? Pdp_Content::highlights( $content['highlights'] ) : array(), 'qa' => isset( $content['product-qa'] ) ? Pdp_Content::qa( $content['product-qa'] ) : array(), 'attributes' => array(), 'attributePairs' => isset( $content['product-attributes'] ) ? Edit_Values::attribute_pairs( $content['product-attributes'] ) : array(), 'imageAlt' => array(), 'customFields' => self::custom_field_rows( isset( $content['custom-fields'] ) ? $content['custom-fields'] : null ), ); if ( $live ) { $live_highlights = Pdp_Content::highlights( get_post_meta( $product_id, Meta::HIGHLIGHTS, true ) ); $live_qa = Pdp_Content::qa( get_post_meta( $product_id, Meta::QA, true ) ); if ( ! empty( $extras['highlights'] ) && ! empty( $live_highlights ) ) { $extras['highlights'] = $live_highlights; } if ( ! empty( $extras['qa'] ) && ! empty( $live_qa ) ) { $extras['qa'] = $live_qa; } } if ( $product && isset( $content['product-attributes'] ) ) { $existing = $product->get_attributes(); $existing = is_array( $existing ) ? $existing : array(); $owned = $live ? Native_Fields::owned_attribute_keys( $product_id ) : array(); foreach ( Edit_Values::attributes_for_write( $extras['attributePairs'] ) as $attribute ) { $key = sanitize_title( $attribute['name'] ); $action = isset( $existing[ $key ] ) ? 'exists' : 'add'; if ( $live && isset( $existing[ $key ] ) && in_array( $key, $owned, true ) ) { $action = 'owned'; } $extras['attributes'][] = array( 'name' => $attribute['name'], 'values' => $attribute['values'], 'action' => $action, ); } } if ( $product && isset( $content['image-alt'] ) ) { $entries = Pdp_Content::image_alts( $content['image-alt'] ); $match = Pdp_Content::match_image_alts( $entries, Native_Fields::image_candidates( $product ) ); foreach ( $entries as $index => $entry ) { if ( ! isset( $match['by_entry'][ $index ] ) ) { $extras['imageAlt'][] = array( 'id' => 0, 'imageUrl' => esc_url_raw( $entry['url'] ), 'thumb' => '', 'current' => '', 'proposed' => $entry['alt'], 'action' => 'unmatched', ); continue; } $attachment_id = (int) $match['by_entry'][ $index ]; $current = (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ); $extras['imageAlt'][] = array( 'id' => $attachment_id, 'imageUrl' => esc_url_raw( $entry['url'] ), 'thumb' => esc_url_raw( (string) wp_get_attachment_image_url( $attachment_id, 'thumbnail' ) ), 'current' => $current, 'proposed' => $entry['alt'], 'action' => $current === $entry['alt'] ? 'same' : 'write', ); } } return $extras; } /** * Rewritten custom fields for the Review screen (4.24.0): each field's * name, what it holds now and the rewrite, as readable lines * (Custom_Fields::display_text), so a multi-line or HTML field keeps its * line breaks and paragraphs. * * @param mixed $payload The `custom-fields` content: { fields: [...] } or the list. * @return array[] Each { label, current, proposed }. */ public static function custom_field_rows( $payload ) { $fields = ( is_array( $payload ) && isset( $payload['fields'] ) && is_array( $payload['fields'] ) ) ? $payload['fields'] : ( is_array( $payload ) ? $payload : array() ); $rows = array(); foreach ( $fields as $field ) { if ( ! is_array( $field ) || empty( $field['value'] ) ) { continue; } $rows[] = array( 'label' => isset( $field['label'] ) ? (string) $field['label'] : ( isset( $field['key'] ) ? (string) $field['key'] : '' ), 'current' => isset( $field['before'] ) ? Custom_Fields::display_text( (string) $field['before'] ) : '', 'proposed' => Custom_Fields::display_text( (string) $field['value'] ), ); } return $rows; } /** * How the Review screen may edit a product: `draft` for a proposal still * waiting, `live` for one already applied, '' for anything else. Dismissed, * failed and queued products have nothing a merchant can usefully edit. * * @param int $product_id Product id. * @return string */ public static function edit_mode( $product_id ) { $status = (string) get_post_meta( (int) $product_id, Meta::STATUS, true ); if ( Meta::STATUS_APPLIED === $status ) { return 'live'; } return Meta::STATUS_ENRICHED === $status ? 'draft' : ''; } /** * What a Review row shows of the product itself (4.20.0): its main image * and a link to its live page. * * The link appears only for a published product, as the Content screen's * View does. Password-protected and catalog-hidden products are published * and reachable by their URL, so they keep it: a shopper reaches the same * page. A missing image is '' rather than WooCommerce's placeholder: on the * screen where merchants check images, a stand-in photo would hide the gap * they came to find. * * @param \WC_Product|mixed $product Product. * @return array{thumb:string,liveUrl:string} */ public static function row_media( $product ) { $media = array( 'thumb' => '', 'liveUrl' => '', ); if ( ! is_object( $product ) ) { return $media; } $image_id = (int) $product->get_image_id(); if ( $image_id > 0 ) { $media['thumb'] = self::safe_url( wp_get_attachment_image_url( $image_id, 'thumbnail' ) ); } $media['liveUrl'] = self::live_url( (int) $product->get_id() ); return $media; } /** * The product's live page, or '' when a shopper cannot open one. * * One rule for the link on this screen and for the page this site reports * to Rezolve Ai when content goes live (4.20.1): a published product only, * password-protected and catalog-hidden ones included, as row_media() says. * * @param int $product_id Product id. * @return string */ public static function live_url( $product_id ) { $product_id = (int) $product_id; if ( $product_id <= 0 || 'publish' !== get_post_status( $product_id ) ) { return ''; } return self::safe_url( get_permalink( $product_id ) ); } /** * Every image on the product, the main image first and then the gallery, * each with the alt text it has now, for an expanded Review row (4.20.0). * * Order and membership come from Native_Fields::image_candidates(), the * list alt text suggestions are matched against, so the strip shows the * images an approval would write to. Alt text is read as stored, so an * empty one reads as empty. * * @param \WC_Product|mixed $product Product. * @return array{images:array,total:int} */ public static function gallery( $product ) { $candidates = Native_Fields::image_candidates( $product ); $main = is_object( $product ) ? (int) $product->get_image_id() : 0; $images = array(); foreach ( array_slice( array_keys( $candidates ), 0, self::GALLERY_MAX ) as $attachment_id ) { $full = self::safe_url( wp_get_attachment_image_url( $attachment_id, 'full' ) ); if ( '' === $full ) { $full = self::safe_url( $candidates[ $attachment_id ][0] ); } $thumb = self::safe_url( wp_get_attachment_image_url( $attachment_id, 'thumbnail' ) ); $images[] = array( 'id' => (int) $attachment_id, 'thumb' => '' !== $thumb ? $thumb : $full, 'full' => $full, 'alt' => (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ), 'featured' => (int) $attachment_id === $main, ); } return array( 'images' => $images, 'total' => count( $candidates ), ); } /** * A URL safe to hand the screen, or '' for anything unusable. * * @param mixed $url Candidate URL. * @return string */ private static function safe_url( $url ) { return is_string( $url ) && '' !== $url ? esc_url_raw( $url ) : ''; } /** * Save a merchant's edit to one field of a product (4.18.0). * * DRAFT (a proposal still waiting). The proposal is rewritten in place: * the flat `Meta::PENDING` key Apply reads and the `Meta::CONTENT` entry * the previews and the report read. Approving afterwards applies the edited * text, and nothing reaches the store before that. * * LIVE (already applied). Only this field is written to the store, through * {@see Apply::write_edit()}, and then the stored proposal is rewritten the * same way, so the Review screen shows what is live. `Meta::BACKUP` is not * touched, so revert still restores the product as it was before Rezolve Ai. * * BOTH. The edit is recorded in `Meta::EDITED` and the product is marked for * the review-decisions report, which carries the edited value to Rezolve * Ai. The mark is written LAST, after everything the report reads: a report * already in flight then sees its flag change and does not claim the edit * as sent (see Review_Sync::drain()). * * The mode the screen asked for must match the product's state. A screen * left open while the product was approved elsewhere would otherwise save a * "draft" that goes nowhere, or write to a store the merchant thought they * were only drafting for. * * @param int $product_id Product id. * @param string $field One of Edit_Values::FIELDS. * @param string $mode draft|live. * @param mixed $raw Decoded list for structured fields, a string otherwise. * @return array|\WP_Error `{ ok: false, errors }` for a value the merchant can * fix, `{ ok: true, ... }` with the refreshed preview, or * an error for a product that cannot be edited. */ public function save_edit( $product_id, $field, $mode, $raw ) { $product_id = (int) $product_id; $field = (string) $field; if ( ! Edit_Values::is_field( $field ) ) { return new \WP_Error( 'agencoen_edit_type', __( 'This content cannot be edited here.', 'agentic-commerce-engine' ) ); } if ( ! $product_id || ! wc_get_product( $product_id ) ) { return new \WP_Error( 'agencoen_no_product', __( 'Product not found.', 'agentic-commerce-engine' ) ); } $expected = self::edit_mode( $product_id ); if ( '' === $expected ) { return new \WP_Error( 'agencoen_edit_state', __( 'This suggestion is no longer waiting for review or live, so it cannot be edited.', 'agentic-commerce-engine' ) ); } if ( $mode !== $expected ) { return new \WP_Error( 'agencoen_edit_stale', 'live' === $expected ? __( 'This product was approved in the meantime. Reload the page to edit what is live.', 'agentic-commerce-engine' ) : __( 'This product is no longer live. Reload the page to see where it stands.', 'agentic-commerce-engine' ) ); } $pending = get_post_meta( $product_id, Meta::PENDING, true ); $pending = is_array( $pending ) ? $pending : array(); $content = get_post_meta( $product_id, Meta::CONTENT, true ); $content = is_array( $content ) ? $content : array(); if ( ! Edit_Values::has_proposal( $field, $pending, $content ) ) { return new \WP_Error( 'agencoen_edit_missing', __( 'There is no suggestion of this kind on this product to edit.', 'agentic-commerce-engine' ) ); } $normalized = Edit_Values::normalize( $field, $raw ); if ( ! $normalized['ok'] ) { return array( 'ok' => false, 'errors' => $normalized['errors'], ); } $value = $normalized['value']; $notes = array(); if ( 'live' === $mode ) { $notes = $this->plugin->apply()->write_edit( $product_id, $field, $value, $content ); if ( is_wp_error( $notes ) ) { return $notes; } } list( $pending, $content ) = Edit_Values::apply_to_proposal( $field, $value, $pending, $content ); if ( 'description' === $field && isset( $pending['short_description'] ) ) { // Derived, never edited on its own. See Poller::derive_flat_fields(). $pending['short_description'] = Poller::short_description_for( $value ); } if ( 'product_qa' === $field ) { foreach ( array( 'jsonld-schema', 'json-ld-schema' ) as $key ) { if ( ! isset( $content[ $key ] ) ) { continue; } $graph = is_string( $content[ $key ] ) ? json_decode( $content[ $key ], true ) : $content[ $key ]; list( $graph, $changed ) = Schema::with_faq_questions( $graph, $value ); if ( $changed ) { // Stored as an array, which Apply reads just the same. A JSON // string inside post meta is unslashed on save, and a quote in // an answer would leave it undecodable. $content[ $key ] = $graph; } } } update_post_meta( $product_id, Meta::PENDING, $pending ); update_post_meta( $product_id, Meta::CONTENT, $content ); $edited = get_post_meta( $product_id, Meta::EDITED, true ); $edited = is_array( $edited ) ? $edited : array(); $edited[ $field ] = gmdate( 'c' ); update_post_meta( $product_id, Meta::EDITED, $edited ); // A revert not yet reported stays a revert: reporting this product as a // fresh proposal would put content the merchant took down back in the // web queue. The edit rides the next decision instead. $flag = get_post_meta( $product_id, Meta::SYNC_DIRTY, true ); $reason = ( is_array( $flag ) && isset( $flag['reason'] ) && 'revert' === $flag['reason'] ) ? 'revert' : 'edit'; $this->plugin->review_sync()->mark_dirty( $product_id, $reason ); return array( 'ok' => true, 'id' => $product_id, 'field' => $field, 'mode' => $mode, 'kept' => isset( $notes['kept'] ) ? array_values( $notes['kept'] ) : array(), 'diff' => $this->diff_for( $product_id ), 'extras' => $this->extras_for( $product_id, 'live' === $mode ), 'content' => $content, // A live alt text edit rewrites _wp_attachment_image_alt, so the // image strip has to come back fresh or it shows the old alt. 'gallery' => self::gallery( wc_get_product( $product_id ) ), ); } /** * Approve (apply) a product's enrichment. * * @param int $product_id Product id. * @param array $fields Optional subset of field keys. * @return true|\WP_Error */ public function approve( $product_id, array $fields = array() ) { return $this->plugin->apply()->apply( $product_id, $fields ); } /** * Dismiss a suggestion without applying it. * * @param int $product_id Product id. * @return true */ public function dismiss( $product_id ) { $product_id = (int) $product_id; // Capture the fields BEFORE the pending meta goes. Dismissing deletes the // only record of what was proposed, so a reporter run an hour later would // find nothing to dismiss and the decision would never reach Rezolve Ai. $decisions = $this->plugin->review_sync()->build_decisions( $product_id, 'dismiss' ); $fields = array(); foreach ( $decisions as $decision ) { $fields[] = $decision['field']; } delete_post_meta( $product_id, Meta::PENDING ); update_post_meta( $product_id, Meta::STATUS, Meta::STATUS_DISMISSED ); $this->plugin->review_sync()->mark_dirty( $product_id, 'dismiss', $fields ); $this->plugin->repository()->bust_counts_cache(); return true; } /** * Revert an applied product to its backup. * * @param int $product_id Product id. * @return true|\WP_Error */ public function revert( $product_id ) { return $this->plugin->apply()->revert( $product_id ); } /** * Build item view-models for a status. * * @param string $status Status value. * @param int $page 1-based page. * @param int $per Page size. * @return array */ private function items_for_status( $status, $page, $per ) { $ids = $this->plugin->repository()->ids_with_status( $status, $page, $per ); $items = array(); foreach ( $ids as $id ) { $product = wc_get_product( $id ); if ( ! $product ) { continue; } $items[] = array( 'id' => $id, 'name' => $product->get_name(), 'edit_link' => get_edit_post_link( $id, 'raw' ), // Measured product-level quality, or null. See Quality::product_overall(). 'score' => Quality::cached_overall( $id ), 'diff' => $this->diff_for( $id ), ); } return $items; } }