array( 'multiline', 'html' ), 'product-qa' => array( 'multiline', 'html' ), 'irl-cards' => array( 'multiline', 'html' ), 'internal-links' => array( 'html' ), 'product-attributes' => array( 'multiline', 'html' ), ); /** Caps, generous for a real product page. Mirrors MAX_ITEMS. */ const MAX_ITEMS = array( 'highlights' => 20, 'product-qa' => 20, 'irl-cards' => 10, 'internal-links' => 20, 'product-attributes' => 50, ); /** * Between block elements. A block theme's excerpt block shows the short * description with its tags stripped, and without whitespace every list * item ran into the next. Mirrors BLOCK_JOIN. */ const BLOCK_JOIN = "\n"; /** Longest section heading. Mirrors MAX_SECTION_HEADING. */ const MAX_HEADING = 120; /** * A section: one
carrying these attributes, with no
inside, so * it is found and replaced without an HTML parser. Mirrors SECTION_PATTERN. */ const SECTION_PATTERN = '/(\r?\n)?]*\bdata-rezolve-ai-section\s*=\s*(["\'])([a-z-]+)\2[^>]*>[\s\S]*?<\/div>(\r?\n)?/i'; /** Whitespace as JavaScript's \s sees it. */ const SPACE = '/[\s\x{00A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}]+/u'; /* --------------------------------------------------------------------- */ /* Settings */ /* --------------------------------------------------------------------- */ /** * The routed types, validated. A type missing here goes to the block. * * @return array */ public static function all() { return self::normalize( get_option( self::OPTION, array() ) ); } /** * Where one type goes, or null for the block. * * @param string $type Content type. * @return array|null */ public static function for_type( $type ) { $all = self::all(); return isset( $all[ $type ] ) ? $all[ $type ] : null; } /** * Store what the platform sent. Always the complete answer: a type the * platform left out is back on the block. * * @param mixed $raw Map of content type to destination. * @return bool Whether anything changed. */ public static function save( $raw ) { $clean = self::normalize( $raw ); $current = get_option( self::OPTION, array() ); if ( $clean === $current ) { return false; } update_option( self::OPTION, $clean ); return true; } /** * Only usable, routed entries. Two types never share a field. * * @param mixed $raw Stored or received map. * @return array */ public static function normalize( $raw ) { $out = array(); $used = array(); if ( ! is_array( $raw ) ) { return $out; } foreach ( self::TYPES as $type ) { if ( ! isset( $raw[ $type ] ) ) { continue; } $destination = self::read_destination( $type, $raw[ $type ] ); if ( null === $destination || 'block' === $destination['kind'] ) { continue; } if ( 'field' === $destination['kind'] ) { $id = $destination['field']['id']; if ( isset( $used[ $id ] ) ) { continue; } $used[ $id ] = true; } $out[ $type ] = $destination; } return $out; } /** * One destination, validated for its type. Mirrors readDestination for the * sources this platform has. * * @param string $type Content type. * @param mixed $raw Destination. * @return array|null */ public static function read_destination( $type, $raw ) { if ( ! is_array( $raw ) || ! isset( $raw['kind'] ) ) { return null; } if ( 'block' === $raw['kind'] ) { return array( 'kind' => 'block' ); } if ( 'description' === $raw['kind'] ) { return array( 'kind' => 'description', 'position' => ( isset( $raw['position'] ) && 'start' === $raw['position'] ) ? 'start' : 'end', 'heading' => self::clean_heading( isset( $raw['heading'] ) ? $raw['heading'] : '' ), ); } if ( 'field' !== $raw['kind'] || ! isset( $raw['field'] ) || ! is_array( $raw['field'] ) ) { return null; } $f = $raw['field']; $str = function ( $key ) use ( $f ) { return isset( $f[ $key ] ) && is_string( $f[ $key ] ) ? trim( $f[ $key ] ) : ''; }; $source = $str( 'source' ); $namespace = $str( 'namespace' ); $key = $str( 'key' ); $field_t = $str( 'type' ); if ( ! self::field_allowed( $source, $namespace, $key ) || ! self::accepts( $type, self::format_for( $source, $field_t ) ) ) { return null; } $field = array( 'id' => $source . ':' . $namespace . '.' . $key, 'source' => $source, 'namespace' => $namespace, 'key' => $key, 'type' => $field_t, 'label' => '' !== $str( 'label' ) ? substr( $str( 'label' ), 0, 256 ) : $key, ); $acf_key = $str( 'acfKey' ); if ( 'acf' === $source && preg_match( '/^field_[A-Za-z0-9]{1,64}$/', $acf_key ) ) { $field['acfKey'] = $acf_key; } return array( 'kind' => 'field', 'field' => $field, ); } /** * A heading as plain text on one line. * * @param mixed $value Heading. * @return string */ private static function clean_heading( $value ) { if ( ! is_string( $value ) ) { return ''; } $value = (string) preg_replace( '/[\x00-\x1f<>]/', ' ', $value ); $value = self::collapse( $value ); return function_exists( 'mb_substr' ) ? mb_substr( $value, 0, self::MAX_HEADING ) : substr( $value, 0, self::MAX_HEADING ); } /** * A choice posted by the settings screen, as a destination. The screen * sends `block`, `description:end`, `description:start` or `field:`; * a field is looked up among the candidates, never taken from the post. * * @param string $type Content type. * @param string $choice Posted choice. * @param string $heading Posted section heading. * @param array $candidates From candidates(). * @return array|null */ public static function from_choice( $type, $choice, $heading, array $candidates ) { if ( 'block' === $choice ) { return array( 'kind' => 'block' ); } if ( 0 === strpos( $choice, 'description:' ) ) { return self::read_destination( $type, array( 'kind' => 'description', 'position' => substr( $choice, strlen( 'description:' ) ), 'heading' => $heading, ) ); } if ( 0 === strpos( $choice, 'field:' ) ) { $id = substr( $choice, strlen( 'field:' ) ); foreach ( $candidates as $field ) { if ( $field['id'] === $id ) { return self::read_destination( $type, array( 'kind' => 'field', 'field' => $field, ) ); } } } return null; } /** * Every field on this store a destination could be, deduplicated by id: * ACF text areas and editors defined for products (even empty ones, with * the ACF key they need), the WooCommerce short description, the fields * the platform has seen on synced products, and whatever is chosen now. * * @param Plugin $plugin Orchestrator, for the platform call. * @return array[] Fields: id, source, namespace, key, type, label, acfKey?. */ public static function candidates( $plugin ) { $fields = array( array( 'source' => 'wc_core', 'namespace' => 'woocommerce', 'key' => 'short_description', 'type' => 'wc:html', 'label' => __( 'Short description', 'agentic-commerce-engine' ), ), ); if ( function_exists( 'acf_get_field_groups' ) && function_exists( 'acf_get_fields' ) ) { foreach ( (array) acf_get_field_groups( array( 'post_type' => 'product' ) ) as $group ) { foreach ( (array) acf_get_fields( $group ) as $f ) { if ( ! is_array( $f ) || empty( $f['name'] ) || ! in_array( isset( $f['type'] ) ? $f['type'] : '', array( 'textarea', 'wysiwyg' ), true ) ) { continue; } $fields[] = array( 'source' => 'acf', 'namespace' => 'acf', 'key' => (string) $f['name'], 'type' => 'acf:' . $f['type'], 'label' => isset( $f['label'] ) && '' !== $f['label'] ? (string) $f['label'] : (string) $f['name'], 'acfKey' => isset( $f['key'] ) ? (string) $f['key'] : '', ); } } } $settings = $plugin->settings(); if ( $settings->is_connected() ) { $remote = get_transient( 'agencoen_destination_candidates' ); if ( ! is_array( $remote ) ) { $remote = array(); $response = $plugin->api()->content_placement( $settings->get_store_id() ); if ( ! is_wp_error( $response ) && isset( $response['candidates'] ) && is_array( $response['candidates'] ) ) { foreach ( $response['candidates'] as $list ) { foreach ( (array) $list as $field ) { if ( is_array( $field ) ) { $remote[] = $field; } } } } // Short: a field added to products shows up within minutes. set_transient( 'agencoen_destination_candidates', $remote, 10 * MINUTE_IN_SECONDS ); } $fields = array_merge( $fields, $remote ); } foreach ( self::all() as $destination ) { if ( 'field' === $destination['kind'] ) { $fields[] = $destination['field']; } } $out = array(); foreach ( $fields as $f ) { $source = isset( $f['source'] ) ? (string) $f['source'] : ''; $namespace = isset( $f['namespace'] ) ? (string) $f['namespace'] : ''; $key = isset( $f['key'] ) ? (string) $f['key'] : ''; $id = $source . ':' . $namespace . '.' . $key; if ( isset( $out[ $id ] ) || ! self::field_allowed( $source, $namespace, $key ) || null === self::format_for( $source, isset( $f['type'] ) ? (string) $f['type'] : '' ) ) { continue; } $field = array( 'id' => $id, 'source' => $source, 'namespace' => $namespace, 'key' => $key, 'type' => (string) $f['type'], 'label' => isset( $f['label'] ) && '' !== (string) $f['label'] ? (string) $f['label'] : $key, ); if ( ! empty( $f['acfKey'] ) ) { $field['acfKey'] = (string) $f['acfKey']; } $out[ $id ] = $field; } uasort( $out, function ( $a, $b ) { return strcasecmp( $a['label'], $b['label'] ); } ); return array_values( $out ); } /** * The candidates that can hold $type. * * @param string $type Content type. * @param array $candidates From candidates(). * @return array[] */ public static function candidates_for( $type, array $candidates ) { return array_values( array_filter( $candidates, function ( $f ) use ( $type ) { return self::accepts( $type, self::format_for( $f['source'], $f['type'] ) ); } ) ); } /* --------------------------------------------------------------------- */ /* Which fields can take which content */ /* --------------------------------------------------------------------- */ /** * The format a field is written in, or null. Mirrors destinationFormatFor. * * @param string $source Source. * @param string $type Field type. * @return string|null */ public static function format_for( $source, $type ) { if ( 'acf' === $source ) { if ( 'acf:textarea' === $type ) { return 'multiline'; } return 'acf:wysiwyg' === $type ? 'html' : null; } if ( 'wp_meta' === $source ) { if ( 'wp:html' === $type ) { return 'html'; } return 'wp:string' === $type ? 'multiline' : null; } if ( 'wc_core' === $source ) { return 'wc:html' === $type ? 'html' : null; } return null; } /** * @param string $type Content type. * @param string|null $format Format. * @return bool */ public static function accepts( $type, $format ) { return null !== $format && isset( self::ACCEPTS[ $type ] ) && in_array( $format, self::ACCEPTS[ $type ], true ); } /** * Whether a field's address may be written at all. Mirrors * isDestinationFieldAllowed; Custom_Fields applies its own rules again. * * @param string $source Source. * @param string $namespace Namespace. * @param string $key Key. * @return bool */ public static function field_allowed( $source, $namespace, $key ) { if ( Custom_Fields::is_private_key( $key ) ) { return false; } $meta_key = (bool) preg_match( '/^[A-Za-z0-9_-]{1,190}$/', $key ) && 0 !== strpos( $key, '_' ); if ( 'acf' === $source ) { return 'acf' === $namespace && $meta_key; } if ( 'wp_meta' === $source ) { return 'meta' === $namespace && $meta_key && ! preg_match( '/^agencoen/i', $key ) && ! Custom_Fields::skipped_meta_key( $key ); } if ( 'wc_core' === $source ) { return 'woocommerce' === $namespace && 'short_description' === $key; } return false; } /* --------------------------------------------------------------------- */ /* Reading generated content */ /* --------------------------------------------------------------------- */ /** * Whitespace runs to one space, trimmed. * * @param string $text Text. * @return string */ private static function collapse( $text ) { return trim( (string) preg_replace( self::SPACE, ' ', $text ), ' ' ); } /** * One line of text. Numbers are text too. Mirrors `line`. * * @param mixed $value Value. * @return string */ private static function line( $value ) { if ( is_int( $value ) || ( is_float( $value ) && is_finite( $value ) ) ) { return (string) $value; } return is_string( $value ) ? self::collapse( $value ) : ''; } /** * Whether an array is a JSON list. `array_is_list` is PHP 8.1; this plugin * supports 8.0. * * @param array $value Array. * @return bool */ private static function is_list( array $value ) { $i = 0; foreach ( array_keys( $value ) as $key ) { if ( $key !== $i++ ) { return false; } } return true; } /** * The first list under any of $keys, or the value when it is one. * * @param mixed $value Value. * @param string[] $keys Keys to look under. * @return array */ private static function list_in( $value, array $keys ) { if ( is_array( $value ) && self::is_list( $value ) ) { return $value; } if ( ! is_array( $value ) ) { return array(); } foreach ( $keys as $key ) { if ( isset( $value[ $key ] ) && is_array( $value[ $key ] ) && self::is_list( $value[ $key ] ) ) { return $value[ $key ]; } } return array(); } /** * @param mixed $value Value. * @return array */ private static function record( $value ) { return ( is_array( $value ) && ! self::is_list( $value ) ) ? $value : array(); } /** * Only links a browser can safely follow. Mirrors isSafeLinkUrl. * * @param string $url URL. * @return bool */ private static function safe_link( $url ) { return (bool) preg_match( '#^https?://[^\s"\'<>]+$#i', $url ) || (bool) preg_match( '#^/(?!/)[^\s"\'<>]*$#', $url ); } /** * Generated content of $type, in any stored shape, as clean items. Null * when nothing usable is left. Mirrors readDestinationItems. * * @param string $type Content type. * @param mixed $value Value, or JSON. * @return array|null */ public static function items( $type, $value ) { if ( is_string( $value ) ) { $t = trim( $value ); if ( '' !== $t && ( '[' === $t[0] || '{' === $t[0] ) ) { $decoded = json_decode( $t, true ); if ( null !== $decoded ) { $value = $decoded; } } } $cap = isset( self::MAX_ITEMS[ $type ] ) ? self::MAX_ITEMS[ $type ] : 0; $items = array(); switch ( $type ) { case 'highlights': foreach ( self::list_in( $value, array( 'highlights', 'bullets', 'items' ) ) as $entry ) { $line = self::line( $entry ); if ( '' !== $line && ! in_array( $line, $items, true ) ) { $items[] = $line; } } break; case 'product-qa': foreach ( self::list_in( $value, array( 'faqs', 'qa', 'items' ) ) as $entry ) { $r = self::record( $entry ); $q = self::line( isset( $r['question'] ) ? $r['question'] : '' ); $a = self::line( isset( $r['answer'] ) ? $r['answer'] : '' ); if ( '' !== $q && '' !== $a ) { $items[] = array( 'question' => $q, 'answer' => $a, ); } } break; case 'irl-cards': $items = self::irl_cards( $value ); break; case 'internal-links': foreach ( self::list_in( $value, array( 'links', 'content', 'items' ) ) as $entry ) { $r = self::record( $entry ); $url = self::line( isset( $r['targetUrl'] ) ? $r['targetUrl'] : ( isset( $r['url'] ) ? $r['url'] : '' ) ); if ( '' === $url || ! self::safe_link( $url ) ) { continue; } $text = self::line( isset( $r['anchorText'] ) ? $r['anchorText'] : ( isset( $r['text'] ) ? $r['text'] : '' ) ); if ( '' === $text ) { $text = self::line( isset( $r['targetTitle'] ) ? $r['targetTitle'] : '' ); } $items[] = array( 'url' => $url, 'text' => '' !== $text ? $text : $url, ); } break; case 'product-attributes': foreach ( self::list_in( $value, array( 'attributes', 'specs', 'items' ) ) as $entry ) { $r = self::record( $entry ); if ( isset( $r['values'] ) && is_array( $r['values'] ) ) { $v = implode( ', ', array_values( array_filter( array_map( array( __CLASS__, 'line' ), $r['values'] ), 'strlen' ) ) ); } else { $v = self::line( isset( $r['value'] ) ? $r['value'] : '' ); } $n = self::line( isset( $r['name'] ) ? $r['name'] : '' ); if ( '' !== $n && '' !== $v ) { $items[] = array( 'name' => $n, 'value' => $v, ); } } break; default: return null; } $items = array_slice( $items, 0, $cap ); return empty( $items ) ? null : $items; } /** * The merchant's selected card when the payload names one, else every card. * * @param mixed $value Payload. * @return array */ private static function irl_cards( $value ) { $r = self::record( $value ); $nested = self::record( isset( $r['irlCards'] ) ? $r['irlCards'] : null ); $cards = self::list_in( $value, array( 'cards', 'enhanced', 'scenarios' ) ); if ( empty( $cards ) ) { $cards = self::list_in( $nested, array( 'enhanced', 'cards', 'scenarios' ) ); } $items = array(); foreach ( $cards as $card ) { $c = self::record( $card ); $raw = isset( $c['keyFeatures'] ) && is_array( $c['keyFeatures'] ) ? $c['keyFeatures'] : ( isset( $c['features'] ) && is_array( $c['features'] ) ? $c['features'] : array() ); $features = array_values( array_filter( array_map( array( __CLASS__, 'line' ), $raw ), 'strlen' ) ); $title = self::line( isset( $c['title'] ) ? $c['title'] : '' ); $desc = self::line( isset( $c['description'] ) ? $c['description'] : '' ); if ( '' !== $title || '' !== $desc ) { $items[] = array( 'title' => $title, 'description' => $desc, 'features' => $features, ); } } foreach ( array( array( $r, 'selectedIndex' ), array( $r, 'selectedCardIndex' ), array( $nested, 'selectedCardIndex' ), array( $nested, 'selectedIndex' ) ) as $pair ) { if ( isset( $pair[0][ $pair[1] ] ) && is_int( $pair[0][ $pair[1] ] ) ) { $selected = $pair[0][ $pair[1] ]; return ( $selected >= 0 && $selected < count( $items ) ) ? array( $items[ $selected ] ) : $items; } } return $items; } /* --------------------------------------------------------------------- */ /* Rendering */ /* --------------------------------------------------------------------- */ /** * HTML-escaped text. Mirrors escapeHtml. * * @param string $text Text. * @return string */ private static function esc( $text ) { return htmlspecialchars( (string) $text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', true ); } /** * @param string[] $items Escaped items. * @return string */ private static function ul( array $items ) { $lis = array(); foreach ( $items as $item ) { $lis[] = '
  • ' . $item . '
  • '; } return '
      ' . self::BLOCK_JOIN . implode( self::BLOCK_JOIN, $lis ) . self::BLOCK_JOIN . '
    '; } /** * The value for a field of $format, or null. Mirrors renderForField for * the formats this platform has. * * @param string $type Content type. * @param array $items From items(). * @param string $format multiline|html. * @return string|null */ public static function render_field( $type, array $items, $format ) { if ( ! self::accepts( $type, $format ) ) { return null; } return 'html' === $format ? self::html( $type, $items, false ) : self::multiline( $type, $items ); } /** * @param string $type Content type. * @param array $items Items. * @return string|null */ private static function multiline( $type, array $items ) { $bullet = "\u{2022} "; $parts = array(); switch ( $type ) { case 'highlights': foreach ( $items as $h ) { $parts[] = $bullet . $h; } return implode( "\n", $parts ); case 'product-qa': foreach ( $items as $q ) { $parts[] = $q['question'] . "\n" . $q['answer']; } return implode( "\n\n", $parts ); case 'irl-cards': foreach ( $items as $c ) { $lines = array_values( array_filter( array( $c['title'], $c['description'] ), 'strlen' ) ); foreach ( $c['features'] as $f ) { $lines[] = $bullet . $f; } $parts[] = implode( "\n", $lines ); } return implode( "\n\n", $parts ); case 'product-attributes': foreach ( $items as $s ) { $parts[] = $s['name'] . ': ' . $s['value']; } return implode( "\n", $parts ); } return null; } /** * In a section questions and card titles are headings; in a field, bold * paragraphs. Mirrors htmlFor. * * @param string $type Content type. * @param array $items Items. * @param bool $in_section Inside a description section. * @return string */ private static function html( $type, array $items, $in_section ) { $strong = function ( $text ) use ( $in_section ) { return $in_section ? '

    ' . self::esc( $text ) . '

    ' : '

    ' . self::esc( $text ) . '

    '; }; $parts = array(); switch ( $type ) { case 'highlights': return self::ul( array_map( array( __CLASS__, 'esc' ), $items ) ); case 'product-qa': foreach ( $items as $q ) { $parts[] = $strong( $q['question'] ) . self::BLOCK_JOIN . '

    ' . self::esc( $q['answer'] ) . '

    '; } return implode( self::BLOCK_JOIN, $parts ); case 'irl-cards': foreach ( $items as $c ) { $parts[] = implode( self::BLOCK_JOIN, array_filter( array( '' !== $c['title'] ? $strong( $c['title'] ) : '', '' !== $c['description'] ? '

    ' . self::esc( $c['description'] ) . '

    ' : '', ! empty( $c['features'] ) ? self::ul( array_map( array( __CLASS__, 'esc' ), $c['features'] ) ) : '', ), 'strlen' ) ); } return implode( self::BLOCK_JOIN, $parts ); case 'internal-links': $links = array(); foreach ( $items as $l ) { $links[] = '' . self::esc( $l['text'] ) . ''; } return self::ul( $links ); case 'product-attributes': $specs = array(); foreach ( $items as $s ) { $specs[] = '' . self::esc( $s['name'] ) . ': ' . self::esc( $s['value'] ); } return self::ul( $specs ); } return ''; } /** * A whole description section for $type, wrapper included. Mirrors * renderDescriptionSection. * * @param string $type Content type. * @param array $items Items. * @param string $position start|end. * @param string $heading Optional heading. * @return string */ public static function render_section( $type, array $items, $position, $heading = '' ) { $position = 'start' === $position ? 'start' : 'end'; $heading = self::clean_heading( $heading ); return '
    ' . self::BLOCK_JOIN . ( '' !== $heading ? '

    ' . self::esc( $heading ) . '

    ' . self::BLOCK_JOIN : '' ) . self::html( $type, $items, true ) . self::BLOCK_JOIN . '
    '; } /* --------------------------------------------------------------------- */ /* Description sections */ /* --------------------------------------------------------------------- */ /** * @param string $open_tag A section's opening tag. * @return string start|end */ private static function position_of( $open_tag ) { return preg_match( '/\bdata-rezolve-ai-position\s*=\s*["\']start["\']/i', $open_tag ) ? 'start' : 'end'; } /** * Every section in a description, in order. * * @param string $html Description. * @return array[] { type, position, html } */ public static function sections( $html ) { $out = array(); if ( ! is_string( $html ) || false === strpos( $html, 'data-rezolve-ai-section' ) ) { return $out; } if ( preg_match_all( self::SECTION_PATTERN, $html, $matches, PREG_SET_ORDER ) ) { foreach ( $matches as $m ) { $whole = (string) preg_replace( '/\r?\n$/', '', (string) preg_replace( '/^\r?\n/', '', $m[0] ) ); $out[] = array( 'type' => $m[3], 'position' => self::position_of( substr( $whole, 0, strpos( $whole, '>' ) + 1 ) ), 'html' => $whole, ); } } return $out; } /** * Whether a description holds any of our sections. * * @param mixed $html Description. * @return bool */ public static function has_sections( $html ) { return ! empty( self::sections( $html ) ); } /** * The description without the sections $drop matches. Mirrors removeSections. * * @param string $html Description. * @param callable $drop fn( string $type ): bool. * @return string */ private static function remove_where( $html, callable $drop ) { return (string) preg_replace_callback( self::SECTION_PATTERN, function ( $m ) use ( $drop ) { if ( ! $drop( $m[3] ) ) { return $m[0]; } return ( ! empty( $m[1] ) && ! empty( $m[4] ) ) ? "\n" : ''; }, (string) $html ); } /** * @param string $html Description. * @param string $type Content type. * @return string */ public static function remove_section( $html, $type ) { return self::remove_where( $html, function ( $t ) use ( $type ) { return $t === $type; } ); } /** * What the merchant wrote: every section taken out. * * @param string $html Description. * @return string */ public static function strip_sections( $html ) { return self::remove_where( $html, function () { return true; } ); } /** * $section put into $html, replacing any earlier one of its type. * * @param string $html Description. * @param string $section From render_section(). * @return string */ public static function upsert_section( $html, $section ) { if ( ! preg_match( '/data-rezolve-ai-section\s*=\s*["\']([a-z-]+)["\']/i', $section, $open ) ) { return (string) $html; } $position = self::position_of( substr( $section, 0, strpos( $section, '>' ) + 1 ) ); $base = self::remove_section( (string) $html, $open[1] ); if ( '' === self::collapse( $base ) ) { return $section; } return 'start' === $position ? $section . "\n" . $base : $base . "\n" . $section; } /** * Sections live in $from carried into $into, for a write that replaces the * whole description. A section $into has is kept as it has it. * * @param string $from Live description. * @param string $into New description. * @return string */ public static function carry_sections( $from, $into ) { $out = (string) $into; $present = array(); foreach ( self::sections( $out ) as $section ) { $present[ $section['type'] ] = true; } foreach ( self::sections( (string) $from ) as $section ) { if ( ! isset( $present[ $section['type'] ] ) ) { $out = self::upsert_section( $out, $section['html'] ); } } return $out; } /** * A new description for a product, with the sections it has live carried * over and any section the new copy tried to bring along dropped. Every * description write goes through this. * * @param \WC_Product $product Product. * @param string $value New description. * @return string */ public static function description_for( $product, $value ) { return self::carry_sections( (string) $product->get_description( 'edit' ), self::strip_sections( (string) $value ) ); } /* --------------------------------------------------------------------- */ /* Routing */ /* --------------------------------------------------------------------- */ /** * Write $data where the merchant chose for $type. * * @param Plugin $plugin Orchestrator, for the pre-apply backup. * @param int $post_id Product id. * @param string $type Content type. * @param mixed $data Generated content, any stored shape or JSON. * @return true|string|null Null: the block, so the caller writes its own * meta as before. True: written. A string: why not. */ public static function route( $plugin, $post_id, $type, $data ) { $destination = self::for_type( $type ); if ( null === $destination ) { return null; } $items = self::items( $type, $data ); if ( null === $items ) { return 'nothing usable to publish'; } if ( 'field' === $destination['kind'] ) { $field = $destination['field']; $format = self::format_for( $field['source'], $field['type'] ); $value = null === $format ? null : self::render_field( $type, $items, $format ); if ( null === $value ) { return $field['label'] . ': ' . __( 'This field cannot hold this content. Choose another field in Settings.', 'agentic-commerce-engine' ); } $result = Custom_Fields::write_rewrites( $post_id, array( array( 'ref' => $field['id'], 'source' => $field['source'], 'namespace' => $field['namespace'], 'key' => $field['key'], 'type' => $field['type'], 'format' => $format, 'label' => $field['label'], 'value' => $value, // No hash: the merchant chose to have this field replaced. 'beforeHash' => '', 'acfKey' => isset( $field['acfKey'] ) ? $field['acfKey'] : '', ), ) ); return $result['written'] > 0 ? true : implode( ' ', $result['skipped'] ); } $product = wc_get_product( (int) $post_id ); if ( ! $product ) { return 'product not loadable'; } // The original description is what revert restores; take it before the // first change, as every other apply does. $plugin->apply()->backup_once( $product ); $section = self::render_section( $type, $items, $destination['position'], $destination['heading'] ); $product->set_description( wp_kses_post( self::upsert_section( (string) $product->get_description( 'edit' ), $section ) ) ); $product->save(); return true; } }