0, 'candidates' => 0 ); $lock = $wpdb->prefix . 'aieo_boost_rebuild'; if ( 1 !== (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $lock ) ) ) { return $out + array( 'skipped' => 'locked' ); } try { $objs = array(); $comp = array(); $quad = array(); $veto = array(); self::$cold_start_keys = array(); self::collect_seo_opportunities( $objs, $comp, $quad ); self::collect_seo_posts( $objs, $comp ); self::collect_gsc_search_demand( $objs, $comp ); self::collect_content_vitals( $objs, $comp ); self::collect_sales_predictor( $objs, $comp, $veto ); self::collect_intent_mass( $objs, $comp ); self::collect_push_appetite( $objs, $comp ); self::collect_waitlist_demand( $objs, $comp, $veto ); self::collect_rating_quality( $objs, $comp, $veto ); self::collect_repeat_affinity( $objs, $comp ); self::collect_basket_role( $objs, $comp ); self::collect_semantic( $objs, $comp ); self::collect_cold_start( $objs, $comp ); self::collect_timely_objects( $objs, $comp, $veto ); self::strip_non_promotable( $objs, $comp ); self::strip_untargetable( $objs, $comp ); if ( empty( $objs ) ) { return $out; } $keys = self::select_top_candidates( $objs, $comp ); $out['candidates'] = count( $keys ); $pct = array(); $dead = array(); foreach ( $comp as $component => $vals ) { $scoped = array(); foreach ( $keys as $k ) { if ( array_key_exists( $k, $vals ) ) { $scoped[ $k ] = $vals[ $k ]; } } if ( ! self::has_variance( $scoped ) ) { if ( ! empty( $scoped ) ) { $dead[] = $component . ' (' . count( $scoped ) . ' objects, all identical)'; } continue; } $pct[ $component ] = in_array( $component, self::ABSOLUTE_COMPONENTS, true ) ? self::clamp_100( $scoped ) : self::percent_ranks( $scoped ); } if ( $dead ) { error_log( 'AIEO_Boost_Candidates: dropped constant component(s) carrying no signal — ' . implode( '; ', $dead ) ); } list( $weights, $weights_version ) = self::load_weights(); $connected = self::connected_platforms(); $now = current_time( 'mysql', true ); $table = self::ensure_table(); $wpdb->query( "TRUNCATE TABLE `{$table}`" ); foreach ( $keys as $k ) { $object_type = (string) $objs[ $k ]['object_type']; $object_id = (int) $objs[ $k ]['object_id']; $vector = array(); $score = 0.0; $weight_sum = 0.0; foreach ( $pct as $component => $ranks ) { if ( ! array_key_exists( $k, $ranks ) ) { continue; } $p = (float) $ranks[ $k ]; $vector[ $component ] = round( $p, 1 ); $w = isset( $weights[ $component ] ) ? (float) $weights[ $component ] : 0.0; if ( $w > 0 ) { $score += $w * $p; $weight_sum += $w; } } $boost_score = ( $weight_sum > 0 ) ? round( $score / $weight_sum, 2 ) : 0.0; $veto_reasons = isset( $veto[ $k ] ) ? (string) $veto[ $k ] : ''; $vectors = ( '' === $veto_reasons ) ? self::eligible_vectors( $object_id, $connected ) : array(); $components_json = wp_json_encode( array( 'components' => $vector, 'weights_version' => $weights_version, 'quadrant' => isset( $quad[ $k ] ) ? (string) $quad[ $k ] : '', ) ); $ok = $wpdb->replace( $table, array( 'object_type' => $object_type, 'object_id' => $object_id, 'boost_score' => $boost_score, 'seo_quadrant' => isset( $quad[ $k ] ) ? (string) $quad[ $k ] : '', 'components_json' => (string) $components_json, 'weights_version' => $weights_version, 'eligible_vectors' => implode( ',', $vectors ), 'veto_reasons' => $veto_reasons, 'computed_at' => $now, ), array( '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s' ) ); if ( false !== $ok ) { $out['updated']++; } } } finally { $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); } return $out; } private static function collect_seo_opportunities( array &$objs, array &$comp, array &$quad ) { global $wpdb; $t = $wpdb->prefix . 'aieo_seo_opportunities'; if ( ! self::table_exists( $t ) || ! self::has_cols( $t, array( 'object_type', 'object_id', 'priority' ) ) ) { return; } $has_quad = self::has_cols( $t, array( 'quadrant' ) ); $quad_sel = $has_quad ? 'quadrant' : "'' AS quadrant"; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT object_type, object_id, priority, {$quad_sel} FROM `{$t}` ORDER BY priority DESC LIMIT %d", self::SOURCE_LIMIT ), ARRAY_A ); foreach ( (array) $rows as $r ) { $k = self::register( $objs, (string) $r['object_type'], (int) $r['object_id'] ); if ( null === $k ) { continue; } $comp['seo_opportunity'][ $k ] = (float) $r['priority']; if ( '' !== (string) $r['quadrant'] ) { $quad[ $k ] = (string) $r['quadrant']; } } } private static function collect_gsc_search_demand( array &$objs, array &$comp ) { global $wpdb; $t = $wpdb->prefix . 'aieo_gsc_daily'; if ( ! self::table_exists( $t ) ) { return; } $cutoff = gmdate( 'Y-m-d', time() - 90 * DAY_IN_SECONDS ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT object_type, object_id, SUM(clicks) + 0.02 * SUM(impressions) AS gsc_demand FROM `{$t}` WHERE dim_type = 'page' AND object_id > 0 AND `date` >= %s AND object_type IN ('product', 'post', 'page') GROUP BY object_type, object_id HAVING gsc_demand > 0 ORDER BY gsc_demand DESC LIMIT %d", $cutoff, self::SOURCE_LIMIT ), ARRAY_A ); foreach ( (array) $rows as $r ) { $k = self::register( $objs, (string) $r['object_type'], (int) $r['object_id'] ); if ( null === $k ) { continue; } if ( empty( $comp['search_demand'][ $k ] ) ) { $comp['search_demand'][ $k ] = (float) $r['gsc_demand']; } } } private static function collect_seo_posts( array &$objs, array &$comp ) { global $wpdb; $t = $wpdb->prefix . 'aieo_seo_posts'; if ( ! self::table_exists( $t ) || ! self::has_cols( $t, array( 'object_type', 'object_id' ) ) ) { return; } $has_search = self::has_cols( $t, array( 'search_demand_score' ) ); $has_striking = self::has_cols( $t, array( 'striking_count' ) ); $has_demand = self::has_cols( $t, array( 'demand_score' ) ); if ( ! $has_search && ! $has_striking && ! $has_demand ) { return; } $search_expr = $has_search ? 'COALESCE(search_demand_score,0)' : '0'; $strike_expr = $has_striking ? 'COALESCE(striking_count,0)' : '0'; $demand_expr = $has_demand ? 'COALESCE(demand_score,0)' : '0'; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT object_type, object_id, ({$search_expr} + {$strike_expr}) AS search_demand, {$demand_expr} AS commerce_demand FROM `{$t}` ORDER BY GREATEST({$search_expr} + {$strike_expr}, {$demand_expr}) DESC LIMIT %d", self::SOURCE_LIMIT ), ARRAY_A ); foreach ( (array) $rows as $r ) { $k = self::register( $objs, (string) $r['object_type'], (int) $r['object_id'] ); if ( null === $k ) { continue; } if ( $has_search || $has_striking ) { $comp['search_demand'][ $k ] = (float) $r['search_demand']; } if ( $has_demand ) { $comp['commerce_demand'][ $k ] = (float) $r['commerce_demand']; } } } private static function collect_content_vitals( array &$objs, array &$comp ) { global $wpdb; $t = $wpdb->prefix . 'aieo_core_content_vitals'; if ( ! self::table_exists( $t ) || ! self::has_cols( $t, array( 'post_type', 'content_id', 'content_demand' ) ) ) { return; } $rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_type, content_id, content_demand FROM `{$t}` ORDER BY content_demand DESC LIMIT %d", self::SOURCE_LIMIT ), ARRAY_A ); foreach ( (array) $rows as $r ) { $k = self::register( $objs, (string) $r['post_type'], (int) $r['content_id'] ); if ( null === $k ) { continue; } $comp['content_demand'][ $k ] = (float) $r['content_demand']; } } private static function collect_semantic( array &$objs, array &$comp ) { global $wpdb; $t = $wpdb->prefix . 'aieo_sem_pairs'; if ( ! self::table_exists( $t ) || ! self::has_cols( $t, array( 'source_type', 'source_id', 'score' ) ) ) { return; } $rows = $wpdb->get_results( $wpdb->prepare( "SELECT source_type, source_id, SUM(score) AS mass FROM `{$t}` GROUP BY source_type, source_id ORDER BY mass DESC LIMIT %d", self::SOURCE_LIMIT ), ARRAY_A ); foreach ( (array) $rows as $r ) { $key = (string) $r['source_type'] . ':' . (int) $r['source_id']; if ( ! isset( $objs[ $key ] ) ) { continue; } $comp['semantic'][ $key ] = (float) $r['mass']; } } private static function collect_sales_predictor( array &$objs, array &$comp, array &$veto ) { global $wpdb; $t = $wpdb->prefix . 'aieo_sales_predictor'; if ( ! self::table_exists( $t ) || ! self::has_cols( $t, array( 'product_id', 'quantity_next_28_days' ) ) ) { return; } $has_conf = self::has_cols( $t, array( 'prediction_confidence' ) ); $has_stock = self::has_cols( $t, array( 'current_stock' ) ); $conf_expr = $has_conf ? 'COALESCE(prediction_confidence,0)' : '1'; $stock_sel = $has_stock ? 'current_stock' : 'NULL AS current_stock'; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT product_id, (COALESCE(quantity_next_28_days,0) * {$conf_expr}) AS forecast_units, {$stock_sel} FROM `{$t}` ORDER BY forecast_units DESC LIMIT %d", self::SOURCE_LIMIT ), ARRAY_A ); foreach ( (array) $rows as $r ) { $k = self::register( $objs, 'product', (int) $r['product_id'] ); if ( null === $k ) { continue; } $forecast = (float) $r['forecast_units']; $comp['forecast_units'][ $k ] = $forecast; if ( $has_stock && null !== $r['current_stock'] && $forecast > 0 ) { $stock = (float) $r['current_stock']; if ( $stock >= 0 && $stock < $forecast ) { $veto[ $k ] = self::add_reason( isset( $veto[ $k ] ) ? $veto[ $k ] : '', 'stock_signout' ); } } } } private static function collect_intent_mass( array &$objs, array &$comp ) { global $wpdb; $intent = array(); $w = $wpdb->prefix . 'aieo_wishlist'; if ( self::table_exists( $w ) && self::has_cols( $w, array( 'item_id' ) ) ) { $type_clause = self::has_cols( $w, array( 'item_type' ) ) ? "WHERE t.item_type = 'product'" : ''; list( $tb_sel, $tb_ord ) = self::tie_break( $w, 'date_added', 't' ); $who = self::has_cols( $w, array( 'user_id' ) ) && self::has_cols( $w, array( 'session_id' ) ) ? "COUNT(DISTINCT COALESCE(NULLIF(t.user_id,0), CONCAT('s:', t.session_id)))" : ( self::has_cols( $w, array( 'user_id' ) ) ? 'COUNT(DISTINCT t.user_id)' : 'COUNT(*)' ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT pid, c FROM ( SELECT tgt.ID AS pid, {$who} AS c{$tb_sel} FROM `{$w}` t JOIN `{$wpdb->posts}` p ON p.ID = t.item_id JOIN `{$wpdb->posts}` tgt ON tgt.ID = CASE WHEN p.post_type = 'product_variation' AND p.post_parent > 0 THEN p.post_parent ELSE p.ID END AND tgt.post_status = 'publish' AND tgt.post_type = 'product' {$type_clause} GROUP BY pid ) x ORDER BY c DESC, {$tb_ord}pid ASC LIMIT %d", self::SOURCE_LIMIT ), ARRAY_A ); foreach ( (array) $rows as $r ) { $k = self::register( $objs, 'product', (int) $r['pid'] ); if ( null !== $k ) { $intent[ $k ] = ( isset( $intent[ $k ] ) ? $intent[ $k ] : 0 ) + (int) $r['c']; } } } $a = $wpdb->prefix . 'aieo_addcart_events'; if ( self::table_exists( $a ) && self::has_cols( $a, array( 'product_id' ) ) ) { $count_expr = self::has_cols( $a, array( 'session_id' ) ) ? 'COUNT(DISTINCT e.session_id)' : 'COUNT(*)'; $var_case = ''; $var_join = ''; if ( self::has_cols( $a, array( 'variation_id' ) ) ) { $var_join = "LEFT JOIN `{$wpdb->posts}` pv ON pv.ID = e.variation_id"; $var_case = "WHEN pp.ID IS NULL THEN CASE WHEN pv.post_type = 'product_variation' AND pv.post_parent > 0 THEN pv.post_parent ELSE pv.ID END"; } list( $tb_sel, $tb_ord ) = self::tie_break( $a, 'ts_added', 'e' ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT pid, c FROM ( SELECT tgt.ID AS pid, {$count_expr} AS c{$tb_sel} FROM `{$a}` e LEFT JOIN `{$wpdb->posts}` pp ON pp.ID = e.product_id {$var_join} JOIN `{$wpdb->posts}` tgt ON tgt.ID = CASE {$var_case} WHEN pp.post_type = 'product_variation' AND pp.post_parent > 0 THEN pp.post_parent ELSE pp.ID END AND tgt.post_status = 'publish' AND tgt.post_type = 'product' GROUP BY pid ) x ORDER BY c DESC, {$tb_ord}pid ASC LIMIT %d", self::SOURCE_LIMIT ), ARRAY_A ); foreach ( (array) $rows as $r ) { $k = self::register( $objs, 'product', (int) $r['pid'] ); if ( null !== $k ) { $intent[ $k ] = ( isset( $intent[ $k ] ) ? $intent[ $k ] : 0 ) + (int) $r['c']; } } } foreach ( $intent as $k => $c ) { $comp['intent_mass'][ $k ] = (float) $c; } } private static function tie_break( $table, $col, $alias ) { if ( ! self::has_cols( $table, array( $col ) ) ) { return array( '', '' ); } return array( ", MAX({$alias}.`{$col}`) AS last_demand", 'last_demand DESC, ' ); } private static function collect_waitlist_demand( array &$objs, array &$comp, array &$veto ) { global $wpdb; $t = $wpdb->prefix . 'aieo_waitlist'; if ( ! self::table_exists( $t ) || ! self::has_cols( $t, array( 'product_id' ) ) ) { return; } $where = ''; if ( self::has_cols( $t, array( 'notified_at' ) ) ) { $where = 'WHERE notified_at IS NULL'; } elseif ( self::has_cols( $t, array( 'status' ) ) ) { $where = "WHERE status <> 'notified'"; } $rows = $wpdb->get_results( $wpdb->prepare( "SELECT product_id AS pid, COUNT(DISTINCT email) AS c FROM `{$t}` {$where} GROUP BY product_id ORDER BY c DESC LIMIT %d", self::SOURCE_LIMIT ), ARRAY_A ); if ( ! $rows ) { return; } $ids = array(); foreach ( $rows as $r ) { $ids[] = (int) $r['pid']; } $ids = array_values( array_unique( array_filter( $ids ) ) ); if ( ! $ids ) { return; } $ph = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); $live = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_type, post_parent FROM `{$wpdb->posts}` WHERE ID IN ({$ph}) AND post_status = 'publish'", $ids ), ARRAY_A ); $resolved = array(); foreach ( (array) $live as $p ) { $resolved[ (int) $p['ID'] ] = ( 'product_variation' === $p['post_type'] && (int) $p['post_parent'] > 0 ) ? (int) $p['post_parent'] : (int) $p['ID']; } if ( ! $resolved ) { return; } $targets = array_values( array_unique( $resolved ) ); $tph = implode( ',', array_fill( 0, count( $targets ), '%d' ) ); $stock = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, meta_value FROM `{$wpdb->postmeta}` WHERE meta_key = '_stock_status' AND post_id IN ({$tph})", $targets ), ARRAY_A ); $in_stock = array(); foreach ( (array) $stock as $srow ) { if ( 'instock' === (string) $srow['meta_value'] ) { $in_stock[ (int) $srow['post_id'] ] = true; } } $demand = array(); foreach ( $rows as $r ) { $pid = (int) $r['pid']; if ( ! isset( $resolved[ $pid ] ) ) { continue; } $target = $resolved[ $pid ]; $demand[ $target ] = ( isset( $demand[ $target ] ) ? $demand[ $target ] : 0 ) + (int) $r['c']; } foreach ( $demand as $target => $count ) { $k = self::register( $objs, 'product', $target ); if ( null === $k ) { continue; } $comp['waitlist_demand'][ $k ] = (float) $count; if ( ! isset( $in_stock[ $target ] ) ) { $veto[ $k ] = self::add_reason( isset( $veto[ $k ] ) ? $veto[ $k ] : '', 'out_of_stock' ); } } } private static function collect_rating_quality( array &$objs, array &$comp, array &$veto ) { global $wpdb; $t = $wpdb->prefix . 'aieo_rating_stats'; if ( ! self::table_exists( $t ) || ! self::has_cols( $t, array( 'product_id', 'count_total', 'sum_total' ) ) ) { return; } $type_clause = self::has_cols( $t, array( 'object_type' ) ) ? "AND object_type = 'product'" : ''; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT product_id AS pid, count_total AS n, sum_total AS s FROM `{$t}` WHERE count_total > 0 {$type_clause} ORDER BY count_total DESC LIMIT %d", self::SOURCE_LIMIT ), ARRAY_A ); if ( ! $rows ) { return; } $tot_n = 0; $tot_s = 0.0; foreach ( $rows as $r ) { $tot_n += (int) $r['n']; $tot_s += (float) $r['s']; } $prior = $tot_n > 0 ? ( $tot_s / $tot_n ) : 0.0; if ( $prior <= 0 ) { return; } foreach ( $rows as $r ) { $k = self::register( $objs, 'product', (int) $r['pid'] ); if ( null === $k ) { continue; } $cnt = (int) $r['n']; $avg = $cnt > 0 ? ( (float) $r['s'] / $cnt ) : 0.0; $shrunk = ( ( $cnt * $avg ) + ( self::RATING_PRIOR_VOTES * $prior ) ) / ( $cnt + self::RATING_PRIOR_VOTES ); $comp['rating_quality'][ $k ] = (float) $shrunk; if ( $cnt >= self::RATING_VETO_MIN_VOTES && $avg < self::RATING_VETO_BELOW ) { $veto[ $k ] = self::add_reason( isset( $veto[ $k ] ) ? $veto[ $k ] : '', 'low_rating' ); } } } private static function collect_cold_start( array &$objs, array &$comp ) { global $wpdb; if ( ! class_exists( 'AIEO_Customer_Purchases' ) ) { return; } $sources = AIEO_Customer_Purchases::source_tables(); $orders = is_array( $sources ) ? (string) reset( $sources ) : (string) $sources; if ( '' === $orders || ! self::table_exists( $orders ) ) { return; } if ( ! self::has_cols( $orders, array( 'ProductId', 'ParentProductId', 'CategoryId', 'PrimaryQty', 'OrderDate', 'OrderStatus' ) ) ) { return; } $days = (int) apply_filters( 'aieo_boost_cold_start_days', self::COLD_START_DAYS ); $days = $days > 0 ? $days : self::COLD_START_DAYS; $cutoff = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ); $new = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_date_gmt FROM {$wpdb->posts} WHERE post_type = 'product' AND post_status = 'publish' AND post_date_gmt >= %s", $cutoff ), ARRAY_A ); if ( empty( $new ) ) { return; } $stat = AIEO_Customer_Purchases::PURCHASE_STATUSES; $cat_rows = $wpdb->get_results( "SELECT CategoryId AS cid, SUM(PrimaryQty) AS units, COUNT(DISTINCT COALESCE(NULLIF(ParentProductId,0), ProductId)) AS prods FROM `{$orders}` WHERE OrderStatus IN {$stat} AND CategoryId > 0 GROUP BY CategoryId", ARRAY_A ); if ( empty( $cat_rows ) ) { return; } $cat_mean = array(); $total_units = 0.0; $total_prods = 0; foreach ( $cat_rows as $r ) { $p = (int) $r['prods']; $u = (float) $r['units']; if ( $p > 0 ) { $cat_mean[ (int) $r['cid'] ] = $u / $p; $total_units += $u; $total_prods += $p; } } $global_mean = $total_prods > 0 ? $total_units / $total_prods : 0.0; if ( $global_mean <= 0 ) { return; } $ids = array_map( 'intval', wp_list_pluck( $new, 'ID' ) ); $in = implode( ',', array_map( 'absint', $ids ) ); $sold = (array) $wpdb->get_col( "SELECT DISTINCT COALESCE(NULLIF(ParentProductId,0), ProductId) FROM `{$orders}` WHERE OrderStatus IN {$stat} AND COALESCE(NULLIF(ParentProductId,0), ProductId) IN ({$in})" ); $sold = array_flip( array_map( 'intval', $sold ) ); $cats = (array) $wpdb->get_results( "SELECT tr.object_id, tt.term_id FROM {$wpdb->term_relationships} tr JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id WHERE tt.taxonomy = 'product_cat' AND tr.object_id IN ({$in})", ARRAY_A ); $by_prod = array(); foreach ( $cats as $c ) { $by_prod[ (int) $c['object_id'] ][] = (int) $c['term_id']; } $now = time(); $halflife = max( 1, (int) apply_filters( 'aieo_boost_cold_start_halflife_days', self::COLD_START_HALFLIFE_DAYS ) ); $k = (float) self::COLD_START_SHRINK; $scored = 0; foreach ( $new as $row ) { $pid = (int) $row['ID']; if ( isset( $sold[ $pid ] ) ) { continue; } if ( empty( $by_prod[ $pid ] ) ) { continue; } $best = 0.0; $n = 0; foreach ( $by_prod[ $pid ] as $tid ) { if ( isset( $cat_mean[ $tid ] ) ) { $best = max( $best, (float) $cat_mean[ $tid ] ); $n++; } } if ( $n <= 0 ) { continue; } $prior = ( $n * $best + $k * $global_mean ) / ( $n + $k ); $age = max( 0, ( $now - strtotime( (string) $row['post_date_gmt'] . ' UTC' ) ) / DAY_IN_SECONDS ); $novelty = exp( -$age / $halflife ); $val = $novelty * ( $prior / $global_mean ); if ( $val <= 0 ) { continue; } $key = self::register( $objs, 'product', $pid ); if ( null === $key ) { continue; } $comp['cold_start'][ $key ] = (float) $val; $scored++; } if ( $scored > 0 ) { self::$cold_start_keys = array_keys( isset( $comp['cold_start'] ) ? $comp['cold_start'] : array() ); } } private static function collect_basket_role( array &$objs, array &$comp ) { if ( ! class_exists( 'AIEO_Basket_Role' ) ) { $f = AIEO_DIR . 'includes/class-aieo-basket-role.php'; if ( ! file_exists( $f ) ) { return; } require_once $f; } foreach ( AIEO_Basket_Role::lifts() as $pid => $lift ) { $key = 'product:' . (int) $pid; if ( ! isset( $objs[ $key ] ) ) { continue; } $comp['basket_role'][ $key ] = (float) $lift; } } private static function collect_repeat_affinity( array &$objs, array &$comp ) { global $wpdb; $t = $wpdb->prefix . 'wc_order_product_lookup'; if ( ! self::table_exists( $t ) || ! self::has_cols( $t, array( 'product_id', 'customer_id', 'order_id' ) ) ) { return; } $qty_clause = self::has_cols( $t, array( 'product_qty' ) ) ? 'AND l.product_qty > 0' : ''; $stats_t = $wpdb->prefix . 'wc_order_stats'; $status_join = ''; if ( self::table_exists( $stats_t ) && self::has_cols( $stats_t, array( 'order_id', 'status' ) ) ) { $excluded = apply_filters( 'woocommerce_analytics_excluded_order_statuses', array( 'pending', 'failed', 'cancelled', 'auto-draft', 'trash', 'checkout-draft' ) ); $excluded = array_values( array_unique( array_filter( array_map( 'strval', (array) $excluded ) ) ) ); if ( $excluded ) { $norm = array(); foreach ( $excluded as $st ) { $bare = ( 0 === strpos( $st, 'wc-' ) ) ? substr( $st, 3 ) : $st; $norm[] = $bare; $norm[] = 'wc-' . $bare; } $norm = array_values( array_unique( array_filter( $norm ) ) ); $ph = implode( ',', array_fill( 0, count( $norm ), '%s' ) ); $status_join = $wpdb->prepare( "INNER JOIN `{$stats_t}` os ON os.order_id = l.order_id AND os.status NOT IN ({$ph})", $norm ); } } $rows = $wpdb->get_results( $wpdb->prepare( "SELECT g.pid, g.buyers, g.repeaters FROM ( SELECT b.product_id AS pid, COUNT(*) AS buyers, SUM(b.order_n > 1) AS repeaters FROM ( SELECT l.product_id, l.customer_id, COUNT(DISTINCT l.order_id) AS order_n FROM `{$t}` l {$status_join} WHERE l.customer_id > 0 {$qty_clause} GROUP BY l.product_id, l.customer_id ) b GROUP BY b.product_id HAVING COUNT(*) >= %d ) g INNER JOIN `{$wpdb->posts}` p ON p.ID = g.pid AND p.post_type = 'product' AND p.post_status = 'publish' ORDER BY g.buyers DESC, g.pid ASC LIMIT %d", self::REPEAT_MIN_BUYERS, self::REPEAT_MAX_POPULATION ), ARRAY_A ); if ( ! $rows ) { return; } if ( count( $rows ) >= self::REPEAT_MAX_POPULATION ) { error_log( sprintf( 'repeat_affinity: population cap reached (%d products); products beyond it were not scored.', self::REPEAT_MAX_POPULATION ) ); } $rate = array(); $buyers = array(); foreach ( $rows as $r ) { $pid = (int) $r['pid']; $n = (int) $r['buyers']; if ( $pid <= 0 || $n <= 0 ) { continue; } $buyers[ $pid ] = $n; $rate[ $pid ] = (float) $r['repeaters'] / $n; } if ( ! $rate ) { return; } $catalogue_mean = array_sum( $rate ) / count( $rate ); if ( $catalogue_mean <= 0 ) { return; } $parent = array(); $tcount = array(); $terms = $wpdb->get_results( "SELECT term_id, parent, count FROM `{$wpdb->term_taxonomy}` WHERE taxonomy = 'product_cat'", ARRAY_A ); foreach ( (array) $terms as $r ) { $tid = (int) $r['term_id']; $parent[ $tid ] = (int) $r['parent']; $tcount[ $tid ] = (int) $r['count']; } $pids = array_keys( $rate ); $ph = implode( ',', array_fill( 0, count( $pids ), '%d' ) ); $rel = $parent ? $wpdb->get_results( $wpdb->prepare( "SELECT tr.object_id AS pid, tt.term_id FROM `{$wpdb->term_relationships}` tr INNER JOIN `{$wpdb->term_taxonomy}` tt ON tt.term_taxonomy_id = tr.term_taxonomy_id WHERE tt.taxonomy = 'product_cat' AND tr.object_id IN ({$ph})", $pids ), ARRAY_A ) : array(); $assigned = array(); $depth = array(); foreach ( (array) $rel as $r ) { $pid = (int) $r['pid']; $tid = (int) $r['term_id']; if ( ! isset( $parent[ $tid ] ) || ! isset( $rate[ $pid ] ) ) { continue; } if ( ! isset( $depth[ $tid ] ) ) { $depth[ $tid ] = count( self::term_chain( $tid, $parent ) ) - 1; } if ( ! isset( $assigned[ $pid ] ) ) { $assigned[ $pid ] = $tid; continue; } $cur = $assigned[ $pid ]; if ( $depth[ $tid ] > $depth[ $cur ] || ( $depth[ $tid ] === $depth[ $cur ] && $tcount[ $tid ] > $tcount[ $cur ] ) || ( $depth[ $tid ] === $depth[ $cur ] && $tcount[ $tid ] === $tcount[ $cur ] && $tid < $cur ) ) { $assigned[ $pid ] = $tid; } } $sum = array(); $cnt = array(); foreach ( $assigned as $pid => $tid ) { foreach ( self::term_chain( $tid, $parent ) as $a ) { $sum[ $a ] = ( isset( $sum[ $a ] ) ? $sum[ $a ] : 0.0 ) + $rate[ $pid ]; $cnt[ $a ] = ( isset( $cnt[ $a ] ) ? $cnt[ $a ] : 0 ) + 1; } } $admitted = 0; foreach ( $buyers as $pid => $n ) { $known = isset( $objs[ 'product:' . $pid ] ); if ( ! $known && $admitted >= self::SOURCE_LIMIT ) { continue; } $reference = $catalogue_mean; if ( isset( $assigned[ $pid ] ) ) { foreach ( self::term_chain( $assigned[ $pid ], $parent ) as $a ) { if ( ! isset( $cnt[ $a ] ) || ( $cnt[ $a ] - 1 ) < self::REPEAT_CAT_MIN_PRODUCTS ) { continue; } $peer_sum = $sum[ $a ] - $rate[ $pid ]; if ( $peer_sum > 0 ) { $reference = $peer_sum / ( $cnt[ $a ] - 1 ); break; } } } if ( $reference <= 0 ) { $reference = $catalogue_mean; } $shrunk = ( ( $n * $rate[ $pid ] ) + ( self::REPEAT_PRIOR_BUYERS * $reference ) ) / ( $n + self::REPEAT_PRIOR_BUYERS ); $key = 'product:' . $pid; $k = isset( $objs[ $key ] ) ? $key : null; if ( null === $k ) { continue; } if ( ! $known ) { $admitted++; } $comp['repeat_affinity'][ $k ] = max( -self::REPEAT_MAX_LOG_LIFT, min( self::REPEAT_MAX_LOG_LIFT, log( $shrunk / $reference ) ) ); } } private static function term_chain( $term_id, array $parent ) { $chain = array(); $tid = (int) $term_id; $guard = 0; while ( $tid > 0 && isset( $parent[ $tid ] ) && ! in_array( $tid, $chain, true ) && $guard++ < 10 ) { $chain[] = $tid; $tid = (int) $parent[ $tid ]; } return $chain; } private static function collect_push_appetite( array &$objs, array &$comp ) { global $wpdb; $t = $wpdb->prefix . 'aieo_push_dispatch'; if ( ! self::table_exists( $t ) || ! self::has_cols( $t, array( 'target_product_id', 'state' ) ) ) { return; } $rows = $wpdb->get_results( $wpdb->prepare( "SELECT target_product_id AS pid, SUM(state IN ('sent','clicked','dismissed')) AS sent, SUM(state = 'clicked') AS clicked, ( SUM(state = 'clicked') / NULLIF(SUM(state IN ('sent','clicked','dismissed')), 0) ) AS ctr FROM `{$t}` WHERE target_product_id > 0 GROUP BY target_product_id HAVING sent >= %d ORDER BY ctr DESC LIMIT %d", self::PUSH_MIN_SENT, self::SOURCE_LIMIT ), ARRAY_A ); foreach ( (array) $rows as $r ) { $sent = (float) $r['sent']; if ( $sent <= 0 ) { continue; } $k = self::register( $objs, 'product', (int) $r['pid'] ); if ( null !== $k ) { $comp['push_appetite'][ $k ] = (float) $r['clicked'] / $sent; } } } public static function get_voc_pulse() { global $wpdb; $out = array( 'available' => false, 'responses' => 0, 'avg_voc' => 0.0, 'bands' => array( 'high' => 0, 'mid' => 0, 'low' => 0 ), 'nps' => array( 'promoters' => 0, 'passives' => 0, 'detractors' => 0, 'score' => 0.0 ), 'latest_response' => '', 'posture' => 'insufficient', ); $t = $wpdb->prefix . 'aieo_survey_intelligence'; if ( ! self::table_exists( $t ) || ! self::has_cols( $t, array( 'voc_score' ) ) ) { return $out; } $band_sel = self::has_cols( $t, array( 'voc_band' ) ) ? "SUM(voc_band = 'high') band_high, SUM(voc_band = 'mid') band_mid, SUM(voc_band = 'low') band_low" : '0 band_high, 0 band_mid, 0 band_low'; $nps_sel = self::has_cols( $t, array( 'nps_class' ) ) ? "SUM(nps_class = 'promoter') promoters, SUM(nps_class = 'passive') passives, SUM(nps_class = 'detractor') detractors" : '0 promoters, 0 passives, 0 detractors'; $when_sel = self::has_cols( $t, array( 'f18_submitted_at' ) ) ? 'MAX(f18_submitted_at) latest_response' : "'' latest_response"; $answered = array(); foreach ( array( 'nps_score', 'overall_satisfaction', 'site_ux_avg' ) as $c ) { if ( self::has_cols( $t, array( $c ) ) ) { $answered[] = "`{$c}` > 0"; } } $scored = $answered ? '( ' . implode( ' OR ', $answered ) . ' )' : 'voc_score > 0'; $row = $wpdb->get_row( "SELECT COUNT(*) responses, AVG(voc_score) avg_voc, {$band_sel}, {$nps_sel}, {$when_sel} FROM `{$t}` WHERE {$scored}", ARRAY_A ); if ( ! $row || (int) $row['responses'] <= 0 ) { return $out; } $promoters = (int) $row['promoters']; $passives = (int) $row['passives']; $detractors = (int) $row['detractors']; $classified = $promoters + $passives + $detractors; $out['available'] = true; $out['responses'] = (int) $row['responses']; $out['avg_voc'] = round( (float) $row['avg_voc'], 1 ); $out['latest_response'] = (string) $row['latest_response']; $out['bands'] = array( 'high' => (int) $row['band_high'], 'mid' => (int) $row['band_mid'], 'low' => (int) $row['band_low'], ); $out['nps'] = array( 'promoters' => $promoters, 'passives' => $passives, 'detractors' => $detractors, 'score' => ( $classified > 0 ) ? round( 100.0 * ( $promoters - $detractors ) / $classified, 1 ) : 0.0, ); if ( $out['responses'] >= self::VOC_MIN_RESPONSES ) { if ( $out['avg_voc'] >= self::VOC_HEALTHY_ABOVE ) { $out['posture'] = 'healthy'; } elseif ( $out['avg_voc'] < self::VOC_CONCERN_BELOW ) { $out['posture'] = 'concern'; } else { $out['posture'] = 'watch'; } } return $out; } private static function strip_untargetable( array &$objs, array &$comp ) { global $wpdb; if ( ! $objs ) { return; } $ids = array(); foreach ( $objs as $k => $o ) { $ids[ (int) $o['object_id'] ] = true; } $ids = array_values( array_filter( array_keys( $ids ) ) ); if ( ! $ids ) { return; } $live = array(); foreach ( array_chunk( $ids, 500 ) as $chunk ) { $ph = implode( ',', array_fill( 0, count( $chunk ), '%d' ) ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_type FROM `{$wpdb->posts}` WHERE ID IN ({$ph}) AND post_status = 'publish'", $chunk ), ARRAY_A ); foreach ( (array) $rows as $r ) { if ( 'product_variation' === (string) $r['post_type'] ) { continue; } $live[ (int) $r['ID'] ] = true; } } $dropped = 0; foreach ( array_keys( $objs ) as $k ) { if ( isset( $live[ (int) $objs[ $k ]['object_id'] ] ) ) { continue; } unset( $objs[ $k ] ); foreach ( $comp as $component => $_ ) { unset( $comp[ $component ][ $k ] ); } $dropped++; } if ( $dropped > 0 ) { error_log( sprintf( 'AIEO_Boost_Candidates: dropped %d untargetable candidate(s) — deleted, unpublished or variation.', $dropped ) ); } } private static function strip_non_promotable( array &$objs, array &$comp ) { $ids = array(); foreach ( array( 'woocommerce_cart_page_id', 'woocommerce_checkout_page_id', 'woocommerce_myaccount_page_id', 'woocommerce_terms_page_id', 'wp_page_for_privacy_policy', ) as $opt ) { $id = (int) get_option( $opt, 0 ); if ( $id > 0 ) { $ids[ $id ] = true; } } if ( ! $ids ) { return; } foreach ( array_keys( $ids ) as $id ) { foreach ( array( 'page', 'post' ) as $type ) { $k = $type . ':' . $id; if ( ! isset( $objs[ $k ] ) ) { continue; } unset( $objs[ $k ] ); foreach ( $comp as $component => $_ ) { unset( $comp[ $component ][ $k ] ); } } } } private static function collect_timely_objects( array &$objs, array &$comp, array &$veto ) { global $wpdb; $specs = array( 'aieo_competition' => array( '_aieo_comp_start', '_aieo_comp_end' ), 'aieo_event' => array( '_aieo_event_start', '_aieo_event_end' ), ); $now = current_time( 'mysql' ); $now_ts = strtotime( $now ); foreach ( $specs as $post_type => $keys ) { list( $k_start, $k_end ) = $keys; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT p.ID, MAX( CASE WHEN m.meta_key = %s THEN m.meta_value END ) AS starts, MAX( CASE WHEN m.meta_key = %s THEN m.meta_value END ) AS ends FROM `{$wpdb->posts}` p LEFT JOIN `{$wpdb->postmeta}` m ON m.post_id = p.ID AND m.meta_key IN ( %s, %s ) WHERE p.post_type = %s AND p.post_status = 'publish' GROUP BY p.ID LIMIT %d", $k_start, $k_end, $k_start, $k_end, $post_type, self::SOURCE_LIMIT ), ARRAY_A ); foreach ( (array) $rows as $r ) { $k = self::register( $objs, $post_type, (int) $r['ID'] ); if ( null === $k ) { continue; } $ends = (string) $r['ends']; $starts = (string) $r['starts']; $end_ts = ( '' !== $ends ) ? strtotime( $ends ) : 0; if ( $end_ts && $end_ts < $now_ts ) { if ( ( $now_ts - $end_ts ) > ( self::TIMELY_STALE_DAYS * DAY_IN_SECONDS ) ) { unset( $objs[ $k ] ); continue; } $veto[ $k ] = self::add_reason( isset( $veto[ $k ] ) ? $veto[ $k ] : '', 'ended' ); $comp['timeliness'][ $k ] = 0.0; continue; } $start_ts = ( '' !== $starts ) ? strtotime( $starts ) : 0; $target = ( $start_ts && $start_ts > $now_ts ) ? $start_ts : $end_ts; if ( ! $target ) { $comp['timeliness'][ $k ] = self::TIMELY_OPEN_ENDED; continue; } $days = max( 0.0, ( $target - $now_ts ) / DAY_IN_SECONDS ); $comp['timeliness'][ $k ] = 100.0 * ( self::TIMELY_HALF_LIFE_DAYS / ( self::TIMELY_HALF_LIFE_DAYS + $days ) ); } } } private static function select_top_candidates( array $objs, array $comp ) { if ( count( $objs ) <= self::MAX_CANDIDATES ) { return array_keys( $objs ); } $pinned = array(); if ( ! empty( $comp['waitlist_demand'] ) ) { foreach ( $comp['waitlist_demand'] as $k => $v ) { if ( (float) $v > 0 && isset( $objs[ $k ] ) ) { $pinned[ $k ] = true; } } } $bounds = array(); foreach ( $comp as $component => $vals ) { if ( empty( $vals ) ) { continue; } $min = min( $vals ); $max = max( $vals ); $bounds[ $component ] = array( $min, ( $max > $min ) ? ( $max - $min ) : 0.0 ); } $seed = array(); foreach ( $objs as $k => $_ ) { $best = 0.0; foreach ( $comp as $component => $vals ) { if ( ! array_key_exists( $k, $vals ) || ! isset( $bounds[ $component ] ) ) { continue; } list( $min, $span ) = $bounds[ $component ]; $norm = ( $span > 0 ) ? ( ( $vals[ $k ] - $min ) / $span ) : 1.0; if ( $norm > $best ) { $best = $norm; } } $seed[ $k ] = $best; } arsort( $seed ); $ordered = array(); foreach ( array_keys( $seed ) as $k ) { if ( isset( $pinned[ $k ] ) ) { $ordered[] = $k; } } foreach ( array_keys( $seed ) as $k ) { if ( ! isset( $pinned[ $k ] ) ) { $ordered[] = $k; } } return self::apply_explore_quota( self::apply_type_cap( $ordered, $objs ), $objs ); } private static function apply_explore_quota( array $kept, array $objs ) { $share = (float) apply_filters( 'aieo_boost_explore_share', self::EXPLORE_SHARE ); if ( $share <= 0 || empty( $kept ) || empty( self::$cold_start_keys ) ) { return $kept; } $share = min( 0.5, $share ); $quota = (int) floor( count( $kept ) * $share ); if ( $quota <= 0 ) { return $kept; } $cold = array_flip( self::$cold_start_keys ); $in_kept = array(); foreach ( $kept as $k ) { if ( isset( $cold[ $k ] ) ) { $in_kept[ $k ] = true; } } $need = $quota - count( $in_kept ); if ( $need <= 0 ) { return $kept; } $waiting = array(); foreach ( self::$cold_start_keys as $k ) { if ( ! isset( $in_kept[ $k ] ) && isset( $objs[ $k ] ) ) { $waiting[] = $k; } } if ( empty( $waiting ) ) { return $kept; } $swap_in = array_slice( $waiting, 0, $need ); $out = $kept; for ( $i = count( $out ) - 1; $i >= 0 && ! empty( $swap_in ); $i-- ) { if ( isset( $cold[ $out[ $i ] ] ) ) { continue; } $out[ $i ] = array_pop( $swap_in ); } return $out; } private static function apply_type_cap( array $ordered, array $objs ) { if ( count( $ordered ) <= self::MAX_CANDIDATES ) { return $ordered; } $cap = (int) floor( self::MAX_CANDIDATES * self::TYPE_SHARE_CAP ); $taken = array(); $out = array(); $spill = array(); foreach ( $ordered as $k ) { $type = isset( $objs[ $k ]['object_type'] ) ? (string) $objs[ $k ]['object_type'] : ''; $n = isset( $taken[ $type ] ) ? $taken[ $type ] : 0; if ( $n >= $cap ) { $spill[] = $k; continue; } $taken[ $type ] = $n + 1; $out[] = $k; if ( count( $out ) >= self::MAX_CANDIDATES ) { return $out; } } foreach ( $spill as $k ) { $out[] = $k; if ( count( $out ) >= self::MAX_CANDIDATES ) { break; } } return array_slice( $out, 0, self::MAX_CANDIDATES ); } private static function clamp_100( array $values ) { $out = array(); foreach ( $values as $k => $v ) { $out[ $k ] = max( 0.0, min( 100.0, (float) $v ) ); } return $out; } private static function has_variance( array $values ) { if ( count( $values ) < 2 ) { return false; } $first = null; foreach ( $values as $v ) { $v = (float) $v; if ( null === $first ) { $first = $v; continue; } if ( $v !== $first ) { return true; } } return false; } private static function percent_ranks( array $values ) { $n = count( $values ); if ( 0 === $n ) { return array(); } if ( 1 === $n ) { return array_map( static function () { return 50.0; }, $values ); } $sorted = array_values( $values ); sort( $sorted, SORT_NUMERIC ); $out = array(); foreach ( $values as $k => $v ) { $less = 0; foreach ( $sorted as $x ) { if ( $x < $v ) { $less++; } else { break; } } $out[ $k ] = 100.0 * $less / ( $n - 1 ); } return $out; } private static function seed_weights() { return array( 'seo_opportunity' => 0.20, 'search_demand' => 0.20, 'commerce_demand' => 0.15, 'forecast_units' => 0.20, 'content_demand' => 0.10, 'intent_mass' => 0.10, 'waitlist_demand' => 0.12, 'rating_quality' => 0.06, 'repeat_affinity' => 0.08, 'cold_start' => 0.05, 'timeliness' => 0.25, 'push_appetite' => 0.05, 'basket_role' => 0.10, 'semantic' => 0.10, ); } private static function load_weights() { global $wpdb; $seed = self::seed_weights(); $t = $wpdb->prefix . 'aieo_boost_weights'; if ( ! self::table_exists( $t ) || ! self::has_cols( $t, array( 'source' ) ) ) { return array( $seed, 'seed_v1' ); } $json_col = ''; foreach ( array( 'weights_json', 'weights', 'payload', 'components_json', 'data' ) as $c ) { if ( self::has_cols( $t, array( $c ) ) ) { $json_col = $c; break; } } if ( '' === $json_col ) { return array( $seed, 'seed_v1' ); } $order = self::has_cols( $t, array( 'updated_at' ) ) ? 'updated_at DESC' : ( self::has_cols( $t, array( 'id' ) ) ? 'id DESC' : 'source DESC' ); $row = $wpdb->get_row( $wpdb->prepare( "SELECT source, `{$json_col}` AS payload FROM `{$t}` WHERE source LIKE %s ORDER BY {$order} LIMIT 1", $wpdb->esc_like( 'hub:' ) . '%' ), ARRAY_A ); if ( ! $row ) { $row = $wpdb->get_row( $wpdb->prepare( "SELECT source, `{$json_col}` AS payload FROM `{$t}` WHERE source = %s ORDER BY {$order} LIMIT 1", 'seed_v1' ), ARRAY_A ); } if ( ! $row || empty( $row['payload'] ) ) { return array( $seed, 'seed_v1' ); } $decoded = json_decode( (string) $row['payload'], true ); if ( ! is_array( $decoded ) ) { return array( $seed, 'seed_v1' ); } if ( isset( $decoded['weights'] ) && is_array( $decoded['weights'] ) ) { $decoded = $decoded['weights']; } $weights = array(); foreach ( $seed as $component => $default ) { $weights[ $component ] = ( isset( $decoded[ $component ] ) && is_numeric( $decoded[ $component ] ) ) ? (float) $decoded[ $component ] : (float) $default; } if ( array_sum( $weights ) <= 0 ) { return array( $seed, 'seed_v1' ); } return array( $weights, (string) $row['source'] ); } private static function connected_platforms() { if ( ! class_exists( 'AIEO_Social_Store' ) || ! method_exists( 'AIEO_Social_Store', 'get_accounts' ) ) { return array(); } $live = array(); foreach ( (array) AIEO_Social_Store::get_accounts() as $acc ) { if ( ! is_array( $acc ) || empty( $acc['platform'] ) ) { continue; } $status = isset( $acc['token_status'] ) ? (string) $acc['token_status'] : ''; if ( in_array( $status, array( 'expired', 'revoked', 'invalid', 'not_connected' ), true ) ) { continue; } $live[ (string) $acc['platform'] ] = true; } return array_keys( $live ); } private static function eligible_vectors( $object_id, array $connected ) { if ( empty( $connected ) ) { return array(); } $has_image = function_exists( 'has_post_thumbnail' ) && has_post_thumbnail( (int) $object_id ); $out = array(); foreach ( $connected as $platform ) { if ( in_array( $platform, self::TEXT_PLATFORMS, true ) ) { $out[] = $platform; } elseif ( in_array( $platform, self::IMAGE_PLATFORMS, true ) ) { if ( $has_image ) { $out[] = $platform; } } else { $out[] = $platform; } } return $out; } private static function ensure_table() { global $wpdb; $t = $wpdb->prefix . 'aieo_boost_candidates'; $wpdb->query( "CREATE TABLE IF NOT EXISTS `{$t}` ( `object_type` VARCHAR(32) NOT NULL DEFAULT 'product', `object_id` BIGINT(20) UNSIGNED NOT NULL, `boost_score` DECIMAL(6,2) NOT NULL DEFAULT 0, `seo_quadrant` VARCHAR(20) NOT NULL DEFAULT '', `components_json` LONGTEXT NULL, `weights_version` VARCHAR(64) NOT NULL DEFAULT '', `eligible_vectors` VARCHAR(191) NOT NULL DEFAULT '', `veto_reasons` VARCHAR(191) NOT NULL DEFAULT '', `computed_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`object_type`, `object_id`), KEY `idx_score` (`boost_score`), KEY `idx_quadrant` (`seo_quadrant`) ) " . aieo_get_charset_collate() ); return $t; } private static function register( array &$objs, $object_type, $object_id ) { $object_type = (string) $object_type; $object_id = (int) $object_id; if ( '' === $object_type || $object_id <= 0 ) { return null; } $k = $object_type . ':' . $object_id; if ( ! isset( $objs[ $k ] ) ) { $objs[ $k ] = array( 'object_type' => $object_type, 'object_id' => $object_id ); } return $k; } public static function veto_is_transient( $reason ) { return in_array( (string) $reason, array( 'out_of_stock', 'stock_signout' ), true ); } public static function split_vetoes( $reasons ) { $out = array( 'transient' => array(), 'permanent' => array() ); foreach ( array_filter( array_map( 'trim', explode( ',', (string) $reasons ) ) ) as $r ) { $out[ self::veto_is_transient( $r ) ? 'transient' : 'permanent' ][] = $r; } return $out; } private static function add_reason( $existing, $reason ) { $list = array_filter( array_map( 'trim', explode( ',', (string) $existing ) ) ); if ( ! in_array( $reason, $list, true ) ) { $list[] = $reason; } return implode( ',', $list ); } private static function table_exists( $table ) { global $wpdb; return (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); } private static function columns( $table ) { if ( isset( self::$col_cache[ $table ] ) ) { return self::$col_cache[ $table ]; } global $wpdb; $rows = $wpdb->get_col( "SHOW COLUMNS FROM `{$table}`" ); $cols = is_array( $rows ) ? array_map( 'strtolower', $rows ) : array(); self::$col_cache[ $table ] = $cols; return $cols; } private static function has_cols( $table, array $needed ) { $cols = self::columns( $table ); foreach ( $needed as $c ) { if ( ! in_array( strtolower( $c ), $cols, true ) ) { return false; } } return true; } }