'global', 'include' => array(), // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- Default for the override array, not a query argument. 'exclude' => array(), ) ); } /** * Resolve the recommended post IDs for a Display on a given source post. * * @param SRPP_Display $display Display. * @param int $source_id Source post ID. * @param array $args Overrides: items, exclude. * @return array Post IDs. */ public static function get_ids( $display, $source_id, $args = array() ) { $source_id = absint( $source_id ); $items = isset( $args['items'] ) ? absint( $args['items'] ) : (int) $display->opt( 'items' ); $items = max( 1, $items ); $override = $source_id ? self::get_override( $source_id ) : array( 'mode' => 'global' ); if ( 'disable' === $override['mode'] ) { return array(); } $runtime_exclude = isset( $args['exclude'] ) ? srpp_int_list( $args['exclude'] ) : array(); if ( $display->opt( 'no_duplicates' ) ) { $runtime_exclude = array_merge( $runtime_exclude, array_keys( self::$used ) ); } // Cache only covers the deterministic part; runtime exclusions are applied after. $cached = ( 'rand' !== $display->opt( 'order' ) && $source_id ) ? SRPP_Cache::get( $display, $source_id ) : false; if ( false === $cached ) { $pool = self::build_pool( $display, $source_id, $override, $items * 3 + 10 ); $cached = $pool; if ( $source_id ) { SRPP_Cache::set( $display, $source_id, $pool ); } } $ids = array_values( array_diff( $cached, $runtime_exclude ) ); if ( 'rand' === $display->opt( 'order' ) ) { shuffle( $ids ); } $ids = array_slice( $ids, 0, $items ); if ( $display->opt( 'no_duplicates' ) ) { self::mark_used( $ids ); } /** * Filter the final recommendation list. * * @param array $ids Post IDs. * @param SRPP_Display $display Display. * @param int $source_id Source post ID. */ return apply_filters( 'srpp_related_ids', $ids, $display, $source_id ); } /** * Build the (cacheable) candidate pool. * * @param SRPP_Display $display Display. * @param int $source_id Source post. * @param array $override Per-post override. * @param int $limit Pool size. * @return array */ protected static function build_pool( $display, $source_id, $override, $limit ) { $mode = $display->opt( 'selection_mode' ); if ( in_array( $override['mode'], array( 'auto', 'custom', 'auto_custom' ), true ) ) { $mode = $override['mode']; } $manual = srpp_int_list( $display->opt( 'manual_ids' ) ); if ( ! empty( $override['include'] ) ) { $manual = srpp_int_list( $override['include'] ); } $exclude = self::base_exclusions( $display, $source_id, $override ); $pool = array(); // 1. Manual picks come first, in the author's chosen order. if ( in_array( $mode, array( 'manual', 'custom', 'auto_custom', 'auto_manual' ), true ) && $manual ) { $pool = self::filter_valid( $manual, $display, $exclude ); } if ( in_array( $mode, array( 'manual', 'custom' ), true ) ) { return array_slice( $pool, 0, $limit ); } // 2. Automatic scoring fills the rest. $needed = $limit - count( $pool ); if ( $needed > 0 ) { $scored = self::score_candidates( $display, $source_id, array_merge( $exclude, $pool ), $needed ); $pool = array_merge( $pool, $scored ); } // 3. Fallback when relationships are thin. $needed = $limit - count( $pool ); if ( $needed > 0 && 'none' !== $display->opt( 'fallback' ) ) { $pool = array_merge( $pool, self::fallback( $display, array_merge( $exclude, $pool ), $needed ) ); } return array_values( array_unique( array_map( 'absint', $pool ) ) ); } /** * IDs that must never appear. * * @param SRPP_Display $display Display. * @param int $source_id Source post. * @param array $override Override data. * @return array */ protected static function base_exclusions( $display, $source_id, $override ) { $exclude = srpp_int_list( $display->opt( 'ex_ids' ) ); if ( ! empty( $override['exclude'] ) ) { $exclude = array_merge( $exclude, srpp_int_list( $override['exclude'] ) ); } if ( $display->opt( 'ex_current' ) && $source_id ) { $exclude[] = $source_id; } return array_values( array_unique( $exclude ) ); } /** * Keep only IDs that pass type/status/exclusion checks. * * @param array $ids Candidate IDs. * @param SRPP_Display $display Display. * @param array $exclude Excluded IDs. * @return array */ protected static function filter_valid( $ids, $display, $exclude ) { $types = (array) $display->opt( 'target_types' ); $out = array(); foreach ( $ids as $id ) { $id = absint( $id ); if ( ! $id || in_array( $id, $exclude, true ) ) { continue; } $post = get_post( $id ); if ( ! $post || 'publish' !== $post->post_status ) { continue; } if ( $types && ! in_array( $post->post_type, $types, true ) ) { continue; } if ( $display->opt( 'ex_password' ) && '' !== $post->post_password ) { continue; } if ( self::blocked_by_meta( $display, $post ) ) { continue; } $out[] = $id; } return $out; } /** * Meta-level exclusions that are cheaper to evaluate in PHP. * * @param SRPP_Display $display Display. * @param WP_Post $post Post. * @return bool True when the post must be skipped. */ protected static function blocked_by_meta( $display, $post ) { if ( $display->opt( 'ex_noindex' ) ) { $yoast = get_post_meta( $post->ID, '_yoast_wpseo_meta-robots-noindex', true ); $rank = get_post_meta( $post->ID, 'rank_math_robots', true ); if ( '1' === (string) $yoast ) { return true; } if ( is_array( $rank ) && in_array( 'noindex', $rank, true ) ) { return true; } } if ( $display->opt( 'ex_authors' ) && in_array( (int) $post->post_author, srpp_int_list( $display->opt( 'ex_authors' ) ), true ) ) { return true; } return false; } /* --------------------------------------------------------------------- * Scoring * ------------------------------------------------------------------ */ /** * Weight for a taxonomy. * * @param string $taxonomy Taxonomy name. * @param array $weights Weight map. * @return int */ protected static function taxonomy_weight( $taxonomy, $weights ) { if ( in_array( $taxonomy, array( 'category', 'product_cat' ), true ) ) { return (int) $weights['category']; } if ( in_array( $taxonomy, array( 'post_tag', 'product_tag' ), true ) ) { return (int) $weights['post_tag']; } if ( 0 === strpos( $taxonomy, 'pa_' ) ) { return (int) $weights['attribute']; } return (int) $weights['taxonomy']; } /** * Term-taxonomy IDs of the source post, mapped to a weight. * * @param SRPP_Display $display Display. * @param int $source_id Source post. * @return array tt_id => weight */ protected static function source_weights( $display, $source_id ) { $weights = wp_parse_args( (array) $display->opt( 'weights' ), srpp_default_weights() ); $relate_by = (array) $display->opt( 'relate_by' ); $map = array(); if ( ! $source_id ) { return $map; } $basic = 'basic' === $display->opt( 'engine' ); foreach ( $relate_by as $taxonomy ) { if ( ! taxonomy_exists( $taxonomy ) ) { continue; } $terms = get_the_terms( $source_id, $taxonomy ); if ( empty( $terms ) || is_wp_error( $terms ) ) { continue; } $weight = $basic ? 1 : self::taxonomy_weight( $taxonomy, $weights ); $primary = $basic ? 0 : self::primary_term_id( $source_id, $taxonomy ); foreach ( $terms as $term ) { $value = $weight; if ( $primary && (int) $term->term_id === $primary ) { $value = (int) $weights['primary_term']; } $map[ (int) $term->term_taxonomy_id ] = max( isset( $map[ $term->term_taxonomy_id ] ) ? $map[ $term->term_taxonomy_id ] : 0, $value ); } } // Cross-content matching: link a post to products (or any other type) that // carry a term with the same slug in one of their own taxonomies. $map = self::add_cross_matches( $display, $source_id, $map ); return $map; } /** * Add same-slug terms from the target types' taxonomies. * * @param SRPP_Display $display Display. * @param int $source_id Source post. * @param array $map Existing tt_id => weight map. * @return array */ protected static function add_cross_matches( $display, $source_id, $map ) { if ( ! $display->opt( 'cross_match' ) ) { return $map; } $source_type = get_post_type( $source_id ); $target_types = array_diff( (array) $display->opt( 'target_types' ), array( $source_type ) ); if ( empty( $target_types ) ) { return $map; } $slugs = array(); foreach ( (array) $display->opt( 'relate_by' ) as $taxonomy ) { $terms = get_the_terms( $source_id, $taxonomy ); if ( empty( $terms ) || is_wp_error( $terms ) ) { continue; } foreach ( $terms as $term ) { $slugs[] = $term->slug; } } if ( empty( $slugs ) ) { return $map; } $target_taxes = array(); foreach ( $target_types as $type ) { $target_taxes = array_merge( $target_taxes, get_object_taxonomies( $type ) ); } $target_taxes = array_unique( $target_taxes ); if ( empty( $target_taxes ) ) { return $map; } $terms = get_terms( array( 'taxonomy' => $target_taxes, 'slug' => array_unique( $slugs ), 'hide_empty' => true, ) ); if ( is_wp_error( $terms ) ) { return $map; } $weights = wp_parse_args( (array) $display->opt( 'weights' ), srpp_default_weights() ); foreach ( $terms as $term ) { $tt = (int) $term->term_taxonomy_id; if ( ! isset( $map[ $tt ] ) ) { $map[ $tt ] = self::taxonomy_weight( $term->taxonomy, $weights ); } } return $map; } /** * Primary term as set by Yoast SEO / Rank Math, when available. * * @param int $post_id Post ID. * @param string $taxonomy Taxonomy. * @return int */ protected static function primary_term_id( $post_id, $taxonomy ) { $yoast = get_post_meta( $post_id, '_yoast_wpseo_primary_' . $taxonomy, true ); if ( $yoast ) { return (int) $yoast; } $rank = get_post_meta( $post_id, 'rank_math_primary_' . $taxonomy, true ); return $rank ? (int) $rank : 0; } /** * Score and rank candidates. * * @param SRPP_Display $display Display. * @param int $source_id Source post. * @param array $exclude Excluded IDs. * @param int $limit How many to return. * @return array */ protected static function score_candidates( $display, $source_id, $exclude, $limit ) { global $wpdb; $map = self::source_weights( $display, $source_id ); if ( empty( $map ) ) { return array(); } $types = array_filter( array_map( 'sanitize_key', (array) $display->opt( 'target_types' ) ) ); if ( empty( $types ) ) { return array(); } $tt_ids = array_map( 'absint', array_keys( $map ) ); $case = 'CASE tr.term_taxonomy_id'; foreach ( $map as $tt_id => $weight ) { $case .= $wpdb->prepare( ' WHEN %d THEN %d', (int) $tt_id, (int) $weight ); } $case .= ' ELSE 0 END'; $type_ph = implode( ', ', array_fill( 0, count( $types ), '%s' ) ); $tt_ph = implode( ', ', array_fill( 0, count( $tt_ids ), '%d' ) ); $where = array(); $params = array(); $where[] = "tr.term_taxonomy_id IN ({$tt_ph})"; $params = array_merge( $params, $tt_ids ); $where[] = "p.post_type IN ({$type_ph})"; $params = array_merge( $params, $types ); $where[] = "p.post_status = 'publish'"; if ( $display->opt( 'ex_password' ) ) { $where[] = "p.post_password = ''"; } if ( ! empty( $exclude ) ) { $ex_ph = implode( ', ', array_fill( 0, count( $exclude ), '%d' ) ); $where[] = "p.ID NOT IN ({$ex_ph})"; $params = array_merge( $params, array_map( 'absint', $exclude ) ); } $authors = srpp_int_list( $display->opt( 'ex_authors' ) ); if ( $authors ) { $a_ph = implode( ', ', array_fill( 0, count( $authors ), '%d' ) ); $where[] = "p.post_author NOT IN ({$a_ph})"; $params = array_merge( $params, $authors ); } $blocked_tt = self::blocked_tt_ids( $display ); if ( $blocked_tt ) { $b_ph = implode( ', ', array_fill( 0, count( $blocked_tt ), '%d' ) ); $where[] = "p.ID NOT IN ( SELECT object_id FROM {$wpdb->term_relationships} WHERE term_taxonomy_id IN ({$b_ph}) )"; $params = array_merge( $params, $blocked_tt ); } $where_sql = implode( ' AND ', $where ); $fetch = max( 20, $limit * 4 ); $sql = "SELECT tr.object_id AS id, SUM({$case}) AS score, MAX(p.post_author) AS author, MAX(p.post_date_gmt) AS pdate FROM {$wpdb->term_relationships} tr INNER JOIN {$wpdb->posts} p ON p.ID = tr.object_id WHERE {$where_sql} GROUP BY tr.object_id ORDER BY score DESC, pdate DESC LIMIT %d"; $params[] = $fetch; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is assembled above from core table names and %d/%s placeholders only; every value travels through $params. Results are cached by SRPP_Cache. $rows = $wpdb->get_results( $wpdb->prepare( $sql, $params ) ); if ( empty( $rows ) ) { return array(); } $rows = self::apply_bonuses( $display, $source_id, $rows ); $ids = array(); foreach ( $rows as $row ) { $post = get_post( (int) $row->id ); if ( ! $post || self::blocked_by_meta( $display, $post ) ) { continue; } $ids[] = (int) $row->id; if ( count( $ids ) >= $limit ) { break; } } return $ids; } /** * Author and recency bonuses, then re-sort. * * @param SRPP_Display $display Display. * @param int $source_id Source post. * @param array $rows Result rows. * @return array */ protected static function apply_bonuses( $display, $source_id, $rows ) { $order = $display->opt( 'order' ); if ( 'basic' !== $display->opt( 'engine' ) && 'score' === $order ) { $weights = wp_parse_args( (array) $display->opt( 'weights' ), srpp_default_weights() ); $source_author = (int) get_post_field( 'post_author', $source_id ); $window = (int) $display->opt( 'recency_days' ) * DAY_IN_SECONDS; $now = time(); foreach ( $rows as $row ) { $row->score = (float) $row->score; if ( $source_author && (int) $row->author === $source_author ) { $row->score += (float) $weights['author']; } if ( $window > 0 && ( $now - strtotime( $row->pdate . ' +0000' ) ) <= $window ) { $row->score += (float) $weights['recency']; } } } if ( 'date' === $order ) { usort( $rows, array( __CLASS__, 'sort_by_date' ) ); } elseif ( 'modified' === $order || 'title' === $order ) { $ids = wp_list_pluck( $rows, 'id' ); $ordered = get_posts( array( 'post__in' => array_map( 'absint', $ids ), 'post_type' => 'any', 'posts_per_page' => count( $ids ), 'orderby' => ( 'title' === $order ) ? 'title' : 'modified', 'order' => ( 'title' === $order ) ? 'ASC' : 'DESC', 'fields' => 'ids', 'no_found_rows' => true, ) ); $lookup = array(); foreach ( $rows as $row ) { $lookup[ (int) $row->id ] = $row; } $sorted = array(); foreach ( $ordered as $id ) { if ( isset( $lookup[ $id ] ) ) { $sorted[] = $lookup[ $id ]; } } $rows = $sorted; } else { usort( $rows, array( __CLASS__, 'sort_by_score' ) ); } return $rows; } /** * Sort helper. * * @param object $a Row. * @param object $b Row. * @return int */ public static function sort_by_score( $a, $b ) { if ( (float) $a->score === (float) $b->score ) { return strcmp( $b->pdate, $a->pdate ); } return ( (float) $a->score < (float) $b->score ) ? 1 : -1; } /** * Sort helper. * * @param object $a Row. * @param object $b Row. * @return int */ public static function sort_by_date( $a, $b ) { return strcmp( $b->pdate, $a->pdate ); } /** * Term-taxonomy IDs that disqualify a candidate. * * @param SRPP_Display $display Display. * @return array */ protected static function blocked_tt_ids( $display ) { $out = array(); foreach ( (array) $display->opt( 'ex_terms' ) as $rule ) { if ( empty( $rule['taxonomy'] ) || empty( $rule['terms'] ) ) { continue; } foreach ( (array) $rule['terms'] as $term_id ) { $term = get_term( (int) $term_id, $rule['taxonomy'] ); if ( $term && ! is_wp_error( $term ) ) { $out[] = (int) $term->term_taxonomy_id; } } } if ( $display->opt( 'ex_outofstock' ) && srpp_is_woo_active() ) { $term = get_term_by( 'name', 'outofstock', 'product_visibility' ); if ( $term && ! is_wp_error( $term ) ) { $out[] = (int) $term->term_taxonomy_id; } } return array_values( array_unique( array_filter( $out ) ) ); } /** * Fill remaining slots when relationships run out. * * @param SRPP_Display $display Display. * @param array $exclude Excluded IDs. * @param int $limit How many. * @return array */ protected static function fallback( $display, $exclude, $limit ) { $fallback = $display->opt( 'fallback' ); $args = array( 'post_type' => (array) $display->opt( 'target_types' ), 'post_status' => 'publish', 'posts_per_page' => $limit, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in -- The list is bounded by the display item count; it holds the handful of posts already shown in this request. 'post__not_in' => array_map( 'absint', array_values( array_unique( $exclude ) ) ), 'ignore_sticky_posts' => true, 'no_found_rows' => true, 'fields' => 'ids', 'has_password' => $display->opt( 'ex_password' ) ? false : null, ); if ( 'random' === $fallback ) { $args['orderby'] = 'rand'; } elseif ( 'popular' === $fallback ) { $args['orderby'] = 'comment_count'; $args['order'] = 'DESC'; } else { $args['orderby'] = 'date'; $args['order'] = 'DESC'; } $tax_query = array(); foreach ( (array) $display->opt( 'ex_terms' ) as $rule ) { if ( empty( $rule['taxonomy'] ) || empty( $rule['terms'] ) ) { continue; } $tax_query[] = array( 'taxonomy' => $rule['taxonomy'], 'field' => 'term_id', 'terms' => array_map( 'absint', (array) $rule['terms'] ), 'operator' => 'NOT IN', ); } if ( $display->opt( 'ex_outofstock' ) && srpp_is_woo_active() ) { $tax_query[] = array( 'taxonomy' => 'product_visibility', 'field' => 'name', 'terms' => array( 'outofstock' ), 'operator' => 'NOT IN', ); } if ( $tax_query ) { $tax_query['relation'] = 'AND'; $args['tax_query'] = $tax_query; // phpcs:ignore WordPress.DB.SlowDBQuery } $authors = srpp_int_list( $display->opt( 'ex_authors' ) ); if ( $authors ) { $args['author__not_in'] = $authors; } $ids = get_posts( $args ); $out = array(); foreach ( $ids as $id ) { $post = get_post( $id ); if ( $post && ! self::blocked_by_meta( $display, $post ) ) { $out[] = (int) $id; } } return $out; } }