*/ public static function term_names( \WC_Product $product, string $taxonomy = 'product_cat', int $max = 10 ): array { $id = $product->get_parent_id() ? $product->get_parent_id() : $product->get_id(); $terms = get_the_terms( $id, $taxonomy ); if ( ! is_array( $terms ) ) { return array(); } $names = array(); foreach ( array_slice( $terms, 0, $max ) as $term ) { $names[] = html_entity_decode( $term->name, ENT_QUOTES, 'UTF-8' ); } return $names; } /** * Compact product row (search results and stock/content reports). * * @param \WC_Product $product Product or variation. * @return array */ public static function product_row( \WC_Product $product ): array { $parent_id = (int) $product->get_parent_id(); $quantity = $product->get_manage_stock() ? $product->get_stock_quantity() : null; return array( 'id' => (int) $product->get_id(), 'parent_id' => $parent_id, 'name' => html_entity_decode( wp_strip_all_tags( $product->get_name() ), ENT_QUOTES, 'UTF-8' ), 'sku' => (string) $product->get_sku(), 'type' => (string) $product->get_type(), 'status' => (string) $product->get_status(), 'price' => self::price( $product->get_price() ), 'regular_price' => self::price( $product->get_regular_price() ), 'sale_price' => self::price( $product->get_sale_price() ), 'stock_status' => (string) $product->get_stock_status(), 'stock_quantity' => null === $quantity ? null : (int) $quantity, 'categories' => self::term_names( $product ), 'edit_link' => self::product_edit_link( $parent_id ? $parent_id : (int) $product->get_id() ), ) + self::price_range( $product ); } /** * Variable product: the price range over its variations (WooCommerce gives the parent only the lowest price and * empty regular/sale prices). Empty array for any other type. * * @param \WC_Product $product Product. * @return array */ public static function price_range( \WC_Product $product ): array { if ( ! $product instanceof \WC_Product_Variable ) { return array(); } $prices = $product->get_variation_prices( false ); $price = array_map( 'floatval', array_values( (array) ( $prices['price'] ?? array() ) ) ); $regular = array_map( 'floatval', array_values( (array) ( $prices['regular_price'] ?? array() ) ) ); $sale = array(); foreach ( (array) ( $prices['sale_price'] ?? array() ) as $id => $value ) { // WooCommerce fills sale_price with the regular price when a variation is not on sale. if ( isset( $prices['regular_price'][ $id ] ) && (float) $value < (float) $prices['regular_price'][ $id ] ) { $sale[] = (float) $value; } } if ( ! $price ) { return array(); } return array( 'price_range' => array( 'min' => min( $price ), 'max' => max( $price ), 'regular_min' => $regular ? min( $regular ) : null, 'regular_max' => $regular ? max( $regular ) : null, 'sale_min' => $sale ? min( $sale ) : null, 'sale_max' => $sale ? max( $sale ) : null, 'on_sale_variations' => count( $sale ), ), ); } /** * Loads rows for a list of product ids, keeping their order. * * @param array $ids Product ids. * @return array> */ public static function product_rows( array $ids ): array { $rows = array(); foreach ( $ids as $id ) { $product = wc_get_product( (int) $id ); if ( $product ) { $rows[] = self::product_row( $product ); } } return $rows; } /** * JSON schema of a product row. * * @return array */ public static function product_row_schema(): array { return array( 'type' => 'object', 'additionalProperties' => false, 'required' => array( 'id', 'name', 'sku', 'type', 'price', 'stock_status', 'edit_link' ), 'properties' => array( 'id' => array( 'type' => 'integer' ), 'parent_id' => array( 'type' => 'integer' ), 'name' => array( 'type' => 'string' ), 'sku' => array( 'type' => 'string' ), 'type' => array( 'type' => 'string' ), 'status' => array( 'type' => 'string' ), 'price' => array( 'type' => 'string' ), 'regular_price' => array( 'type' => 'string' ), 'sale_price' => array( 'type' => 'string' ), 'stock_status' => array( 'type' => 'string' ), 'stock_quantity' => array( 'type' => array( 'integer', 'null' ) ), 'categories' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), ), 'edit_link' => array( 'type' => 'string' ), 'price_range' => array( 'type' => 'object', 'additionalProperties' => false, 'properties' => array( 'min' => array( 'type' => 'number' ), 'max' => array( 'type' => 'number' ), 'regular_min' => array( 'type' => array( 'number', 'null' ) ), 'regular_max' => array( 'type' => array( 'number', 'null' ) ), 'sale_min' => array( 'type' => array( 'number', 'null' ) ), 'sale_max' => array( 'type' => array( 'number', 'null' ) ), 'on_sale_variations' => array( 'type' => 'integer' ), ), ), ), ); } /** * Input schema fragments for a period filter. * * @param string $default_period Default period (documented only). * @return array> */ public static function period_input_properties( string $default_period = '' ): array { $period = array( 'type' => 'string', 'enum' => self::PERIODS, 'description' => 'Date range: today, 7d (last 7 days incl. today), 30d, month (this calendar month) or custom (needs date_from/date_to). Store timezone.' . ( '' !== $default_period ? ' Default ' . $default_period . '.' : '' ), ); $date = array( 'type' => 'string', 'pattern' => '^\d{4}-\d{2}-\d{2}$', ); return array( 'period' => $period, 'date_from' => $date + array( 'description' => 'Start date YYYY-MM-DD (custom period, inclusive).' ), 'date_to' => $date + array( 'description' => 'End date YYYY-MM-DD (custom period, inclusive). Max range 366 days.' ), ); } /** * Resolves a period into a [from, to] range in the store timezone. * * @param string $period today|7d|30d|month|custom. * @param string $date_from YYYY-MM-DD (custom). * @param string $date_to YYYY-MM-DD (custom). * @param int $now Current timestamp (tests). * @return array{from:\DateTimeImmutable,to:\DateTimeImmutable,period:string}|WP_Error */ public static function period_range( string $period, string $date_from = '', string $date_to = '', int $now = 0 ) { $tz = wp_timezone(); $now = ( new \DateTimeImmutable( '@' . ( $now ? $now : time() ) ) )->setTimezone( $tz ); $today = $now->setTime( 0, 0, 0 ); if ( '' === $period && ( '' !== $date_from || '' !== $date_to ) ) { $period = 'custom'; } switch ( $period ) { case 'today': return array( 'from' => $today, 'to' => $now, 'period' => $period, ); case '7d': return array( 'from' => $today->modify( '-6 days' ), 'to' => $now, 'period' => $period, ); case '30d': return array( 'from' => $today->modify( '-29 days' ), 'to' => $now, 'period' => $period, ); case 'month': return array( 'from' => $today->modify( 'first day of this month' ), 'to' => $now, 'period' => $period, ); case 'custom': $from = \DateTimeImmutable::createFromFormat( '!Y-m-d', $date_from, $tz ); $to = '' === $date_to ? $today : \DateTimeImmutable::createFromFormat( '!Y-m-d', $date_to, $tz ); if ( ! $from || ! $to || $from->format( 'Y-m-d' ) !== $date_from || ( '' !== $date_to && $to->format( 'Y-m-d' ) !== $date_to ) ) { return new WP_Error( 'addsynapse_assistant_invalid_dates', 'A custom period needs valid dates date_from and date_to (YYYY-MM-DD).' ); } if ( $from > $to ) { return new WP_Error( 'addsynapse_assistant_invalid_dates', 'date_from must be before or equal to date_to.' ); } if ( (int) $from->diff( $to )->days >= self::MAX_RANGE_DAYS ) { return new WP_Error( 'addsynapse_assistant_range_too_long', sprintf( 'The date range is limited to %d days.', self::MAX_RANGE_DAYS ) ); } return array( 'from' => $from, 'to' => $to->setTime( 23, 59, 59 ), 'period' => 'custom', ); }//end switch return new WP_Error( 'addsynapse_assistant_invalid_period', 'Unknown period.' ); } /** * Period as output data. * * @param array{from:\DateTimeImmutable,to:\DateTimeImmutable,period:string} $range Range. * @return array */ public static function period_output( array $range ): array { return array( 'period' => $range['period'], 'from' => $range['from']->format( 'Y-m-d' ), 'to' => $range['to']->format( 'Y-m-d' ), ); } /** * Schema of period output. * * @return array */ public static function period_output_schema(): array { return array( 'type' => 'object', 'additionalProperties' => false, 'properties' => array( 'period' => array( 'type' => 'string' ), 'from' => array( 'type' => 'string' ), 'to' => array( 'type' => 'string' ), ), ); } /** * Common ability meta (private: not in REST; exposed to the agent; read-only). * * @return array */ public static function readonly_meta(): array { return array( 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true, ), 'show_in_rest' => false, 'addsynapse_assistant_agent' => true, ); } /** * Meta of the plan abilities: exposed to the agent, NOT read-only (they record a draft * changeset), never destructive. ToolRegistry runs them only because they are on its * fixed PLAN_ABILITIES list. * * @return array */ public static function plan_meta(): array { return array( 'annotations' => array( 'readonly' => false, 'destructive' => false, 'idempotent' => false, ), 'show_in_rest' => false, 'addsynapse_assistant_agent' => true, 'addsynapse_assistant_plan' => true, ); } /** * Clamps a limit to 1..MAX_LIMIT. * * @param mixed $limit Limit. */ public static function limit( $limit ): int { $limit = null === $limit ? self::DEFAULT_LIMIT : (int) $limit; return max( 1, min( self::MAX_LIMIT, $limit ) ); } /** * Removes accents (Greek tonos/dialytika and Latin diacritics) — used for search terms. Uses intl's * Normalizer when present, otherwise the pure-PHP I18n\AccentFold (same result for Greek and Latin). * * @param string $text Text. */ public static function strip_accents( string $text ): string { // Seam `no_intl` (tests only): the same code path as a host without the intl extension. if ( class_exists( \Normalizer::class ) && null === \Addsynapse\StoreAssistant\Seams::get( 'no_intl', 'array' ) ) { $decomposed = \Normalizer::normalize( $text, \Normalizer::FORM_D ); if ( is_string( $decomposed ) ) { return (string) preg_replace( '/\p{Mn}+/u', '', $decomposed ); } } return \Addsynapse\StoreAssistant\I18n\AccentFold::strip( $text ); } }