diff --git a/elasticpress.php b/elasticpress.php index 4bf9e3de47..103afcf278 100644 --- a/elasticpress.php +++ b/elasticpress.php @@ -116,6 +116,10 @@ function register_indexable_posts() { new Feature\Autosuggest\Autosuggest() ); + Features::factory()->register_feature( + new Feature\DidYouMean\DidYouMean() + ); + Features::factory()->register_feature( new Feature\WooCommerce\WooCommerce() ); diff --git a/includes/classes/Elasticsearch.php b/includes/classes/Elasticsearch.php index 1b2e1db7c9..c72130ba62 100644 --- a/includes/classes/Elasticsearch.php +++ b/includes/classes/Elasticsearch.php @@ -468,6 +468,7 @@ public function query( $index, $type, $query, $query_args, $query_object = null 'found_documents' => $total_hits, 'documents' => $documents, 'aggregations' => $response['aggregations'] ?? [], + 'suggest' => $response['suggest'] ?? [], ], $response, $query, diff --git a/includes/classes/Feature/DidYouMean/DidYouMean.php b/includes/classes/Feature/DidYouMean/DidYouMean.php new file mode 100644 index 0000000000..4d5e6d8aae --- /dev/null +++ b/includes/classes/Feature/DidYouMean/DidYouMean.php @@ -0,0 +1,406 @@ +slug = 'did-you-mean'; + + $this->title = esc_html__( 'Did You Mean', 'elasticpress' ); + + $this->summary = __( 'Recommend alternative search terms for misspelled queries or terms with no results.', 'elasticpress' ); + + $this->requires_install_reindex = true; + + $this->available_during_installation = true; + + $this->default_settings = [ + 'search_behavior' => false, + ]; + + parent::__construct(); + } + + /** + * Setup search functionality. + * + * @return void + */ + public function setup() { + add_filter( 'ep_post_mapping', [ $this, 'add_mapping' ] ); + add_filter( 'ep_post_formatted_args', [ $this, 'add_query_args' ], 10, 3 ); + add_filter( 'ep_integrate_search_queries', [ $this, 'set_ep_suggestion' ], 10, 2 ); + add_action( 'template_redirect', [ $this, 'automatically_redirect_user' ] ); + add_action( 'ep_suggestions', [ $this, 'the_output' ] ); + } + + /** + * Output feature box long. + * + * @return void + */ + public function output_feature_box_long() { + ?> +

+ 'shingle', + 'min_shingle_size' => 2, + 'max_shingle_size' => 3, + ]; + + // Custom analyzer. + $mapping['settings']['analysis']['analyzer']['trigram'] = [ + 'type' => 'custom', + 'tokenizer' => 'standard', + 'filter' => [ + 'lowercase', + 'shingle_filter', + ], + ]; + + if ( version_compare( Elasticsearch::factory()->get_elasticsearch_version(), '7.0', '<' ) ) { + $mapping['mappings']['post']['properties']['post_content']['fields'] = [ + 'shingle' => [ + 'type' => 'text', + 'analyzer' => 'trigram', + ], + ]; + } else { + $mapping['mappings']['properties']['post_content']['fields'] = [ + 'shingle' => [ + 'type' => 'text', + 'analyzer' => 'trigram', + ], + ]; + } + + return $mapping; + } + + /** + * Return the suggested search term. + * + * @param WP_Query $query WP_Query object + * @return string|false + */ + public function get_suggestion( $query = null ) { + global $wp_query; + + $settings = $this->get_settings(); + if ( empty( $settings['active'] ) ) { + return false; + } + + if ( ! $query && $wp_query->is_main_query() && $wp_query->is_search() ) { + $query = $wp_query; + } + + if ( ! is_a( $query, '\WP_Query' ) ) { + return false; + } + + $term = $this->get_suggested_term( $query ); + if ( empty( $term ) ) { + return false; + } + + $html = sprintf( '%s: %s?', esc_html__( 'Did you mean', 'elasticpress' ), get_search_link( $term ), $term ); + + $html .= $this->get_alternatives_terms( $query ); + $terms = $query->suggested_terms['options'] ?? []; + + /** + * Filter the did you mean suggested HTML. + * + * @since 4.6.0 + * @hook ep_suggestion_html + * @param {string} $html The HTML output. + * @param {array} $terms All suggested terms. + * @param {WP_Query} $query The WP_Query object. + * @return {string} New HTML output + */ + return apply_filters( 'ep_suggestion_html', $html, $terms, $query ); + } + + /** + * If needed set the `suggest` to ES query clause. + * + * @param array $formatted_args Formatted Elasticsearch query. + * @param array $args WP_Query arguments + * @param array $wp_query WP_Query object + */ + public function add_query_args( $formatted_args, $args, $wp_query ) : array { + $search_analyzer = [ + 'phrase' => [ + 'field' => 'post_content.shingle', + 'max_errors' => 2, + 'direct_generator' => [ + [ + 'field' => 'post_content.shingle', + ], + ], + ], + ]; + + /** + * Filter the search analyzer use for the did you mean feature. + * + * @since 4.6.0 + * @hook ep_search_suggestion_analyzer + * @param {array} $search_analyzer Search analyzer + * @param {array} $formatted_args Formatted Elasticsearch query + * @param {array} $args WP_Query arguments + * @param {WP_Query} $wp_query WP_Query object + * @return {array} New search analyzer + */ + $search_analyzer = apply_filters( 'ep_search_suggestion_analyzer', $search_analyzer, $formatted_args, $args, $wp_query ); + + if ( ! empty( $args['s'] ) ) { + $formatted_args['suggest'] = array( + 'text' => $args['s'], + 'ep_suggestion' => $search_analyzer, + ); + } + + return $formatted_args; + } + + /** + * Set the ep_suggestion flag to true if the query is a search query. + * + * @param bool $enabled Whether to enable the search queries integration. + * @param WP_Query $query The WP_Query object. + */ + public function set_ep_suggestion( $enabled, $query ) : bool { + if ( $query->is_search() && ! empty( $query->query_vars['s'] ) ) { + $query->set( 'ep_suggestion', true ); + } + + return $enabled; + } + + /** + * Returns requirements status of feature + * + * Requires the search feature to be activated + */ + public function requirements_status() : FeatureRequirementsStatus { + $features = Features::factory(); + $search = $features->get_registered_feature( 'search' ); + + if ( ! $search->is_active() ) { + return new FeatureRequirementsStatus( 2, esc_html__( 'This feature requires the "Post Search" feature to be enabled', 'elasticpress' ) ); + } + + return new FeatureRequirementsStatus( 1 ); + } + + /** + * Display feature settings. + * + * @return void + */ + public function output_feature_box_settings() { + $settings = $this->get_settings(); + ?> +
+
+
+
+
+
+
+
+ is_main_query() && $wp_query->is_search() ) { + $query = $wp_query; + } + + if ( ! is_a( $query, '\WP_Query' ) ) { + return false; + } + + $settings = $this->get_settings(); + + // If there are posts, we don't need to show the list of suggestions. + if ( 'list' !== $settings['search_behavior'] || $query->found_posts ) { + return false; + } + + $options = $query->suggested_terms['options'] ?? []; + array_shift( $options ); + + if ( empty( $options ) ) { + return ''; + } + + $html = '
'; + $html .= esc_html__( 'Other suggestions:', 'elasticpress' ); + $html .= ''; + $html .= '
'; + + return $html; + } + + /** + * Returns the top suggested term + * + * @param WP_Query $query WP_Query object + * @return string|bool + */ + public function get_suggested_term( $query ) { + $options = $query->suggested_terms['options'] ?? []; + return ! empty( $options ) ? $options[0]['text'] : false; + } + + /** + * Redirect user to suggested search term if no results found and search_behavior is set to redirect. + * + * @return void + */ + public function automatically_redirect_user() { + global $wp_query; + + if ( ! $wp_query->is_main_query() || ! $wp_query->is_search() ) { + return; + } + + if ( $wp_query->found_posts ) { + return; + } + + $settings = $this->get_settings(); + if ( 'redirect' !== $settings['search_behavior'] ) { + return; + } + + $term = $this->get_suggested_term( $wp_query ); + if ( empty( $term ) ) { + return; + } + + $url = get_search_link( $term ); + $url = add_query_arg( + [ + 'ep_suggestion_original_term' => $wp_query->query_vars['s'], + ], + $url + ); + + wp_safe_redirect( $url ); + exit; + } + + /** + * Return a message to the user when the original search term has no results and the user is redirected to the suggested term. + * + * @param WP_Query $query WP_Query object + * + * @return string|void + */ + public function get_original_search_term( $query = null ) { + global $wp_query; + + $settings = $this->get_settings(); + if ( empty( $settings['active'] ) ) { + return false; + } + + if ( ! $query && $wp_query->is_main_query() && $wp_query->is_search() ) { + $query = $wp_query; + } + + if ( ! is_a( $query, '\WP_Query' ) ) { + return; + } + + if ( ! isset( $_GET['ep_suggestion_original_term'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return; + } + + $settings = $this->get_settings(); + if ( 'redirect' !== $settings['search_behavior'] ) { + return; + } + + $original_term = sanitize_text_field( wp_unslash( $_GET['ep_suggestion_original_term'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + $html = sprintf( + '
+ %s%s +
+ %s%s +
', + esc_html__( 'Showing results for: ', 'elasticpress' ), + esc_html( $query->query_vars['s'] ), + esc_html__( 'No results for: ', 'elasticpress' ), + esc_html( $original_term ) + ); + + /** + * Filter the HTML output for the original search term. + * + * @since 4.6.0 + * @hook ep_suggestion_original_search_term_html + * @param {string} $html HTML output + * @param {string} $search_term Suggested search term + * @param {string} $original_term Original search term + * @param {WP_Query} $query WP_Query object + * @return {string} New HTML output + */ + return apply_filters( 'ep_suggestion_original_search_term_html', $html, $query->query_vars['s'], $original_term, $query ); + } + + /** + * Returns the suggestion + * + * @param WP_Query $query WP_Query object + * @return void + */ + public function the_output( $query = null ) { + $html = $this->get_original_search_term( $query ); + $html .= $this->get_suggestion( $query ); + + echo wp_kses_post( $html ); + } +} diff --git a/includes/classes/FeatureRequirementsStatus.php b/includes/classes/FeatureRequirementsStatus.php index 18f08a1902..39c00171bc 100644 --- a/includes/classes/FeatureRequirementsStatus.php +++ b/includes/classes/FeatureRequirementsStatus.php @@ -34,7 +34,7 @@ public function __construct( $code, $message = null ) { * Returns the status of a feature * * 0 is no issues - * 1 is usable but there are warnngs + * 1 is usable but there are warnings * 2 is not usable * * @var int diff --git a/includes/classes/Indexable/Post/QueryIntegration.php b/includes/classes/Indexable/Post/QueryIntegration.php index ecf9230d7b..99c9d2f300 100644 --- a/includes/classes/Indexable/Post/QueryIntegration.php +++ b/includes/classes/Indexable/Post/QueryIntegration.php @@ -371,6 +371,7 @@ public function get_es_posts( $posts, $query ) { $query->found_posts = $found_documents; $query->num_posts = $query->found_posts; $query->max_num_pages = ceil( $found_documents / $query->get( 'posts_per_page' ) ); + $query->suggested_terms = isset( $ep_query['suggest']['ep_suggestion'] ) && isset( $ep_query['suggest']['ep_suggestion'][0] ) ? $ep_query['suggest']['ep_suggestion'][0] : []; $query->elasticsearch_success = true; // Determine how we should format the results from ES based on the fields parameter. diff --git a/tests/php/features/TestDidYouMean.php b/tests/php/features/TestDidYouMean.php new file mode 100644 index 0000000000..d1ff3d6d11 --- /dev/null +++ b/tests/php/features/TestDidYouMean.php @@ -0,0 +1,394 @@ +suppress_errors(); + + $admin_id = $this->factory->user->create( [ 'role' => 'administrator' ] ); + + wp_set_current_user( $admin_id ); + + ElasticPress\Features::factory()->activate_feature( 'search' ); + ElasticPress\Features::factory()->activate_feature( 'did-you-mean' ); + + ElasticPress\Features::factory()->setup_features(); + + ElasticPress\Elasticsearch::factory()->delete_all_indices(); + ElasticPress\Indexables::factory()->get( 'post' )->put_mapping(); + + $this->setup_test_post_type(); + } + + /** + * Test Feature properties. + */ + public function testConstruct() { + $instance = new ElasticPress\Feature\DidYouMean\DidYouMean(); + + $this->assertEquals( 'did-you-mean', $instance->slug ); + $this->assertEquals( 'Did You Mean', $instance->title ); + $this->assertTrue( $instance->requires_install_reindex ); + $this->assertTrue( $instance->available_during_installation ); + $this->assertTrue( $instance->is_visible() ); + $this->assertSame( [ 'search_behavior' => false ], $instance->default_settings ); + } + + /** + * Test Requirements status. + */ + public function testRequirementsStatus() { + $instance = new ElasticPress\Feature\DidYouMean\DidYouMean(); + $status = $instance->requirements_status(); + + $this->assertEquals( 1, $status->code ); + $this->assertEquals( null, $status->message ); + } + + /** + * Test Requirements status when search feature is not active. + */ + public function testRequirementsStatusWhenSearchFeatureIsNotActive() { + ElasticPress\Features::factory()->deactivate_feature( 'search' ); + + $instance = new ElasticPress\Feature\DidYouMean\DidYouMean(); + $status = $instance->requirements_status(); + + $this->assertEquals( 2, $status->code ); + $this->assertEquals( 'This feature requires the "Post Search" feature to be enabled', $status->message ); + } + + /** + * Tests that ES returns a suggestion when search term has a typo. + */ + public function testEsSearchSuggestion() { + $this->ep_factory->post->create( [ 'post_content' => 'Test post' ] ); + + ElasticPress\Elasticsearch::factory()->refresh_indices(); + + $query = new \WP_Query( + [ + 's' => 'teet', + ] + ); + + $this->assertTrue( $query->elasticsearch_success ); + $this->assertEquals( 'test', $query->suggested_terms['options'][0]['text'] ); + } + + /** + * Tests that ES returns a suggestion only for search queries. + */ + public function testEsSearchSuggestionOnlyIntegrateWithSearchQuery() { + $this->ep_factory->post->create( [ 'post_content' => 'Test post' ] ); + + ElasticPress\Elasticsearch::factory()->refresh_indices(); + + $query = new \WP_Query( + [ + 'ep_integrate' => true, + ] + ); + + $this->assertTrue( $query->elasticsearch_success ); + $this->assertEmpty( $query->suggested_terms ); + } + + /** + * Tests the get_suggestion method. + */ + public function testGetSearchSuggestionMethod() { + $this->ep_factory->post->create( [ 'post_content' => 'Test post' ] ); + + ElasticPress\Elasticsearch::factory()->refresh_indices(); + + $query = new \WP_Query( + [ + 's' => 'teet', + ] + ); + + $this->assertTrue( $query->elasticsearch_success ); + + $expected = sprintf( 'Did you mean: test?', get_search_link( 'test' ) ); + $this->assertEquals( $expected, ElasticPress\Features::factory()->get_registered_feature( 'did-you-mean' )->get_suggestion( $query ) ); + } + + /** + * Tests that get_suggestion method returns suggestion only for main query. + */ + public function testGetSearchSuggestionMethodReturnsSuggestionForMainQuery() { + global $wp_the_query, $wp_query; + + $this->ep_factory->post->create( [ 'post_content' => 'Test post' ] ); + + ElasticPress\Elasticsearch::factory()->refresh_indices(); + + $args = [ + 's' => 'teet', + ]; + $query = new \WP_Query( $args ); + + // mock the query as main query + $wp_the_query = $query; + $wp_query = $query; + + $this->assertTrue( $query->elasticsearch_success ); + + $query = $query->query( $args ); + + $expected = sprintf( 'Did you mean: test?', get_search_link( 'test' ) ); + $this->assertEquals( $expected, ElasticPress\Features::factory()->get_registered_feature( 'did-you-mean' )->get_suggestion() ); + } + + /** + * Tests that get_suggestion method returns false if other than WP_Query is passed. + */ + public function testGetSearchSuggestionMethodReturnsFalseIfOtherThanWpQueryIsPassed() { + $query = new \stdClass(); + $this->assertFalse( ElasticPress\Features::factory()->get_registered_feature( 'did-you-mean' )->get_suggestion( $query ) ); + } + + /** + * Tests that get_suggestion method filter `ep_suggestion_html`. + */ + public function testGetSearchSuggestionMethodFilter() { + $this->ep_factory->post->create( [ 'post_content' => 'Test post' ] ); + + ElasticPress\Elasticsearch::factory()->refresh_indices(); + + $expected_result = 'Did you mean: test filter is working ?'; + add_filter( + 'ep_suggestion_html', + function( $html, $terms, $query ) use ( $expected_result ) { + $this->assertEquals( 'test', $terms[0]['text'] ); + $this->assertInstanceOf( '\WP_Query', $query ); + return $expected_result; + }, + 10, + 3 + ); + + $query = new \WP_Query( + [ + 's' => 'teet', + ] + ); + $this->assertEquals( $expected_result, ElasticPress\Features::factory()->get_registered_feature( 'did-you-mean' )->get_suggestion( $query ) ); + } + + /** + * Test Mapping for ES version 7 and above. + */ + public function testMapping() { + add_filter( + 'ep_elasticsearch_version', + function() { + return '7.0'; + } + ); + + $mapping = ElasticPress\Indexables::factory()->get( 'post' )->generate_mapping(); + + $expected_result = [ + 'shingle' => + [ + 'type' => 'text', + 'analyzer' => 'trigram', + ], + ]; + $this->assertSame( $expected_result, $mapping['mappings']['properties']['post_content']['fields'] ); + } + + /** + * Test Mapping for ES version lower than 7. + */ + public function testMappingForESVersionLowerThanSeven() { + add_filter( + 'ep_elasticsearch_version', + function() { + return '5.2.0'; + } + ); + + $mapping = ElasticPress\Indexables::factory()->get( 'post' )->generate_mapping(); + + $expected_result = [ + 'shingle' => + [ + 'type' => 'text', + 'analyzer' => 'trigram', + ], + ]; + $this->assertSame( $expected_result, $mapping['mappings']['post']['properties']['post_content']['fields'] ); + } + + /** + * Test `ep_search_suggestion_analyzer` filter. + */ + public function testSearchAnalyzerFilter() { + $this->ep_factory->post->create( [ 'post_content' => 'Test post' ] ); + + ElasticPress\Elasticsearch::factory()->refresh_indices(); + + $search_analyzer = [ + 'term' => [ + 'field' => 'post_content', + ], + ]; + + add_filter( + 'ep_search_suggestion_analyzer', + function() use ( $search_analyzer ) { + return $search_analyzer; + } + ); + + add_filter( + 'ep_query_request_args', + function( $request_args, $path, $index, $type, $query, $query_args, $query_object ) use ( $search_analyzer ) { + $this->assertEquals( $search_analyzer, $query['suggest']['ep_suggestion'] ); + return $request_args; + }, + 10, + 7 + ); + + $query = new \WP_Query( + [ + 's' => 'teet', + ] + ); + + $this->assertTrue( $query->elasticsearch_success ); + $this->assertEquals( 'Test post', $query->posts[0]->post_content ); + } + + /** + * Test that function returns the original search term when no results are found. + */ + public function testGetOriginalSearchTerm() { + global $wp_the_query, $wp_query; + + $this->ep_factory->post->create( [ 'post_content' => 'Test post' ] ); + + ElasticPress\Elasticsearch::factory()->refresh_indices(); + + ElasticPress\Features::factory()->update_feature( + 'did-you-mean', + [ + 'active' => true, + 'search_behavior' => 'redirect', + ] + ); + + parse_str( 'ep_suggestion_original_term=Original Term', $_GET ); + + $query = new \WP_Query( + [ + 's' => 'teet', + ] + ); + + // mock the query as main query + $wp_the_query = $query; + $wp_query = $query; + + $this->assertTrue( $query->elasticsearch_success ); + + $html = ElasticPress\Features::factory()->get_registered_feature( 'did-you-mean' )->get_original_search_term(); + $this->assertStringContainsString( '
', $html ); + $this->assertStringContainsString( 'Showing results for: teet', $html ); + $this->assertStringContainsString( 'No results for: Original Term', $html ); + } + + /** + * Test `ep_suggestions` action for main query. + */ + public function testEPSuggestionsAction() { + global $wp_the_query, $wp_query; + + $this->ep_factory->post->create( [ 'post_content' => 'Test post' ] ); + + ElasticPress\Elasticsearch::factory()->refresh_indices(); + + $query = new \WP_Query( + [ + 's' => 'teet', + ] + ); + + ElasticPress\Features::factory()->update_feature( + 'did-you-mean', + [ + 'active' => true, + 'search_behavior' => 'redirect', + ] + ); + + parse_str( 'ep_suggestion_original_term=Original Term', $_GET ); + + // mock the query as main query + $wp_the_query = $query; + $wp_query = $query; + + ob_start(); + do_action( 'ep_suggestions' ); + $output = ob_get_clean(); + + $expected = sprintf( 'Did you mean: test?', get_search_link( 'test' ) ); + $this->assertStringContainsString( $expected, $output ); + $this->assertStringContainsString( 'Showing results for: teet', $output ); + } + + /** + * Test `ep_suggestions` action for other than main query. + */ + public function testEPSuggestionsActionOtherThanMainQuery() { + $this->ep_factory->post->create( [ 'post_content' => 'Test post' ] ); + + ElasticPress\Elasticsearch::factory()->refresh_indices(); + + $query = new \WP_Query( + [ + 's' => 'teet', + ] + ); + + ElasticPress\Features::factory()->update_feature( + 'did-you-mean', + [ + 'active' => true, + 'search_behavior' => 'redirect', + ] + ); + + parse_str( 'ep_suggestion_original_term=Original Term', $_GET ); + + ob_start(); + do_action( 'ep_suggestions', $query ); + $output = ob_get_clean(); + + $expected = sprintf( 'Did you mean: test?', get_search_link( 'test' ) ); + $this->assertStringContainsString( $expected, $output ); + $this->assertStringContainsString( 'Showing results for: teet', $output ); + } +}