[11160] | 1 | <?php |
---|
| 2 | namespace WordPressdotorg\Plugin_Directory; |
---|
| 3 | |
---|
| 4 | // Hmm |
---|
| 5 | add_filter( 'option_has_jetpack_search_product', '__return_true' ); |
---|
| 6 | |
---|
| 7 | /** |
---|
[11649] | 8 | * Override Jetpack Search class with special features for the Plugin Directory |
---|
| 9 | * |
---|
| 10 | * @package WordPressdotorg\Plugin_Directory |
---|
| 11 | */ |
---|
[11160] | 12 | class Plugin_Search { |
---|
| 13 | |
---|
| 14 | // Set this to true to disable the new class and use the old jetpack-search.php code. |
---|
[11161] | 15 | const USE_OLD_SEARCH = false; |
---|
[11160] | 16 | |
---|
[11649] | 17 | // Internal state - These are all overridden below, but here for reference purposes for a non-block english search. |
---|
| 18 | protected $locale = 'en_US'; |
---|
| 19 | protected $is_block_search = false; |
---|
| 20 | protected $is_english = true; |
---|
| 21 | protected $en_boost = 0.00001; |
---|
| 22 | protected $desc_boost = 1; |
---|
| 23 | protected $desc_en_boost = 0.00001; |
---|
[11160] | 24 | |
---|
| 25 | /** |
---|
| 26 | * Fetch the instance of the Plugin_Search class. |
---|
| 27 | * |
---|
| 28 | * @static |
---|
| 29 | */ |
---|
| 30 | public static function instance() { |
---|
| 31 | static $instance = null; |
---|
| 32 | |
---|
| 33 | if ( ! $instance ) { |
---|
| 34 | $instance = new self(); |
---|
| 35 | } |
---|
| 36 | |
---|
| 37 | return $instance; |
---|
| 38 | } |
---|
| 39 | |
---|
| 40 | /** |
---|
| 41 | * Plugin_Search constructor. |
---|
| 42 | * |
---|
| 43 | * @access private |
---|
| 44 | */ |
---|
| 45 | private function __construct() { |
---|
| 46 | if ( isset( $_GET['s'] ) ) |
---|
| 47 | return false; |
---|
| 48 | |
---|
| 49 | add_action( 'init', array( $this, 'init' ) ); |
---|
| 50 | |
---|
| 51 | return false; |
---|
| 52 | } |
---|
| 53 | |
---|
| 54 | public function init() { |
---|
| 55 | |
---|
| 56 | if ( self::USE_OLD_SEARCH ) { |
---|
| 57 | // Instantiate our copy of the Jetpack_Search class. |
---|
| 58 | if ( class_exists( 'Jetpack' ) && ! class_exists( 'Jetpack_Search' ) |
---|
| 59 | && ! isset( $_GET['s'] ) ) { // Don't run the ES query if we're going to redirect to the pretty search URL |
---|
| 60 | require_once __DIR__ . '/libs/site-search/jetpack-search.php'; |
---|
| 61 | \Jetpack_Search::instance(); |
---|
| 62 | } |
---|
| 63 | } else { |
---|
| 64 | add_filter( 'jetpack_get_module', array( $this, 'jetpack_get_module' ), 10, 2 ); |
---|
| 65 | add_filter( 'option_jetpack_active_modules', array( $this, 'option_jetpack_active_modules' ), 10, 1 ); |
---|
| 66 | add_filter( 'pre_option_has_jetpack_search_product', array( $this, 'option_has_jetpack_search_product' ), 10, 1 ); |
---|
| 67 | |
---|
[11649] | 68 | // add_filter( 'jetpack_search_abort', array( $this, 'log_jetpack_search_abort' ) ); |
---|
[12942] | 69 | |
---|
[12158] | 70 | // $es_query_args = apply_filters( 'jetpack_search_es_query_args', $es_query_args, $query ); |
---|
[11160] | 71 | |
---|
| 72 | add_filter( 'jetpack_search_es_wp_query_args', array( $this, 'jetpack_search_es_wp_query_args' ), 10, 2 ); |
---|
[12942] | 73 | add_filter( 'jetpack_search_es_query_args', array( $this, 'jetpack_search_es_query_args' ), 10, 2 ); |
---|
| 74 | add_filter( 'posts_pre_query', array( $this, 'set_max_num_pages' ), 15, 2 ); // After `Classic_Search::filter__posts_pre_query()` |
---|
[11160] | 75 | |
---|
[12158] | 76 | // Load Jetpack Search. |
---|
| 77 | include_once WP_PLUGIN_DIR . '/jetpack/vendor/autoload_packages.php'; |
---|
| 78 | |
---|
| 79 | if ( class_exists( '\Automattic\Jetpack\Search\Classic_Search' ) ) { |
---|
| 80 | // New Jetpack |
---|
| 81 | \Automattic\Jetpack\Search\Classic_Search::instance(); |
---|
| 82 | |
---|
| 83 | } else { |
---|
| 84 | // Old(er) Jetpack, load the classic search module, Temporarily. |
---|
[12942] | 85 | |
---|
[12158] | 86 | include_once WP_PLUGIN_DIR . '/jetpack/modules/search/class.jetpack-search.php'; |
---|
| 87 | include_once WP_PLUGIN_DIR . '/jetpack/modules/search/class.jetpack-search-helpers.php'; |
---|
| 88 | |
---|
| 89 | \Jetpack_Search::instance()->setup(); |
---|
| 90 | } |
---|
| 91 | |
---|
[11160] | 92 | } |
---|
| 93 | |
---|
| 94 | } |
---|
| 95 | |
---|
| 96 | function var_export($expression, $return=FALSE) { |
---|
| 97 | $export = var_export($expression, TRUE); |
---|
| 98 | $patterns = [ |
---|
| 99 | "/array \(/" => '[', |
---|
| 100 | "/^([ ]*)\)(,?)$/m" => '$1]$2', |
---|
| 101 | "/=>[ ]?\n[ ]+\[/" => '=> [', |
---|
| 102 | "/([ ]*)(\'[^\']+\') => ([\[\'])/" => '$1$2 => $3', |
---|
| 103 | ]; |
---|
| 104 | $export = preg_replace(array_keys($patterns), array_values($patterns), $export); |
---|
| 105 | if ((bool)$return) return $export; else echo $export; |
---|
| 106 | } |
---|
| 107 | |
---|
| 108 | public function option_jetpack_active_modules( $modules ) { |
---|
| 109 | if ( self::USE_OLD_SEARCH ) { |
---|
| 110 | if ( $i = array_search( 'search', $modules ) ) |
---|
| 111 | unset( $modules[$i] ); |
---|
| 112 | } else { |
---|
| 113 | $modules[] = 'search'; |
---|
| 114 | } |
---|
| 115 | |
---|
| 116 | return array_unique( $modules ); |
---|
| 117 | } |
---|
| 118 | |
---|
| 119 | public function option_has_jetpack_search_product( $option ) { |
---|
| 120 | if ( !self::USE_OLD_SEARCH ) { |
---|
| 121 | return true; |
---|
| 122 | } |
---|
| 123 | return $option; |
---|
| 124 | } |
---|
| 125 | |
---|
| 126 | /* Make sure the search module is available regardless of Jetpack plan. |
---|
| 127 | * This works because search indexes were manually created for w.org. |
---|
| 128 | */ |
---|
| 129 | public function jetpack_get_module( $module, $slug ) { |
---|
| 130 | if ( !self::USE_OLD_SEARCH ) { |
---|
| 131 | if ( 'search' === $slug && isset( $module[ 'plan_classes' ] ) && !in_array( 'free', $module[ 'plan_classes' ] ) ) { |
---|
| 132 | $module[ 'plan_classes' ][] = 'free'; |
---|
| 133 | } |
---|
| 134 | } |
---|
| 135 | |
---|
| 136 | return $module; |
---|
| 137 | } |
---|
| 138 | |
---|
[13640] | 139 | |
---|
| 140 | /** |
---|
| 141 | * Localise the ES fields searched for localised queries. |
---|
| 142 | */ |
---|
| 143 | public function localise_es_fields( $fields ) { |
---|
| 144 | $localised_prefixes = [ |
---|
| 145 | 'all_content', |
---|
| 146 | 'title', |
---|
| 147 | 'excerpt', |
---|
| 148 | 'description', |
---|
| 149 | ]; |
---|
| 150 | |
---|
| 151 | $localised_fields = array(); |
---|
| 152 | |
---|
| 153 | foreach ( (array) $fields as $field ) { |
---|
| 154 | // title.ngram |
---|
| 155 | list( $field, $type ) = explode( '.', $field . '.' ); |
---|
| 156 | if ( $type ) { |
---|
| 157 | $type = ".{$type}"; |
---|
| 158 | } |
---|
| 159 | |
---|
| 160 | if ( ! in_array( $field, $localised_prefixes ) ) { |
---|
| 161 | $localised_fields[] = $field . $type; |
---|
| 162 | continue; |
---|
| 163 | } |
---|
| 164 | |
---|
| 165 | if ( $this->is_english ) { |
---|
| 166 | $localised_fields[] = $field . '_en' . $type; |
---|
| 167 | continue; |
---|
| 168 | } |
---|
| 169 | |
---|
| 170 | $boost = ''; |
---|
| 171 | $en_boost = '^' . $this->en_boost; |
---|
| 172 | if ( 'description' === $field ) { |
---|
| 173 | $boost = '^' . $this->desc_boost; |
---|
| 174 | $en_boost = '^' . $this->desc_en_boost; |
---|
| 175 | } |
---|
| 176 | |
---|
| 177 | $localised_fields[] = $field . '_' . $this->locale . $type . $boost; |
---|
| 178 | $localised_fields[] = $field . '_en' . $type . $en_boost; |
---|
| 179 | } |
---|
| 180 | |
---|
| 181 | return $localised_fields; |
---|
| 182 | } |
---|
| 183 | |
---|
[11160] | 184 | public function jetpack_search_es_wp_query_args( $args, $query ) { |
---|
| 185 | |
---|
| 186 | // Block Search. |
---|
| 187 | $this->is_block_search = !empty( $query->query['block_search'] ); |
---|
| 188 | if ( $this->is_block_search ) { |
---|
| 189 | $args['block_search'] = $query->query['block_search']; |
---|
| 190 | } |
---|
| 191 | |
---|
| 192 | // How much weighting to put on the Description field. |
---|
| 193 | // Blocks get a much lower value here, as it's more title/excerpt (short description) based. |
---|
| 194 | $this->desc_boost = $this->is_block_search ? 0.05 : 1; |
---|
| 195 | |
---|
| 196 | // Because most plugins don't have any translations we need to |
---|
| 197 | // correct for the very low scores that locale-specific fields. |
---|
| 198 | // end up getting. This is caused by the average field length being |
---|
| 199 | // very close to zero and thus the BM25 alg discounts fields that are |
---|
| 200 | // significantly longer. |
---|
| 201 | // |
---|
| 202 | // As of 2017-01-23 it looked like we were off by about 10,000x, |
---|
| 203 | // so rather than 0.1 we use a much smaller multiplier of en content |
---|
| 204 | $this->en_boost = 0.00001; |
---|
| 205 | $this->desc_en_boost = $this->desc_boost * $this->en_boost; |
---|
| 206 | |
---|
| 207 | // We need to be locale aware for this |
---|
[11650] | 208 | $this->locale = get_locale(); |
---|
| 209 | $this->is_english = ( ! $this->locale || str_starts_with( $this->locale, 'en_' ) ); |
---|
[11160] | 210 | |
---|
[13640] | 211 | $args['query_fields'] = $this->localise_es_fields( 'all_content' ); |
---|
[11160] | 212 | |
---|
| 213 | return $args; |
---|
| 214 | } |
---|
| 215 | |
---|
| 216 | public function jetpack_search_es_query_args( $es_query_args, $query ) { |
---|
| 217 | // These are the things that jetpack_search_es_wp_query_args doesn't let us change, so we need to filter the es_query_args late in the code path to add more custom stuff. |
---|
| 218 | |
---|
[13654] | 219 | // Replace any existing filter with an AND for our custom filters. |
---|
| 220 | if ( ! isset( $es_query_args['filter']['and'] ) ) { |
---|
| 221 | // 'filter' will either be an `and` or term we need to wrap in an `and`. |
---|
| 222 | $es_query_args['filter'] = [ |
---|
| 223 | 'and' => $es_query_args['filter'] ? [ $es_query_args['filter'] ] : [], |
---|
| 224 | ]; |
---|
| 225 | } |
---|
[13332] | 226 | |
---|
[13654] | 227 | // Exclude 'disabled' plugins. This is separate from the 'status' field, which is used for the plugin status. |
---|
[13652] | 228 | $es_query_args['filter']['and'][] = [ |
---|
| 229 | 'term' => [ |
---|
[13654] | 230 | 'disabled' => false, |
---|
[11160] | 231 | ] |
---|
| 232 | ]; |
---|
| 233 | |
---|
[13654] | 234 | // Limit to the Block Directory. |
---|
[11160] | 235 | if ( $this->is_block_search ) { |
---|
| 236 | $es_query_args['filter']['and'][] = [ |
---|
[13652] | 237 | 'term' => [ |
---|
[13654] | 238 | 'taxonomy.plugin_section.slug' => 'block', |
---|
[11160] | 239 | ] |
---|
| 240 | ]; |
---|
| 241 | } |
---|
| 242 | |
---|
[13640] | 243 | // The should match is where we add the fields to be searched in, and the weighting of them (boost). |
---|
| 244 | $should_match = []; |
---|
| 245 | if ( isset( $es_query_args[ 'query' ][ 'function_score' ][ 'query' ][ 'bool' ][ 'should' ] ) ) { |
---|
| 246 | $should_match = & $es_query_args[ 'query' ][ 'function_score' ][ 'query' ][ 'bool' ][ 'should' ]; |
---|
[11160] | 247 | } |
---|
| 248 | |
---|
[13640] | 249 | $search_phrase = $should_match[0][ 'multi_match' ][ 'query' ] ?? ''; |
---|
| 250 | |
---|
| 251 | // The function score is where calculations on fields occur. |
---|
| 252 | $function_score = []; |
---|
| 253 | if ( isset( $es_query_args[ 'query' ][ 'function_score' ][ 'functions' ] ) ) { |
---|
| 254 | $function_score = & $es_query_args[ 'query' ][ 'function_score' ][ 'functions' ]; |
---|
[11160] | 255 | } |
---|
| 256 | |
---|
[13640] | 257 | // Set boost on the match query, from jetpack_search_es_wp_query_args. |
---|
| 258 | if ( isset( $es_query_args[ 'query' ][ 'function_score' ][ 'query' ][ 'bool' ][ 'must' ][0][ 'multi_match' ] ) ) { |
---|
| 259 | $es_query_args[ 'query' ][ 'function_score' ][ 'query' ][ 'bool' ][ 'must' ][0][ 'multi_match' ][ 'boost' ] = 0.1; |
---|
[11160] | 260 | } |
---|
| 261 | |
---|
[13640] | 262 | // This extends the search to additionally search in the title, excerpt, description and plugin_tags. |
---|
| 263 | if ( isset( $should_match[0][ 'multi_match' ] ) ) { |
---|
| 264 | $should_match[0][ 'multi_match' ][ 'boost' ] = 2; |
---|
| 265 | $should_match[0][ 'multi_match' ][ 'fields' ] = $this->localise_es_fields( [ |
---|
| 266 | 'title', |
---|
| 267 | 'excerpt', |
---|
| 268 | 'description', |
---|
[11160] | 269 | 'taxonomy.plugin_tags.name', |
---|
| 270 | ] ); |
---|
| 271 | } |
---|
| 272 | |
---|
[13640] | 273 | // Setup the boosting for various fields. |
---|
| 274 | $should_match[] = [ |
---|
| 275 | 'multi_match' => [ |
---|
| 276 | 'query' => $search_phrase, |
---|
| 277 | 'fields' => $this->localise_es_fields( [ 'title.ngram' ] ), |
---|
| 278 | 'type' => 'phrase', |
---|
| 279 | 'boost' => 2, |
---|
| 280 | ], |
---|
| 281 | ]; |
---|
[11160] | 282 | |
---|
[13640] | 283 | // A direct slug match |
---|
| 284 | $should_match[] = [ |
---|
| 285 | 'multi_match' => [ |
---|
| 286 | 'query' => $search_phrase, |
---|
| 287 | 'fields' => $this->localise_es_fields( 'title', 'slug_text' ), |
---|
| 288 | 'type' => 'most_fields', |
---|
| 289 | 'boost' => 5, |
---|
| 290 | ], |
---|
| 291 | ]; |
---|
[11160] | 292 | |
---|
[13640] | 293 | $should_match[] = [ |
---|
| 294 | 'multi_match' => [ |
---|
| 295 | 'query' => $search_phrase, |
---|
| 296 | 'fields' => $this->localise_es_fields( [ |
---|
| 297 | 'excerpt', |
---|
| 298 | 'description', |
---|
[11160] | 299 | 'taxonomy.plugin_tags.name', |
---|
[13640] | 300 | ] ), |
---|
| 301 | 'type' => 'best_fields', |
---|
| 302 | 'boost' => 2, |
---|
| 303 | ], |
---|
| 304 | ]; |
---|
[11160] | 305 | |
---|
[13640] | 306 | $should_match[] = [ |
---|
| 307 | 'multi_match' => [ |
---|
| 308 | 'query' => $search_phrase, |
---|
| 309 | 'fields' => $this->localise_es_fields( [ |
---|
| 310 | 'author', |
---|
| 311 | 'contributor' |
---|
| 312 | ] ), |
---|
| 313 | 'type' => 'best_fields', |
---|
| 314 | 'boost' => 3, |
---|
| 315 | ], |
---|
| 316 | ]; |
---|
[11160] | 317 | |
---|
[13640] | 318 | // We'll overwrite the default Jetpack Search function scoring with our own. |
---|
| 319 | $function_score = [ |
---|
| 320 | [ |
---|
| 321 | // The more recent a plugin was updated, the more relevant it is. |
---|
| 322 | 'exp' => [ |
---|
[11160] | 323 | 'plugin_modified' => [ |
---|
[13640] | 324 | 'origin' => date('Y-m-d'), |
---|
| 325 | 'offset' => '180d', |
---|
| 326 | 'scale' => '360d', |
---|
| 327 | 'decay' => 0.5, |
---|
[11160] | 328 | ], |
---|
[13640] | 329 | ] |
---|
| 330 | ], |
---|
| 331 | [ |
---|
| 332 | // The older a plugins tested-up-to is, the less likely it's relevant. |
---|
| 333 | 'exp' => [ |
---|
[11160] | 334 | 'tested' => [ |
---|
[13641] | 335 | 'origin' => sprintf( '%0.1f', defined( 'WP_CORE_STABLE_BRANCH' ) ? WP_CORE_STABLE_BRANCH : $GLOBALS['wp_version'] ), |
---|
[13640] | 336 | 'offset' => 0.1, |
---|
| 337 | 'scale' => 0.4, |
---|
| 338 | 'decay' => 0.6, |
---|
[11160] | 339 | ], |
---|
| 340 | ], |
---|
[13640] | 341 | ], |
---|
| 342 | [ |
---|
| 343 | // A higher install base is a sign that the plugin will be relevant to the searcher. |
---|
| 344 | 'field_value_factor' => [ |
---|
| 345 | 'field' => 'active_installs', |
---|
| 346 | 'factor' => 0.375, |
---|
[11160] | 347 | 'modifier' => 'log2p', |
---|
[13640] | 348 | 'missing' => 1, |
---|
[11160] | 349 | ], |
---|
[13640] | 350 | ], |
---|
| 351 | [ |
---|
| 352 | // For plugins with less than 1 million installs, we need to adjust their scores a bit more. |
---|
| 353 | 'filter' => [ |
---|
[11160] | 354 | 'range' => [ |
---|
[13640] | 355 | 'active_installs' => [ |
---|
| 356 | 'lte' => 1000000, |
---|
| 357 | ], |
---|
| 358 | ], |
---|
[11160] | 359 | ], |
---|
[13640] | 360 | 'exp' => [ |
---|
[11160] | 361 | 'active_installs' => [ |
---|
[13640] | 362 | 'origin' => 1000000, |
---|
| 363 | 'offset' => 0, |
---|
| 364 | 'scale' => 900000, |
---|
| 365 | 'decay' => 0.75, |
---|
[11160] | 366 | ], |
---|
| 367 | ], |
---|
[13640] | 368 | ], |
---|
| 369 | [ |
---|
| 370 | // The more resolved support threads (as a percentage) a plugin has, the more responsive the developer is, and the better experience the end-user will have. |
---|
| 371 | 'field_value_factor' => [ |
---|
| 372 | 'field' => 'support_threads_resolved', |
---|
| 373 | 'factor' => 0.25, |
---|
[11160] | 374 | 'modifier' => 'log2p', |
---|
[13640] | 375 | 'missing' => 0.5, |
---|
[11160] | 376 | ], |
---|
[13640] | 377 | ], |
---|
| 378 | [ |
---|
| 379 | // A higher rated plugin is more likely to be preferred. |
---|
| 380 | 'field_value_factor' => [ |
---|
| 381 | 'field' => 'rating', |
---|
| 382 | 'factor' => 0.25, |
---|
[11160] | 383 | 'modifier' => 'sqrt', |
---|
[13640] | 384 | 'missing' => 2.5, |
---|
[11160] | 385 | ], |
---|
[13640] | 386 | ], |
---|
| 387 | ]; |
---|
[11160] | 388 | |
---|
[13642] | 389 | unset( $es_query_args[ 'query' ][ 'function_score' ][ 'max_boost' ] ); |
---|
[11160] | 390 | unset( $es_query_args[ 'query' ][ 'function_score' ][ 'score_mode' ] ); |
---|
| 391 | |
---|
| 392 | // Couple of extra fields wanted in the response, mainly for debugging |
---|
| 393 | $es_query_args[ 'fields' ] = [ |
---|
[13640] | 394 | 'slug', |
---|
| 395 | 'post_id', |
---|
[11160] | 396 | ]; |
---|
| 397 | |
---|
| 398 | return $es_query_args; |
---|
| 399 | } |
---|
| 400 | |
---|
[12942] | 401 | /** |
---|
| 402 | * Limit the number of pagination links to 50. |
---|
| 403 | * |
---|
| 404 | * Jetpack ignores the `max_num_pages` that's set in `WP_Query` args and overrides it in |
---|
| 405 | * `Classic_Search::filter__posts_pre_query()`. When there are more than 1,000 matches, their value causes |
---|
| 406 | * Core's `paginate_links()` to generated 51 links, even though we redirect the user to the homepage when |
---|
| 407 | * page 51+ is requested. |
---|
| 408 | */ |
---|
| 409 | function set_max_num_pages( $posts, $query ) { |
---|
| 410 | $post_type = (array) $query->query_vars['post_type'] ?? ''; |
---|
| 411 | |
---|
| 412 | if ( is_admin() || ! is_search() || ! in_array( 'plugin', $post_type ) ) { |
---|
| 413 | return $posts; |
---|
| 414 | } |
---|
| 415 | |
---|
| 416 | if ( $query->max_num_pages > 50 ) { |
---|
| 417 | $query->max_num_pages = 50; |
---|
| 418 | } |
---|
| 419 | |
---|
| 420 | return $posts; |
---|
| 421 | } |
---|
| 422 | |
---|
[11160] | 423 | public function log_search_es_wp_query_args( $es_wp_query_args, $query ) { |
---|
| 424 | error_log( '--- ' . __FUNCTION__ . ' ---' ); |
---|
| 425 | error_log( $this->var_export( $es_wp_query_args, true ) ); |
---|
| 426 | |
---|
| 427 | return $es_wp_query_args; |
---|
| 428 | } |
---|
| 429 | |
---|
| 430 | public function log_jetpack_search_abort( $reason ) { |
---|
| 431 | error_log( "--- jetpack_search_abort $reason ---" ); |
---|
| 432 | } |
---|
| 433 | |
---|
| 434 | public function log_did_jetpack_search_query( $query ) { |
---|
| 435 | error_log( '--- did_jetpack_search_query ---' ); |
---|
| 436 | error_log( $this->var_export( $query, true ) ); |
---|
| 437 | } |
---|
| 438 | } |
---|