publish`, so * anything unpublished since the last sync is gone by the time it renders. */ final class Search { /** * How many hits to ask for: enough to fill a few pages after resolution. */ private const TOP_K = 60; /** * What this request did, for the one statistics row it records. * * @var array{keys:string, source:string, results:int}|null */ private ?array $recorded = null; /** * Set when Sharely could not answer and core is about to. * * @var bool */ private bool $fell_back = false; /** * The query this instance has already answered, so it answers it once. * * @var \WP_Query|null */ private ?\WP_Query $handled = null; /** * Constructor. * * @param Connection $connection Which workspace. */ public function __construct( private Connection $connection ) {} /** * Hooks into the main query. */ public function register(): void { add_filter( 'posts_pre_query', array( $this, 'answer' ), 10, 2 ); add_filter( 'found_posts', array( $this, 'found_posts' ), 10, 2 ); } /** * Whether this plugin should answer this query. * * @param \WP_Query $query The query about to run. */ private function mine( \WP_Query $query ): bool { if ( is_admin() || wp_doing_ajax() || wp_doing_cron() ) { return false; } if ( ! $query->is_search() || ! $query->is_main_query() ) { return false; } if ( ! $this->connection->is_connected() ) { return false; } if ( ! (bool) $this->connection->get( 'search_enabled', true ) ) { return false; } return '' !== trim( (string) $query->get( 's' ) ); } /** * Replaces the main search query's results, or leaves them alone. * * Returning null is how this filter says "carry on" — every failure path * here does that, and WordPress runs the query it was going to run. * * @param \WP_Post[]|int[]|null $posts The short-circuit, if any. * @param \WP_Query $query The query about to run. * * @return \WP_Post[]|int[]|null */ public function answer( $posts, $query ) { if ( ! $query instanceof \WP_Query || ! $this->mine( $query ) ) { return $posts; } // WordPress runs the main query more than once on some routes. Answer // the first and leave the rest to whatever asked for them. if ( $this->handled === $query ) { return $posts; } $this->handled = $query; $keys = trim( (string) $query->get( 's' ) ); $found = $this->results( $keys ); if ( ! $found['ok'] ) { return $this->core( $keys ); } $ids = $found['ids']; if ( array() === $ids ) { // Nothing matched the sync map. That is a real "no results" only // if the workspace really had nothing; a map that has not caught // up looks the same from here, and core is the safer answer. return $found['empty'] ? $this->none( $keys, $found['source'], $query ) : $this->core( $keys ); } $this->recorded = array( 'keys' => $keys, 'source' => $found['source'], 'results' => count( $ids ), ); $this->record(); $query->found_posts = count( $ids ); $query->max_num_pages = (int) ceil( count( $ids ) / max( 1, $this->per_page( $query ) ) ); return $this->page( $ids, $query ); } /** * This site's posts for a query, ranked by the workspace. * * The search half on its own, with no WordPress query around it, so the * main search page and the Sharely Search block can share one * implementation instead of drifting into two. * * `empty` distinguishes the two ways of coming back with nothing: the * workspace genuinely had no match, or it had matches this site could not * place. They look the same from here and they mean opposite things — * the first is an honest empty page, the second is a sync map that has not * caught up, where core is the better answer. * * @param string $keys What the visitor typed. * * @return array{ok:bool, ids:int[], source:string, empty:bool} */ public function results( string $keys ): array { $nothing = array( 'ok' => false, 'ids' => array(), 'source' => 'core', 'empty' => false, ); $keys = trim( $keys ); if ( '' === $keys || ! $this->connection->is_connected() ) { return $nothing; } $token = ( new VisitorToken( $this->connection ) )->get(); if ( ! $token['ok'] ) { return $nothing; } $found = ( new SearchClient( $this->connection ) )->search( $keys, $token['token'], self::TOP_K, self::language() ); if ( ! $found['ok'] ) { return $nothing; } return array( 'ok' => true, 'ids' => self::readable( $this->resolve( $found['hits'] ) ), 'source' => $found['source'], 'empty' => array() === $found['hits'], ); } /** * Hands the query back to WordPress, noting that core is answering. * * @param string $keys What was searched for. * * @return null */ private function core( string $keys ) { $this->fell_back = true; $this->recorded = array( 'keys' => $keys, 'source' => 'core', 'results' => 0, ); return null; } /** * An empty result that is genuinely empty, rather than a fallback. * * @param string $keys What was searched for. * @param string $source Which engine said so. * @param \WP_Query $query The query. * * @return array{} */ private function none( string $keys, string $source, \WP_Query $query ): array { $this->recorded = array( 'keys' => $keys, 'source' => $source, 'results' => 0, ); $this->record(); $query->found_posts = 0; $query->max_num_pages = 0; return array(); } /** * The page of posts this request asked for, in the order Sharely ranked them. * * Re-fetched through WordPress rather than trusted from the map: a post * unpublished, trashed or made private since the last sync must not appear, * and only WordPress knows that. * * @param int[] $ids Post ids in rank order. * @param \WP_Query $query The query. * * @return \WP_Post[]|int[] */ public function page( array $ids, \WP_Query $query ): array { $per_page = $this->per_page( $query ); $paged = max( 1, (int) $query->get( 'paged' ) ); $slice = array_slice( $ids, ( $paged - 1 ) * $per_page, $per_page ); if ( array() === $slice ) { return array(); } $posts = get_posts( array( 'post__in' => $slice, 'orderby' => 'post__in', 'posts_per_page' => count( $slice ), 'post_type' => $this->connection->post_types(), // Vetted by readable() already; a private post here is one // this visitor is entitled to. 'post_status' => array( 'publish', 'private' ), 'has_password' => false, 'ignore_sticky_posts' => true, 'no_found_rows' => true, ) ); return 'ids' === $query->get( 'fields' ) ? wp_list_pluck( $posts, 'ID' ) : $posts; } /** * Hits to post ids, in rank order, without repeats. * * A hit is a chunk: its own id is the vector's, and the knowledge id this * plugin stored is in the chunk's metadata. Both are tried, then the * source URL — so a change to the shape degrades the ranking rather than * blanking the page. * * @param array> $hits What came back. * * @return int[] */ private function resolve( array $hits ): array { $knowledge_ids = array(); foreach ( $hits as $hit ) { $meta = is_array( $hit['metadata'] ?? null ) ? $hit['metadata'] : array(); foreach ( array( $meta['knowledgeId'] ?? '', $hit['id'] ?? '' ) as $candidate ) { $candidate = (string) $candidate; if ( '' !== $candidate ) { $knowledge_ids[] = $candidate; } } } $map = self::posts_by_knowledge_id( array_values( array_unique( $knowledge_ids ) ) ); $ids = array(); $seen = array(); foreach ( $hits as $hit ) { $meta = is_array( $hit['metadata'] ?? null ) ? $hit['metadata'] : array(); $post_id = $map[ (string) ( $meta['knowledgeId'] ?? '' ) ] ?? $map[ (string) ( $hit['id'] ?? '' ) ] ?? self::post_from_url( (string) ( $meta['sourceUrl'] ?? '' ) ); if ( ! $post_id || isset( $seen[ $post_id ] ) ) { continue; } $seen[ $post_id ] = true; $ids[] = $post_id; } return $ids; } /** * Only the posts this visitor may actually read, in the order given. * * The platform has already filtered by role — but it filtered against the * roles written on an item at the last sync, and WordPress is the only * thing that knows what is true now. A post made private an hour ago is * still tagged for anonymous visitors until the next sync catches it, and * this is what stops that hour being a leak. * * Filtered before the results are counted, so the total on the page is the * number of results the page actually has. * * @param int[] $ids Post ids in rank order. * * @return int[] */ private static function readable( array $ids ): array { if ( array() === $ids ) { return array(); } // One query for the lot, so readability does not cost a query a post. get_posts( array( 'post__in' => $ids, 'posts_per_page' => count( $ids ), 'post_type' => 'any', 'post_status' => array( 'publish', 'private' ), 'ignore_sticky_posts' => true, 'no_found_rows' => true, 'update_post_term_cache' => false, ) ); return array_values( array_filter( $ids, static fn( $id ) => Access::visitor_can_read( (int) $id ) ) ); } /** * The sync map, for the ids these hits actually name. * * One query rather than one per hit, and only the rows asked for — the map * on a large site is every post it has ever sent. * * @param string[] $knowledge_ids Ids to look up. * * @return array Knowledge id to post id. */ public static function posts_by_knowledge_id( array $knowledge_ids ): array { if ( array() === $knowledge_ids ) { return array(); } global $wpdb; $placeholders = implode( ', ', array_fill( 0, count( $knowledge_ids ), '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- prepared below; placeholders cannot be a parameter. $rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- the list is placeholders, built above. "SELECT meta_value AS knowledge_id, post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value IN ( {$placeholders} )", array_merge( array( Sync::META_ID ), $knowledge_ids ) ), ARRAY_A ); $map = array(); foreach ( (array) $rows as $row ) { $map[ (string) $row['knowledge_id'] ] = (int) $row['post_id']; } return $map; } /** * A post id from a URL this site served, when the map has nothing. * * @param string $url The hit's source URL. */ private static function post_from_url( string $url ): int { if ( '' === $url ) { return 0; } return (int) url_to_postid( $url ); } /** * Corrects the total when this plugin answered. * * @param int $found The count WordPress worked out. * @param \WP_Query $query The query. */ public function found_posts( $found, $query ) { if ( ! $query instanceof \WP_Query || $this->handled !== $query ) { return $found; } if ( $this->fell_back ) { // Core answered, so only core knows how many. Recorded here rather // than above for that reason. if ( null !== $this->recorded ) { $this->recorded['results'] = (int) $found; $this->record(); } return $found; } return isset( $query->found_posts ) ? (int) $query->found_posts : $found; } /** * Records the search, once. */ private function record(): void { if ( null === $this->recorded ) { return; } $row = $this->recorded; $this->recorded = null; ( new Stats( $this->connection ) )->record( $row['keys'], $row['results'], $row['source'] ); } /** * Posts per page for this query. * * @param \WP_Query $query The query. */ private function per_page( \WP_Query $query ): int { $per_page = (int) $query->get( 'posts_per_page' ); if ( $per_page < 1 ) { $per_page = (int) get_option( 'posts_per_page', 10 ); } return max( 1, $per_page ); } /** * The language to search in, as the platform spells it. */ private static function language(): string { $locale = (string) get_locale(); $short = strtolower( substr( '' !== $locale ? $locale : 'en_US', 0, 2 ) ); return '' !== $short ? $short : 'en'; } }