diff --git a/includes/classes/Feature/Search/Search.php b/includes/classes/Feature/Search/Search.php index 1cf1885dd..8ca95e39a 100644 --- a/includes/classes/Feature/Search/Search.php +++ b/includes/classes/Feature/Search/Search.php @@ -62,6 +62,7 @@ public function __construct() { 'highlight_enabled' => '0', 'highlight_excerpt' => '0', 'highlight_tag' => 'mark', + 'keyword_boosts' => '', ]; $this->available_during_installation = true; @@ -117,6 +118,7 @@ public function setup() { public function search_setup() { add_filter( 'ep_elasticpress_enabled', [ $this, 'integrate_search_queries' ], 10, 2 ); add_filter( 'ep_formatted_args', [ $this, 'weight_recent' ], 11, 2 ); + add_filter( 'ep_formatted_args', [ $this, 'apply_keyword_boosts' ], 25, 2 ); add_filter( 'ep_query_post_type', [ $this, 'filter_query_post_type_for_search' ], 10, 2 ); add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_scripts' ] ); @@ -730,6 +732,177 @@ public function output_exclude_from_search_setting( $post ) { normalize_keyword_boosts( $settings['keyword_boosts'] ); + } + + return $settings; + } + + /** + * Parse and normalize keyword:boost pairs. + * + * @since 5.3.4 + * @param string $raw Raw textarea value. + * @return string Normalized line-separated keyword:boost pairs. + */ + protected function normalize_keyword_boosts( $raw ) { + $boosts = $this->parse_keyword_boosts( $raw ); + $lines = array(); + + foreach ( $boosts as $keyword => $boost ) { + $lines[] = $keyword . ':' . $boost; + } + + return implode( "\n", $lines ); + } + + /** + * Parse keyword:boost pairs from a string. + * + * @since 5.3.4 + * @param string $raw Raw textarea value. + * @return array Associative array of keyword => boost. + */ + protected function parse_keyword_boosts( $raw ) { + $parsed = array(); + + if ( empty( $raw ) ) { + return $parsed; + } + + $lines = preg_split( '/\r\n|\r|\n/', $raw ); + + foreach ( $lines as $line ) { + $line = trim( $line ); + + if ( empty( $line ) || false === strpos( $line, ':' ) ) { + continue; + } + + $parts = explode( ':', $line, 2 ); + $keyword = sanitize_text_field( trim( $parts[0] ) ); + $boost = trim( $parts[1] ); + + if ( '' === $keyword || '' === $boost ) { + continue; + } + + $boost = floatval( $boost ); + + if ( $boost <= 0 || $boost > 100 ) { + continue; + } + + $parsed[ $keyword ] = $boost; + } + + return $parsed; + } + + /** + * Get keyword boosts for the current search query. + * + * @since 5.3.4 + * @return array Associative array of keyword => boost. + */ + protected function get_keyword_boosts() { + $settings = $this->get_settings(); + $raw = isset( $settings['keyword_boosts'] ) ? $settings['keyword_boosts'] : ''; + $boosts = $this->parse_keyword_boosts( $raw ); + + /** + * Filter keyword boosts. + * + * @since 5.3.4 + * @hook ep_search_keyword_boosts + * @param array $boosts Keyword => boost pairs. + * @return array + */ + return apply_filters( 'ep_search_keyword_boosts', $boosts ); + } + + /** + * Apply keyword boosts to the Elasticsearch query. + * + * @since 5.3.4 + * @param array $formatted_args Formatted ES args. + * @param array $args WP_Query args. + * @return array + */ + public function apply_keyword_boosts( $formatted_args, $args ) { + if ( empty( $args['s'] ) ) { + return $formatted_args; + } + + $boosts = $this->get_keyword_boosts(); + + if ( empty( $boosts ) ) { + return $formatted_args; + } + + $default_search_fields = array( + 'post_title', + 'post_excerpt', + 'post_content', + ); + + /** + * Filter default post search fields + * + * @hook ep_search_fields + * @param array $fields Default search fields + * @param array $args WP Query args + * @return array + */ + $search_fields = apply_filters( 'ep_search_fields', $default_search_fields, $args ); + + if ( empty( $search_fields ) ) { + return $formatted_args; + } + + if ( ! isset( $formatted_args['query'] ) ) { + return $formatted_args; + } + + $query = &$formatted_args['query']; + + if ( isset( $query['function_score']['query'] ) ) { + $query = &$query['function_score']['query']; + } + + if ( ! isset( $query['bool']['should'] ) ) { + $existing_query = $query; + $query = array( + 'bool' => array( + 'must' => array( $existing_query ), + 'should' => array(), + ), + ); + } + + foreach ( $boosts as $keyword => $boost ) { + $query['bool']['should'][] = array( + 'multi_match' => array( + 'query' => $keyword, + 'type' => 'phrase', + 'fields' => $search_fields, + 'boost' => (float) $boost, + ), + ); + } + + return $formatted_args; + } + /** * Saves exclude from search meta. * @@ -871,6 +1044,13 @@ protected function set_settings_schema() { 'key' => 'synonyms_editor_mode', 'type' => 'hidden', ], + [ + 'default' => '', + 'help' => __( 'Boost specific keywords in search results. Enter one keyword per line, followed by a colon and a boost number, e.g. premium:5. Boosts must be between 0.01 and 100.', 'elasticpress' ), + 'key' => 'keyword_boosts', + 'label' => __( 'Keyword boosts', 'elasticpress' ), + 'type' => 'textarea', + ], ]; if ( ! defined( 'EP_IS_NETWORK' ) || ! EP_IS_NETWORK ) { diff --git a/tests/php/features/TestSearch.php b/tests/php/features/TestSearch.php index 421ad5073..c6712c18a 100644 --- a/tests/php/features/TestSearch.php +++ b/tests/php/features/TestSearch.php @@ -45,6 +45,13 @@ public function tear_down() { parent::tear_down(); $this->fired_actions = array(); + + ElasticPress\Features::factory()->update_feature( + 'search', + array( + 'keyword_boosts' => '', + ) + ); } /** @@ -346,11 +353,221 @@ public function test_get_settings_schema() { $settings_keys = wp_list_pluck( $settings_schema, 'key' ); - $expected = [ 'active', 'decaying_enabled', 'highlight_enabled', 'highlight_excerpt', 'highlight_tag', 'synonyms_editor_mode' ]; + $expected = [ 'active', 'decaying_enabled', 'highlight_enabled', 'highlight_excerpt', 'highlight_tag', 'synonyms_editor_mode', 'keyword_boosts' ]; if ( ! is_multisite() ) { $expected[] = 'additional_links'; } $this->assertSame( $expected, $settings_keys ); } + + /** + * Test keyword boosts are injected into the ES query when date decay is enabled. + * + * @since 5.3.4 + * @group search + */ + public function testKeywordBoostsInjectedWithDecay() { + ElasticPress\Features::factory()->activate_feature( 'search' ); + ElasticPress\Features::factory()->setup_features(); + ElasticPress\Features::factory()->get_registered_feature( 'search' )->search_setup(); + + ElasticPress\Features::factory()->update_feature( + 'search', + array( + 'active' => true, + 'keyword_boosts' => "premium:5\nsale:3.5\n", + ) + ); + + add_filter( 'ep_formatted_args', array( $this, 'catch_ep_formatted_args' ), 25 ); + + $query = new \WP_Query( + array( + 's' => 'test', + ) + ); + + $this->assertTrue( isset( $this->fired_actions['ep_formatted_args'] ) ); + + $es_query = $this->fired_actions['ep_formatted_args']['query']; + $this->assertTrue( isset( $es_query['function_score']['query']['bool']['should'] ) ); + + $shoulds = $es_query['function_score']['query']['bool']['should']; + $found = 0; + + foreach ( $shoulds as $should ) { + if ( ! isset( $should['multi_match']['query'] ) ) { + continue; + } + + if ( 'premium' === $should['multi_match']['query'] && 5.0 === $should['multi_match']['boost'] ) { + ++$found; + } + + if ( 'sale' === $should['multi_match']['query'] && 3.5 === $should['multi_match']['boost'] ) { + ++$found; + } + } + + $this->assertSame( 2, $found ); + } + + /** + * Test keyword boosts are injected into the ES query when date decay is disabled. + * + * @since 5.3.4 + * @group search + */ + public function testKeywordBoostsInjectedWithoutDecay() { + ElasticPress\Features::factory()->activate_feature( 'search' ); + ElasticPress\Features::factory()->setup_features(); + ElasticPress\Features::factory()->get_registered_feature( 'search' )->search_setup(); + + ElasticPress\Features::factory()->update_feature( + 'search', + array( + 'active' => true, + 'decaying_enabled' => false, + 'keyword_boosts' => "premium:5\n", + ) + ); + + add_filter( 'ep_formatted_args', array( $this, 'catch_ep_formatted_args' ), 25 ); + + $query = new \WP_Query( + array( + 's' => 'test', + ) + ); + + $this->assertTrue( isset( $this->fired_actions['ep_formatted_args'] ) ); + + $es_query = $this->fired_actions['ep_formatted_args']['query']; + $this->assertTrue( isset( $es_query['bool']['should'] ) ); + $this->assertFalse( isset( $es_query['function_score'] ) ); + + $found = false; + foreach ( $es_query['bool']['should'] as $should ) { + if ( isset( $should['multi_match']['query'] ) && 'premium' === $should['multi_match']['query'] && 5.0 === $should['multi_match']['boost'] ) { + $found = true; + break; + } + } + + $this->assertTrue( $found ); + } + + /** + * Test keyword boosts are not injected when empty. + * + * @since 5.3.4 + * @group search + */ + public function testKeywordBoostsNotInjectedWhenEmpty() { + ElasticPress\Features::factory()->activate_feature( 'search' ); + ElasticPress\Features::factory()->setup_features(); + ElasticPress\Features::factory()->get_registered_feature( 'search' )->search_setup(); + + ElasticPress\Features::factory()->update_feature( + 'search', + array( + 'active' => true, + 'decaying_enabled' => false, + 'keyword_boosts' => '', + ) + ); + + add_filter( 'ep_formatted_args', array( $this, 'catch_ep_formatted_args' ), 25 ); + + $query = new \WP_Query( + array( + 's' => 'test', + ) + ); + + $this->assertTrue( isset( $this->fired_actions['ep_formatted_args'] ) ); + + $es_query = $this->fired_actions['ep_formatted_args']['query']; + $boost_clauses = 0; + $other_clauses = 0; + + foreach ( $es_query['bool']['should'] as $should ) { + if ( isset( $should['multi_match']['query'] ) ) { + ++$boost_clauses; + } else { + ++$other_clauses; + } + } + + $this->assertSame( 0, $boost_clauses ); + $this->assertGreaterThan( 0, $other_clauses ); + } + + /** + * Test keyword boost parsing and sanitization. + * + * @since 5.3.4 + * @group search + */ + public function testKeywordBoostsSanitization() { + ElasticPress\Features::factory()->activate_feature( 'search' ); + ElasticPress\Features::factory()->setup_features(); + $search = ElasticPress\Features::factory()->get_registered_feature( 'search' ); + + $normalized = $search->sanitize_settings_callback( + array( + 'keyword_boosts' => "premium:5\nno-colon\n sale:0 \n valid:2.5 \nbig:101\nnegative:-3\n", + ) + ); + + $this->assertSame( "premium:5\nvalid:2.5", $normalized['keyword_boosts'] ); + } + + /** + * Test keyword boosts filter allows programmatic override. + * + * @since 5.3.4 + * @group search + */ + public function testKeywordBoostsFilter() { + ElasticPress\Features::factory()->activate_feature( 'search' ); + ElasticPress\Features::factory()->setup_features(); + ElasticPress\Features::factory()->get_registered_feature( 'search' )->search_setup(); + + ElasticPress\Features::factory()->update_feature( + 'search', + array( + 'active' => true, + 'keyword_boosts' => '', + ) + ); + + add_filter( + 'ep_search_keyword_boosts', + function () { + return array( 'filterterm' => 7 ); + } + ); + + add_filter( 'ep_formatted_args', array( $this, 'catch_ep_formatted_args' ), 25 ); + + $query = new \WP_Query( + array( + 's' => 'test', + ) + ); + + $es_query = $this->fired_actions['ep_formatted_args']['query']; + $shoulds = isset( $es_query['function_score']['query']['bool']['should'] ) ? $es_query['function_score']['query']['bool']['should'] : $es_query['bool']['should']; + $found = false; + foreach ( $shoulds as $should ) { + if ( isset( $should['multi_match']['query'] ) && 'filterterm' === $should['multi_match']['query'] && 7.0 === $should['multi_match']['boost'] ) { + $found = true; + break; + } + } + + $this->assertTrue( $found ); + } }