| 1 |
<?php |
| 2 |
|
| 3 |
namespace WPDeveloper\BetterDocs\FrontEnd; |
| 4 |
|
| 5 |
if ( ! defined( 'ABSPATH' ) ) { |
| 6 |
exit; |
| 7 |
} |
| 8 |
|
| 9 |
/** |
| 10 |
* Extends WordPress search SQL on `docs` queries to also match docs whose |
| 11 |
* assigned `doc_tag` or `doc_category` term names contain the search term. |
| 12 |
* |
| 13 |
* Also ranks exact and leading title matches first in those searches. |
| 14 |
* |
| 15 |
* The filters are registered globally so they apply to every search query against |
| 16 |
* the `docs` post type — including WP core's `GET /wp/v2/docs?search=...`, |
| 17 |
* BetterDocs' `/betterdocs/v1/search`, and the shortcode/widget AJAX paths. |
| 18 |
*/ |
| 19 |
class SearchExtender { |
| 20 |
public function __construct() { |
| 21 |
add_filter( 'posts_search', [ $this, 'extend_search' ], 20, 2 ); |
| 22 |
add_filter( 'posts_search_orderby', [ $this, 'rank_title_matches' ], 20, 2 ); |
| 23 |
} |
| 24 |
|
| 25 |
/** |
| 26 |
* Inject an OR clause that matches docs whose related taxonomy term names |
| 27 |
* contain the search term. |
| 28 |
* |
| 29 |
* @param string $search The search SQL clause (already begins with " AND ("). |
| 30 |
* @param \WP_Query $query The WP_Query instance. |
| 31 |
* @return string Modified search SQL. |
| 32 |
*/ |
| 33 |
public function extend_search( $search, $query ) { |
| 34 |
global $wpdb; |
| 35 |
|
| 36 |
if ( empty( $search ) || ! $this->is_docs_query( $query ) ) { |
| 37 |
return $search; |
| 38 |
} |
| 39 |
|
| 40 |
$search_term = isset( $query->query_vars['s'] ) ? (string) $query->query_vars['s'] : ''; |
| 41 |
if ( $search_term === '' ) { |
| 42 |
return $search; |
| 43 |
} |
| 44 |
|
| 45 |
$taxonomies = [ 'doc_tag', 'doc_category' ]; |
| 46 |
$like = '%' . $wpdb->esc_like( $search_term ) . '%'; |
| 47 |
|
| 48 |
$placeholders = implode( ',', array_fill( 0, count( $taxonomies ), '%s' ) ); |
| 49 |
$args = $taxonomies; |
| 50 |
$args[] = $like; |
| 51 |
|
| 52 |
// $placeholders is a generated run of %s tokens bound via $args below; all |
| 53 |
// interpolated identifiers are $wpdb core table names, not user input. |
| 54 |
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 55 |
$subquery = $wpdb->prepare( |
| 56 |
"{$wpdb->posts}.ID IN ( |
| 57 |
SELECT DISTINCT tr.object_id |
| 58 |
FROM {$wpdb->term_relationships} tr |
| 59 |
INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id |
| 60 |
INNER JOIN {$wpdb->terms} t ON tt.term_id = t.term_id |
| 61 |
WHERE tt.taxonomy IN ({$placeholders}) AND t.name LIKE %s |
| 62 |
)", |
| 63 |
$args |
| 64 |
); |
| 65 |
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 66 |
|
| 67 |
return preg_replace( '/^\s*AND\s*\(/', " AND ({$subquery} OR ", $search, 1 ); |
| 68 |
} |
| 69 |
|
| 70 |
/** |
| 71 |
* Rank docs whose title is the search term first, then titles that start |
| 72 |
* with it, ahead of WordPress's own relevance buckets. Equal matches are |
| 73 |
* listed A to Z instead of by publish date, which sorted the oldest partial |
| 74 |
* matches above a newer exact match (betterdocs/betterdocs#182). |
| 75 |
* |
| 76 |
* Only refines WordPress's relevance ordering: when a query asks for an |
| 77 |
* explicit orderby, WordPress passes an empty clause and it is left alone. |
| 78 |
* |
| 79 |
* @param string $search_orderby The relevance ORDER BY clause built by WordPress. |
| 80 |
* @param \WP_Query $query The WP_Query instance. |
| 81 |
* @return string Modified ORDER BY clause. |
| 82 |
*/ |
| 83 |
public function rank_title_matches( $search_orderby, $query ) { |
| 84 |
global $wpdb; |
| 85 |
|
| 86 |
if ( empty( $search_orderby ) || ! $this->is_docs_query( $query ) ) { |
| 87 |
return $search_orderby; |
| 88 |
} |
| 89 |
|
| 90 |
// Match the title the way a visitor typed it: ignore surrounding and |
| 91 |
// repeated whitespace, and a pair of quotes around the whole term. |
| 92 |
$search_term = isset( $query->query_vars['s'] ) ? (string) $query->query_vars['s'] : ''; |
| 93 |
$search_term = trim( preg_replace( '/\s+/u', ' ', $search_term ) ); |
| 94 |
if ( strlen( $search_term ) > 1 && '"' === $search_term[0] && '"' === substr( $search_term, -1 ) ) { |
| 95 |
$search_term = trim( substr( $search_term, 1, -1 ) ); |
| 96 |
} |
| 97 |
if ( $search_term === '' ) { |
| 98 |
return $search_orderby; |
| 99 |
} |
| 100 |
|
| 101 |
// LIKE without a wildcard is a case-insensitive exact match. |
| 102 |
$title_rank = $wpdb->prepare( |
| 103 |
"(CASE WHEN {$wpdb->posts}.post_title LIKE %s THEN 0 WHEN {$wpdb->posts}.post_title LIKE %s THEN 1 ELSE 2 END)", |
| 104 |
$wpdb->esc_like( $search_term ), |
| 105 |
$wpdb->esc_like( $search_term ) . '%' |
| 106 |
); |
| 107 |
|
| 108 |
return "{$title_rank}, {$search_orderby}, {$wpdb->posts}.post_title ASC"; |
| 109 |
} |
| 110 |
|
| 111 |
/** |
| 112 |
* Whether a query searches the `docs` post type. |
| 113 |
* |
| 114 |
* @param \WP_Query $query The WP_Query instance. |
| 115 |
* @return bool |
| 116 |
*/ |
| 117 |
private function is_docs_query( $query ) { |
| 118 |
$post_type = isset( $query->query_vars['post_type'] ) ? $query->query_vars['post_type'] : ''; |
| 119 |
|
| 120 |
return is_array( $post_type ) ? in_array( 'docs', $post_type, true ) : 'docs' === $post_type; |
| 121 |
} |
| 122 |
} |
| 123 |
|