From 356dc6a7828139e471b37e7ee32984bc327c1cdf Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Sun, 18 Oct 2026 11:11:43 +0100 Subject: [PATCH 01/13] feat: add the universal search service and endpoint, with AI search of the community --- src/php/Client/Docs_Client.php | 150 ++++ src/php/Client/Welcome_Client.php | 25 +- src/php/Core/Uninstaller.php | 5 + src/php/Plugin.php | 6 + .../Search/Search_REST_Controller.php | 161 +++++ src/php/Search/Search_Index.php | 282 ++++++++ src/php/Search/Search_Query.php | 314 ++++++++ src/php/Search/Universal_Search.php | 674 ++++++++++++++++++ src/php/Settings/settings.php | 8 +- tests/unit/Client/Docs_Client_Test.php | 189 +++++ .../Search/Search_REST_Controller_Test.php | 238 +++++++ tests/unit/Search/Search_Query_Test.php | 183 +++++ tests/unit/Search/Universal_Search_Test.php | 541 ++++++++++++++ 13 files changed, 2773 insertions(+), 3 deletions(-) create mode 100644 src/php/Client/Docs_Client.php create mode 100644 src/php/REST_API/Search/Search_REST_Controller.php create mode 100644 src/php/Search/Search_Index.php create mode 100644 src/php/Search/Search_Query.php create mode 100644 src/php/Search/Universal_Search.php create mode 100644 tests/unit/Client/Docs_Client_Test.php create mode 100644 tests/unit/REST_API/Search/Search_REST_Controller_Test.php create mode 100644 tests/unit/Search/Search_Query_Test.php create mode 100644 tests/unit/Search/Universal_Search_Test.php diff --git a/src/php/Client/Docs_Client.php b/src/php/Client/Docs_Client.php new file mode 100644 index 000000000..aa18ed23d --- /dev/null +++ b/src/php/Client/Docs_Client.php @@ -0,0 +1,150 @@ + + */ + public function get_articles(): array { + $cached = get_transient( self::CACHE_KEY ); + + if ( is_array( $cached ) ) { + return $cached; + } + + $articles = $this->fetch_articles(); + set_transient( self::CACHE_KEY, $articles ?? [], is_null( $articles ) ? self::FAILURE_TTL : self::CACHE_TTL ); + + return $articles ?? []; + } + + /** + * Purge the cached index. + * + * @return void + */ + public static function clear_cache() { + delete_transient( self::CACHE_KEY ); + } + + /** + * Reduce a value from the index to plain text. + * + * @param mixed $value Value, expected to be a string. + * + * @return string + */ + private static function text( $value ): string { + return is_string( $value ) ? Search_Query::plain_text( $value ) : ''; + } + + /** + * Fetch the documentation index and keep the articles that are safe to show. + * + * @return array|null + * Index of articles, or null if it could not be fetched. + */ + private function fetch_articles(): ?array { + $response = wp_remote_get( self::INDEX_URL, [ 'timeout' => self::REQUEST_TIMEOUT ] ); + + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + return null; + } + + $body = unpack_response_body( $response ); + + if ( ! is_array( $body ) || ! is_array( $body['articles'] ?? null ) ) { + return null; + } + + $articles = []; + + foreach ( array_slice( $body['articles'], 0, self::MAX_ARTICLES ) as $article ) { + if ( ! is_array( $article ) ) { + continue; + } + + $url = esc_url_raw( is_string( $article['url'] ?? null ) ? $article['url'] : '', [ 'https' ] ); + $title = self::text( $article['title'] ?? null ); + + if ( '' === $title || self::DOCS_HOST !== wp_parse_url( $url, PHP_URL_HOST ) ) { + continue; + } + + $categories = is_array( $article['category'] ?? null ) ? $article['category'] : []; + + $articles[] = [ + 'id' => substr( md5( $url ), 0, self::ID_LENGTH ), + 'title' => $title, + 'url' => $url, + 'text' => mb_substr( self::text( $article['excerpt'] ?? null ), 0, self::MAX_TEXT_LENGTH ), + 'category' => self::text( reset( $categories ) ), + ]; + } + + return $articles; + } +} diff --git a/src/php/Client/Welcome_Client.php b/src/php/Client/Welcome_Client.php index 2a5481944..c7ed6dbf4 100644 --- a/src/php/Client/Welcome_Client.php +++ b/src/php/Client/Welcome_Client.php @@ -73,6 +73,16 @@ public function __construct() { } } + /** + * Retrieve the welcome page data that is already cached, without fetching it. + * + * @return array Cached data, or an empty array when nothing is cached. + */ + public static function get_cached_data(): array { + $stored_data = get_transient( self::CACHE_KEY ); + return is_array( $stored_data ) ? $stored_data : []; + } + /** * Purge the welcome data cache. * @@ -225,6 +235,17 @@ protected function fetch_remote_welcome_data() { * @return void */ protected function build_changelog_data() { + $this->welcome_data['changelog'] = self::read_changelog(); + } + + /** + * Read the latest releases from the plugin's changelog file. + * + * The file ships with the plugin, so this never makes a remote request. + * + * @return array>}> + */ + public static function read_changelog(): array { require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php'; require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php'; $filesystem = new WP_Filesystem_Direct( null ); @@ -240,7 +261,7 @@ protected function build_changelog_data() { } if ( ! $filesystem->exists( $changelog_dir . $changelog_filename ) ) { - return; + return []; } $changelog_contents = $filesystem->get_contents( $changelog_dir . $changelog_filename ); @@ -301,7 +322,7 @@ function ( $section ) { ]; } - $this->welcome_data['changelog'] = $changelog; + return $changelog; } /** diff --git a/src/php/Core/Uninstaller.php b/src/php/Core/Uninstaller.php index b8fafbbd0..ad323e5e1 100644 --- a/src/php/Core/Uninstaller.php +++ b/src/php/Core/Uninstaller.php @@ -10,8 +10,10 @@ namespace Code_Snippets\Core; use Code_Snippets\Admin\Feedback_Panel; +use Code_Snippets\Client\Docs_Client; use Code_Snippets\Client\Feedback_Client; use Code_Snippets\Model\Feedback_Connection; +use Code_Snippets\Search\Universal_Search; use function Code_Snippets\flush_versioned_cache_groups; /** @@ -88,6 +90,9 @@ private function uninstall_current_site() { delete_transient( 'cs_codevault_snippets' ); delete_transient( 'cs_local_to_cloud_map' ); + Docs_Client::clear_cache(); + Universal_Search::clear_caches(); + delete_option( Feedback_Connection::CREDENTIALS_OPTION ); delete_transient( Feedback_Panel::SUMMARY_TRANSIENT ); delete_transient( Feedback_Client::REGISTRATION_FAILURE_TRANSIENT ); diff --git a/src/php/Plugin.php b/src/php/Plugin.php index 9b6c066f3..b5bd4d5da 100644 --- a/src/php/Plugin.php +++ b/src/php/Plugin.php @@ -6,6 +6,7 @@ use Code_Snippets\Admin\Feedback_Error_Capture; use Code_Snippets\Admin\Feedback_Panel; use Code_Snippets\Admin\Menus\Manage\Manage_Menu; +use Code_Snippets\Client\Docs_Client; use Code_Snippets\Client\Feedback_Client; use Code_Snippets\Controller\Cloud_Search_Controller; use Code_Snippets\Core\DB; @@ -26,8 +27,10 @@ use Code_Snippets\REST_API\Preferences\Demos_Seen_REST_Controller; use Code_Snippets\REST_API\Preferences\Stats_View_Rest_Controller; use Code_Snippets\REST_API\Preferences\Snippet_View_REST_Controller; +use Code_Snippets\REST_API\Search\Search_REST_Controller; use Code_Snippets\REST_API\Snippets\Recently_Active_REST_Controller; use Code_Snippets\REST_API\Snippets\Snippets_REST_Controller; +use Code_Snippets\Search\Universal_Search; use function Code_Snippets\Settings\are_settings_unified; /** @@ -168,6 +171,8 @@ public function init_rest_api() { new Cloud_Snippets_REST_Controller( $cloud_search ); new Feedback_REST_Controller( new Feedback_Client( new Feedback_Connection() ) ); + + new Search_REST_Controller( new Universal_Search( $cloud_search, new Docs_Client() ) ); } /** @@ -387,6 +392,7 @@ public function localize_script( string $handle ) { 'token' => $this->cloud_connection->get_local_token(), 'snippets' => esc_url_raw( rest_url( Cloud_Snippets_REST_Controller::get_base_route() ) ), ], + 'search' => esc_url_raw( rest_url( Search_REST_Controller::get_base_route() ) ), ], 'urls' => [ 'plugin' => esc_url_raw( plugins_url( '', PLUGIN_FILE ) ), diff --git a/src/php/REST_API/Search/Search_REST_Controller.php b/src/php/REST_API/Search/Search_REST_Controller.php new file mode 100644 index 000000000..8c610ee6e --- /dev/null +++ b/src/php/REST_API/Search/Search_REST_Controller.php @@ -0,0 +1,161 @@ +search = $search; + } + + /** + * Register REST routes. + */ + public function register_routes() { + register_rest_route( + $this->namespace, + self::BASE_ROUTE, + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_results_callback' ], + 'permission_callback' => [ $this, 'permission_callback' ], + 'args' => [ + 'q' => [ + 'description' => esc_html__( 'Text to search for.', 'code-snippets' ), + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => [ Search_Query::class, 'sanitize_term' ], + ], + 'sources' => [ + 'description' => esc_html__( 'Sources to search. Defaults to every source.', 'code-snippets' ), + 'type' => 'array', + 'items' => [ + 'type' => 'string', + 'enum' => Universal_Search::get_sources(), + ], + 'default' => Universal_Search::get_sources(), + ], + 'fields' => [ + 'description' => esc_html__( 'Fields to match the search text against. Defaults to every field.', 'code-snippets' ), + 'type' => 'array', + 'items' => [ + 'type' => 'string', + 'enum' => Search_Query::FIELDS, + ], + 'default' => Search_Query::FIELDS, + ], + 'method' => [ + 'description' => esc_html__( 'How to search the cloud: by keyword, or with AI.', 'code-snippets' ), + 'type' => 'string', + 'enum' => Search_Query::METHODS, + 'default' => 'term', + ], + 'per_source' => [ + 'description' => esc_html__( 'Largest number of results to return for each source.', 'code-snippets' ), + 'type' => 'integer', + 'default' => 50, + 'minimum' => 1, + 'maximum' => self::MAX_PER_SOURCE, + ], + 'excerpts' => [ + 'description' => esc_html__( 'Whether to shorten long descriptions to an excerpt around the match.', 'code-snippets' ), + 'type' => 'boolean', + 'default' => true, + ], + 'network' => [ + 'description' => esc_html__( 'Search network-wide snippets instead of site-wide.', 'code-snippets' ), + 'type' => 'boolean', + 'default' => false, + ], + ], + ], + ] + ); + } + + /** + * Determine whether the request has permission to search. + * + * @param WP_REST_Request $request Incoming HTTP request. + * + * @return bool + */ + public function permission_callback( WP_REST_Request $request ): bool { + return is_multisite() && $request->get_param( 'network' ) + ? code_snippets()->user_can_manage_network_snippets() + : code_snippets()->current_user_can(); + } + + /** + * Callback for retrieving search results. + * + * @param WP_REST_Request $request The REST request object. + * + * @return WP_REST_Response Search results, grouped by source. + */ + public function get_results_callback( WP_REST_Request $request ): WP_REST_Response { + $query = new Search_Query( + (string) $request->get_param( 'q' ), + (array) $request->get_param( 'fields' ), + (bool) $request->get_param( 'excerpts' ), + (string) $request->get_param( 'method' ) + ); + + return rest_ensure_response( + [ + 'query' => $query->get_term(), + 'groups' => $this->search->search( + $query, + (array) $request->get_param( 'sources' ), + is_multisite() && $request->get_param( 'network' ), + (int) $request->get_param( 'per_source' ) + ), + ] + ); + } +} diff --git a/src/php/Search/Search_Index.php b/src/php/Search/Search_Index.php new file mode 100644 index 000000000..6990a014d --- /dev/null +++ b/src/php/Search/Search_Index.php @@ -0,0 +1,282 @@ +url_context = $network ? 'network' : 'admin'; + } + + /** + * Join the parts of a section path for display. + * + * @param string ...$parts Section names, outermost first. + * + * @return string + */ + public static function build_path( string ...$parts ): string { + return implode( ' › ', array_filter( $parts ) ); + } + + /** + * Build the text describing a setting from its definition. + * + * A checkbox carries its explanation in its label, while the label of a + * number field is only the unit shown beside it. + * + * @param array $field Field definition. + * + * @return string + */ + private static function describe_setting( array $field ): string { + $parts = []; + + if ( in_array( $field['type'] ?? '', [ 'checkbox', 'action' ], true ) && ! empty( $field['label'] ) ) { + $parts[] = (string) $field['label']; + } + + if ( ! empty( $field['desc'] ) ) { + $parts[] = (string) $field['desc']; + } + + if ( ! empty( $field['options'] ) && is_array( $field['options'] ) && 'checkboxes' === $field['type'] ) { + $parts[] = implode( ', ', array_map( 'strval', $field['options'] ) ); + } + + return Search_Query::plain_text( implode( ' ', $parts ) ); + } + + /** + * Retrieve an entry for every setting shown on the settings screen. + * + * @return array> + */ + public function get_settings_entries(): array { + $definitions = Settings_Fields::get_field_definitions(); + $descriptions = Settings_Layout::get_descriptions(); + $settings_url = code_snippets()->get_menu_url( 'settings', $this->url_context ); + $settings_label = __( 'Settings', 'code-snippets' ); + $entries = []; + + foreach ( Settings_Layout::get_available_tabs() as $tab_id => $tab_label ) { + foreach ( Settings_Layout::get_visible_fields( $tab_id ) as $entry ) { + list( $section_id, $field_id ) = $entry; + $field = $definitions[ $section_id ][ $field_id ]; + + if ( empty( $field['name'] ) ) { + continue; + } + + if ( isset( $descriptions[ $field_id ] ) ) { + $field['desc'] = $descriptions[ $field_id ]; + } + + $entries[] = [ + 'id' => "$section_id-$field_id", + 'type' => 'setting', + 'title' => (string) $field['name'], + 'description' => self::describe_setting( $field ), + 'url' => add_query_arg( 'section', $tab_id, $settings_url ) . '#' . self::SETTING_ANCHOR_PREFIX . $field_id, + 'path' => self::build_path( $settings_label, (string) $tab_label ), + ]; + } + } + + return $entries; + } + + /** + * Retrieve an entry for every report on the Stats screen. + * + * @return array> + */ + public function get_stats_entries(): array { + $stats_url = code_snippets()->get_menu_url( 'stats', $this->url_context ); + $path = self::build_path( __( 'Stats', 'code-snippets' ), __( 'Reports', 'code-snippets' ) ); + + $reports = [ + 'total' => [ + __( 'Total snippets', 'code-snippets' ), + __( 'The number of snippets saved on this site.', 'code-snippets' ), + ], + 'type' => [ + __( 'Snippet type', 'code-snippets' ), + __( 'Snippets broken down by type: functions, content, styles, scripts and conditions.', 'code-snippets' ), + ], + 'activation' => [ + __( 'Activation status', 'code-snippets' ), + __( 'How many snippets are active and how many are inactive.', 'code-snippets' ), + ], + 'conditions' => [ + __( 'Condition usage', 'code-snippets' ), + __( 'How many snippets only run when a condition is met.', 'code-snippets' ), + ], + 'location' => [ + __( 'Location', 'code-snippets' ), + __( 'Where snippets run: everywhere, the administration area, the site front-end or a part of the page.', 'code-snippets' ), + ], + 'tags' => [ + __( 'Tags', 'code-snippets' ), + __( 'The tags used across snippets, and how often each one appears.', 'code-snippets' ), + ], + ]; + + $entries = []; + + foreach ( $reports as $report_id => $report ) { + $entries[] = [ + 'id' => "report-$report_id", + 'type' => 'stat', + 'title' => $report[0], + 'description' => $report[1], + 'url' => $stats_url . '#' . self::STATS_ANCHOR_PREFIX . $report_id, + 'path' => $path, + ]; + } + + return $entries; + } + + /** + * Flatten the changes of a release into a single line of text. + * + * @param array $sections Changes keyed by section, then by plugin edition. + * + * @return string + */ + private static function flatten_changelog_entries( array $sections ): string { + $changes = []; + + array_walk_recursive( + $sections, + function ( $change ) use ( &$changes ) { + if ( is_string( $change ) ) { + $changes[] = $change; + } + } + ); + + return Search_Query::plain_text( implode( ' ', $changes ) ); + } + + /** + * Retrieve an entry for every section of the welcome screen, along with the + * release notes and articles it lists. + * + * The articles come from data the welcome screen has already cached, and are + * left out rather than fetched when it is missing, so a search never waits on + * a remote request for a local result. Release notes are read from the + * changelog shipped with the plugin when nothing is cached. + * + * @return array> + */ + public function get_welcome_entries(): array { + $welcome_url = code_snippets()->get_menu_url( 'welcome', $this->url_context ); + $welcome_label = __( 'Welcome', 'code-snippets' ); + $whats_new_path = self::build_path( $welcome_label, __( "What's new", 'code-snippets' ) ); + $getting_started_path = self::build_path( $welcome_label, __( 'Getting started', 'code-snippets' ) ); + + $entries = [ + [ + 'id' => 'latest-changes', + 'type' => 'guide', + 'title' => __( "What's new in Code Snippets", 'code-snippets' ), + 'description' => __( 'Latest changes: the new features, improvements and bug fixes in recent releases.', 'code-snippets' ), + 'url' => $welcome_url . '#latest-changes', + 'path' => $whats_new_path, + ], + [ + 'id' => 'helpful-articles', + 'type' => 'guide', + 'title' => __( 'Helpful articles', 'code-snippets' ), + 'description' => __( 'Guides and tutorials for getting started with snippets.', 'code-snippets' ), + 'url' => $welcome_url . '#helpful-articles', + 'path' => $getting_started_path, + ], + [ + 'id' => 'partner-deals', + 'type' => 'guide', + 'title' => __( 'Exclusive deals from our partners', 'code-snippets' ), + 'description' => __( 'Offers on other WordPress products from Code Snippets partners.', 'code-snippets' ), + 'url' => $welcome_url . '#partner-deals', + 'path' => self::build_path( $welcome_label, __( 'Partners', 'code-snippets' ) ), + ], + ]; + + $cached = Welcome_Client::get_cached_data(); + $changelog = is_array( $cached['changelog'] ?? null ) ? $cached['changelog'] : Welcome_Client::read_changelog(); + + foreach ( $changelog as $release ) { + if ( empty( $release['version'] ) || ! is_scalar( $release['version'] ) ) { + continue; + } + + $entries[] = [ + 'id' => 'release-' . sanitize_key( (string) $release['version'] ), + 'type' => 'guide', + // translators: %s: version number. + 'title' => sprintf( __( "What's new in version %s", 'code-snippets' ), $release['version'] ), + 'description' => self::flatten_changelog_entries( is_array( $release['entries'] ?? null ) ? $release['entries'] : [] ), + 'url' => $welcome_url . '#latest-changes', + 'path' => $whats_new_path, + ]; + } + + foreach ( is_array( $cached['features'] ?? null ) ? $cached['features'] : [] as $index => $article ) { + if ( empty( $article['title'] ) || ! is_scalar( $article['title'] ) ) { + continue; + } + + $entries[] = [ + 'id' => "article-$index", + 'type' => 'guide', + 'title' => Search_Query::plain_text( (string) $article['title'] ), + 'description' => Search_Query::plain_text( is_scalar( $article['description'] ?? null ) ? (string) $article['description'] : '' ), + 'url' => $welcome_url . '#helpful-articles', + 'path' => $getting_started_path, + ]; + } + + return $entries; + } +} diff --git a/src/php/Search/Search_Query.php b/src/php/Search/Search_Query.php new file mode 100644 index 000000000..1720e134b --- /dev/null +++ b/src/php/Search/Search_Query.php @@ -0,0 +1,314 @@ +term = mb_substr( trim( $term ), 0, self::MAX_LENGTH ); + $this->fields = array_values( array_intersect( self::FIELDS, $fields ) ); + $this->excerpts = $excerpts; + $this->method = in_array( $method, self::METHODS, true ) ? $method : 'term'; + + $words = preg_split( '/\s+/u', $this->term, -1, PREG_SPLIT_NO_EMPTY ); + $this->words = array_slice( array_values( array_unique( is_array( $words ) ? $words : [] ) ), 0, self::MAX_WORDS ); + + $quoted = array_map( fn( string $word ) => preg_quote( $word, '/' ), $this->words ); + $this->pattern = '/' . implode( '|', $quoted ) . '/iu'; + } + + /** + * Clean a search term received from a request. + * + * The term is only ever compared against text and is escaped wherever it is + * displayed, so markup and code are kept intact: searching for `` has to find snippets containing exactly that. Only + * invalid UTF-8 and control characters are removed. + * + * @param mixed $term Raw search term. + * + * @return string + */ + public static function sanitize_term( $term ): string { + $term = wp_check_invalid_utf8( is_scalar( $term ) ? (string) $term : '', true ); + + return trim( (string) preg_replace( '/[\x00-\x1F\x7F]+/u', ' ', $term ) ); + } + + /** + * Retrieve the search term. + * + * @return string + */ + public function get_term(): string { + return $this->term; + } + + /** + * Retrieve the fields the term is matched against. + * + * @return string[] + */ + public function get_fields(): array { + return $this->fields; + } + + /** + * Retrieve how remote sources are searched. + * + * @return string + */ + public function get_method(): string { + return $this->method; + } + + /** + * Determine whether remote sources are searched with AI. + * + * @return bool + */ + public function is_ai(): bool { + return 'ai' === $this->method; + } + + /** + * Determine whether the term is long enough to search for. + * + * @return bool + */ + public function is_searchable(): bool { + return mb_strlen( $this->term ) >= self::MIN_LENGTH && $this->words && $this->fields; + } + + /** + * Determine whether a piece of text contains a word. + * + * @param string $text Text to look in. + * @param string $word Word to look for. + * + * @return bool + */ + private static function contains( string $text, string $word ): bool { + $result = preg_match( '/' . preg_quote( $word, '/' ) . '/iu', $text ); + + // The pattern fails outright on text that is not valid UTF-8. + return false === $result ? false !== stripos( $text, $word ) : 1 === $result; + } + + /** + * Determine which of the searched fields a result matches in. + * + * @param array $values Text of each field, keyed by field name. + * + * @return string[] Names of the fields containing a word of the term, in the order of `FIELDS`. + * Empty unless every word was found in one of the searched fields. + */ + public function matched_fields( array $values ): array { + $searched = array_intersect_key( $values, array_flip( $this->fields ) ); + $matched = []; + + foreach ( $this->words as $word ) { + $found = array_filter( $searched, fn( string $text ) => '' !== $text && self::contains( $text, $word ) ); + + if ( ! $found ) { + return []; + } + + $matched += $found; + } + + return array_values( array_intersect( $this->fields, array_keys( $matched ) ) ); + } + + /** + * Count the distinct words of the term found in a piece of text. + * + * @param string $text Text to look in. + * + * @return int + */ + private function count_words_in( string $text ): int { + return count( array_filter( $this->words, fn( string $word ) => self::contains( $text, $word ) ) ); + } + + /** + * Reduce markup to a single line of plain text. + * + * @param string $text Text, possibly containing markup. + * + * @return string + */ + public static function plain_text( string $text ): string { + $text = html_entity_decode( wp_strip_all_tags( $text ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); + return trim( (string) preg_replace( '/\s+/u', ' ', $text ) ); + } + + /** + * Shorten plain text for display, keeping the first match in view. + * + * Text is returned whole when excerpts are turned off. + * + * @param string $text Plain text. + * + * @return string + */ + public function excerpt( string $text ): string { + // Invalid UTF-8 would make the match unfindable and the excerpt start at the beginning. + $text = wp_check_invalid_utf8( $text, true ); + + if ( ! $this->excerpts || mb_strlen( $text ) <= self::EXCERPT_LENGTH ) { + return $text; + } + + $parts = $this->words ? preg_split( $this->pattern, $text, 2 ) : false; + $match_offset = is_array( $parts ) && 2 === count( $parts ) ? mb_strlen( $parts[0] ) : 0; + + if ( $match_offset + self::EXCERPT_LEAD <= self::EXCERPT_LENGTH ) { + return rtrim( mb_substr( $text, 0, self::EXCERPT_LENGTH ) ) . '…'; + } + + $start = max( 0, $match_offset - self::EXCERPT_LEAD ); + $excerpt = trim( mb_substr( $text, $start, self::EXCERPT_LENGTH ) ); + + return '…' . $excerpt . ( $start + self::EXCERPT_LENGTH < mb_strlen( $text ) ? '…' : '' ); + } + + /** + * Shorten a line shown around a match, where it only gives a sense of the code nearby. + * + * @param string $line Line of code. + * + * @return string + */ + private static function context_line( string $line ): string { + return mb_substr( trim( wp_check_invalid_utf8( $line, true ) ), 0, self::EXCERPT_LENGTH ); + } + + /** + * Find the line of code that best shows the match: the one containing the + * most words of the term, and the earliest of those. + * + * @param string $code Code to look in. + * + * @return array{line: int, text: string, before: string, after: string}|null Line number, counted + * from one, the line itself, and the lines either side of it for context. + */ + public function find_code_line( string $code ): ?array { + $lines = preg_split( '/\r\n|\r|\n/', $code ); + $best = null; + $best_count = 0; + + $lines = is_array( $lines ) ? $lines : []; + + foreach ( $lines as $index => $line ) { + $count = $this->count_words_in( $line ); + + if ( $count > $best_count ) { + $best_count = $count; + $best = [ + 'line' => $index + 1, + 'text' => $this->excerpt( trim( $line ) ), + 'before' => self::context_line( $lines[ $index - 1 ] ?? '' ), + 'after' => self::context_line( $lines[ $index + 1 ] ?? '' ), + ]; + } + } + + return $best; + } +} diff --git a/src/php/Search/Universal_Search.php b/src/php/Search/Universal_Search.php new file mode 100644 index 000000000..363deb329 --- /dev/null +++ b/src/php/Search/Universal_Search.php @@ -0,0 +1,674 @@ + 4, + 'description' => 2, + 'code' => 1, + ]; + + /** + * Number of matches the cloud reported for each source searched remotely, + * which can be more than were fetched. + * + * @var array + */ + private array $remote_totals = []; + + /** + * Cloud search controller. + * + * @var Cloud_Search_Controller + */ + private Cloud_Search_Controller $cloud_search; + + /** + * Documentation client. + * + * @var Docs_Client + */ + private Docs_Client $docs_client; + + /** + * Class constructor. + * + * @param Cloud_Search_Controller $cloud_search Cloud search controller. + * @param Docs_Client $docs_client Documentation client. + */ + public function __construct( Cloud_Search_Controller $cloud_search, Docs_Client $docs_client ) { + $this->cloud_search = $cloud_search; + $this->docs_client = $docs_client; + } + + /** + * Retrieve every source that can be searched, local sources first. + * + * @return string[] + */ + public static function get_sources(): array { + $sources = apply_filters( + 'code_snippets/search/sources', + array_merge( self::LOCAL_SOURCES, self::REMOTE_SOURCES ) + ); + + return array_values( array_unique( array_filter( (array) $sources, 'is_string' ) ) ); + } + + /** + * Purge cached remote results. + * + * @return void + */ + public static function clear_caches() { + delete_transient( self::COMMUNITY_CACHE_KEY ); + do_action( 'code_snippets/search/clear_caches' ); + } + + /** + * Search the requested sources. + * + * @param Search_Query $query Search query. + * @param string[] $sources Sources to search. + * @param bool $network Whether to search network-wide snippets and link to the network admin. + * @param int $limit Largest number of items to return for each source. + * + * @return array>}> + */ + public function search( Search_Query $query, array $sources, bool $network = false, int $limit = 50 ): array { + if ( ! $query->is_searchable() ) { + return []; + } + + $groups = []; + $this->remote_totals = []; + + foreach ( array_intersect( self::get_sources(), $sources ) as $source ) { + $items = $this->search_source( $source, $query, $network ); + + if ( $items ) { + $groups[] = [ + 'source' => $source, + 'total' => max( count( $items ), $this->remote_totals[ $source ] ?? 0 ), + 'items' => array_slice( $items, 0, max( 1, $limit ) ), + ]; + } + } + + return $groups; + } + + /** + * Record how many matches a remote service reported for a source, when that + * is more than the items it returned. + * + * @param string $source Source name. + * @param int $total Number of matches. + * + * @return void + */ + public function set_remote_total( string $source, int $total ) { + $this->remote_totals[ $source ] = $total; + } + + /** + * Search a single source. + * + * @param string $source Source name. + * @param Search_Query $query Search query. + * @param bool $network Whether to search network-wide snippets and link to the network admin. + * + * @return array> Matching items, best match first. + */ + private function search_source( string $source, Search_Query $query, bool $network ): array { + $index = new Search_Index( $network ); + + switch ( $source ) { + case 'snippets': + $items = $this->search_snippets( $query, $network ); + break; + + case 'settings': + $items = $this->can_access_settings() ? $this->search_entries( $index->get_settings_entries(), $source, $query, $network ) : []; + break; + + case 'stats': + $items = self::can_view_snippets() ? $this->search_entries( $index->get_stats_entries(), $source, $query, $network ) : []; + break; + + case 'welcome': + $items = $this->search_entries( $index->get_welcome_entries(), $source, $query, $network ); + break; + + case 'docs': + $items = $this->search_docs( $query ); + break; + + case 'community': + $items = $this->search_cloud_snippets( $this->get_community_snippets( $query ), $source, $query ); + break; + + default: + $items = []; + } + + /** + * Filter the items found in a source, or provide them for a source added with `code_snippets/search/sources`. + * + * @param array> $items Matching items, best match first. + * @param string $source Source name. + * @param Search_Query $query Search query. + * @param bool $network Whether network-wide snippets are being searched. + * @param Universal_Search $search Search service, for its helper methods. + */ + $items = apply_filters( 'code_snippets/search/source_items', $items, $source, $query, $network, $this ); + + return is_array( $items ) ? array_values( $items ) : []; + } + + /** + * Determine whether the current user can open the settings screen. + * + * @return bool + */ + private function can_access_settings(): bool { + if ( are_settings_unified() && ! is_super_admin() ) { + return false; + } + + return current_user_can( 'manage_options' ); + } + + /** + * Determine whether the current user is allowed to see the snippets saved on this site. + * + * @return bool + */ + public static function can_view_snippets(): bool { + return (bool) apply_filters( 'code_snippets/search/can_view_snippets', code_snippets()->current_user_can() ); + } + + /** + * Determine whether the current user is allowed to read snippet code. + * + * @return bool + */ + public static function can_read_code(): bool { + return (bool) apply_filters( 'code_snippets/search/can_read_code', code_snippets()->current_user_can() ); + } + + /** + * Order items so the strongest matches come first, keeping the source's own order between equals. + * + * @param array> $items Matching items. + * + * @return array> + */ + private function rank( array $items ): array { + $scores = array_map( + fn( array $item ) => array_sum( array_intersect_key( self::FIELD_WEIGHTS, array_flip( $item['matched'] ) ) ), + $items + ); + + $order = array_keys( $items ); + + usort( + $order, + fn( int $a, int $b ) => $scores[ $b ] <=> $scores[ $a ] ? $scores[ $b ] <=> $scores[ $a ] : $a <=> $b + ); + + return array_map( fn( int $index ) => $items[ $index ], $order ); + } + + /** + * Search a list of index entries by their title and description. + * + * @param array> $entries Index entries. + * @param string $source Source the entries belong to. + * @param Search_Query $query Search query. + * @param bool $network Whether links lead to the network admin. + * + * @return array> + */ + public function search_entries( array $entries, string $source, Search_Query $query, bool $network = false ): array { + /** + * Filter the index entries searched for a source. + * + * @param array> $entries Entries of the shape `{ id, type, title, description, url, path }`. + * @param string $source Source the entries belong to. + * @param bool $network Whether links lead to the network admin. + */ + $entries = (array) apply_filters( 'code_snippets/search/entries', $entries, $source, $network ); + $items = []; + + foreach ( $entries as $entry ) { + $matched = $query->matched_fields( + [ + 'title' => $entry['title'], + 'description' => $entry['description'], + ] + ); + + if ( $matched ) { + $items[] = array_merge( + $entry, + [ + 'id' => "$source-{$entry['id']}", + 'source' => $source, + 'description' => $query->excerpt( $entry['description'] ), + 'matched' => $matched, + ] + ); + } + } + + return $this->rank( $items ); + } + + /** + * Retrieve the snippets listed on the snippets screen: those of this site, or + * of the network, along with any network snippets shared with this site. + * + * @param bool $network Whether to look at network-wide snippets. + * + * @return Snippet[] + */ + private static function get_listed_snippets( bool $network ): array { + $snippets = get_snippets( [], $network ); + + if ( ! is_multisite() || $network ) { + return $snippets; + } + + $shared_ids = get_site_option( 'shared_network_snippets' ); + + if ( ! $shared_ids || ! is_array( $shared_ids ) ) { + return $snippets; + } + + $active_shared = (array) get_option( 'active_shared_network_snippets', [] ); + + foreach ( get_snippets( $shared_ids, true ) as $shared ) { + $shared->shared_network = true; + $shared->active = in_array( $shared->id, $active_shared, true ); + $snippets[] = $shared; + } + + return $snippets; + } + + /** + * Search the snippets saved on this site. + * + * @param Search_Query $query Search query. + * @param bool $network Whether to search network-wide snippets. + * + * @return array> + */ + private function search_snippets( Search_Query $query, bool $network ): array { + if ( ! self::can_view_snippets() ) { + return []; + } + + $can_read_code = self::can_read_code(); + $edit_url = code_snippets()->get_menu_url( 'edit', $network ? 'network' : 'admin' ); + $network_edit_url = code_snippets()->get_menu_url( 'edit', 'network' ); + $type_labels = self::get_type_labels(); + $items = []; + + foreach ( self::get_listed_snippets( $network ) as $snippet ) { + if ( $snippet->trashed ) { + continue; + } + + // A condition stores its rules as JSON, which is not code anyone searches for. + $code = $can_read_code && ! $snippet->is_condition() ? (string) $snippet->code : ''; + $description = Search_Query::plain_text( (string) $snippet->desc ); + + $matched = $query->matched_fields( + [ + 'title' => $snippet->display_name, + 'description' => $description, + 'code' => $code, + ] + ); + + if ( ! $matched ) { + continue; + } + + // Shared network snippets live in their own table, so their IDs can repeat those of this site. + $is_shared = $snippet->shared_network && ! $network; + + $item = [ + 'id' => $is_shared ? "snippets-network-$snippet->id" : "snippets-$snippet->id", + 'source' => 'snippets', + 'type' => $snippet->type, + 'title' => $snippet->display_name, + 'description' => $query->excerpt( $description ), + 'url' => add_query_arg( 'id', $snippet->id, $is_shared ? $network_edit_url : $edit_url ), + 'path' => $type_labels[ $snippet->type ] ?? '', + 'matched' => $matched, + 'snippet' => [ + 'id' => $snippet->id, + 'scope' => $snippet->scope, + 'active' => (bool) $snippet->active, + 'locked' => (bool) $snippet->locked, + 'network' => (bool) $snippet->network, + 'shared_network' => (bool) $snippet->shared_network, + ], + ]; + + if ( in_array( 'code', $matched, true ) ) { + $item['code'] = $query->find_code_line( $code ); + } + + $items[] = $item; + } + + return $this->rank( $items ); + } + + /** + * Retrieve the names of the snippet types. + * + * @return array + */ + private static function get_type_labels(): array { + return [ + 'php' => __( 'Functions', 'code-snippets' ), + 'html' => __( 'Content', 'code-snippets' ), + 'css' => __( 'Styles', 'code-snippets' ), + 'js' => __( 'Scripts', 'code-snippets' ), + 'cond' => __( 'Conditions', 'code-snippets' ), + ]; + } + + /** + * Search the documentation articles. + * + * @param Search_Query $query Search query. + * + * @return array> + */ + private function search_docs( Search_Query $query ): array { + $docs_label = __( 'Docs', 'code-snippets' ); + $items = []; + + foreach ( $this->docs_client->get_articles() as $article ) { + $matched = $query->matched_fields( + [ + 'title' => $article['title'], + 'description' => $article['text'], + ] + ); + + if ( $matched ) { + $items[] = [ + 'id' => "docs-{$article['id']}", + 'source' => 'docs', + 'type' => 'doc', + 'title' => $article['title'], + 'description' => $query->excerpt( $article['text'] ), + 'url' => $article['url'], + 'path' => Search_Index::build_path( $docs_label, $article['category'] ), + 'matched' => $matched, + ]; + } + } + + return $this->rank( $items ); + } + + /** + * Make a request to Code Snippets Cloud, treating an error status as a failure. + * + * The cloud answers errors with a JSON body, which the clients unpack into an + * empty result indistinguishable from a search that found nothing. Caching + * that would hide the source until the cache expired, so the status of every + * response received while the request runs is checked as well. + * + * @param callable $request Function making the request and returning its result. + * + * @return mixed|null The request's result, or null when it failed. + */ + public static function request_cloud( callable $request ) { + $failed = false; + + $watch = function ( $response ) use ( &$failed ) { + if ( wp_remote_retrieve_response_code( $response ) >= 400 ) { + $failed = true; + } + + return $response; + }; + + add_filter( 'http_response', $watch ); + + try { + $result = $request(); + } finally { + remove_filter( 'http_response', $watch ); + } + + return $failed ? null : $result; + } + + /** + * Retrieve the community snippets matching a search. + * + * @param Search_Query $query Search query. + * + * @return Cloud_Snippet[] + */ + private function get_community_snippets( Search_Query $query ): array { + $cache = get_transient( self::COMMUNITY_CACHE_KEY ); + $cache = is_array( $cache ) ? $cache : []; + $cache_key = md5( $query->get_method() . '|' . strtolower( $query->get_term() ) ); + $now = time(); + + $entry = $cache[ $cache_key ] ?? null; + + if ( ! is_array( $entry ) || ! isset( $entry['expires'], $entry['snippets'] ) || $entry['expires'] <= $now ) { + $result = self::request_cloud( + fn() => $this->cloud_search->fetch_search_results( $query->get_method(), $query->get_term(), 1, self::COMMUNITY_RESULTS ) + ); + + if ( ! $result ) { + return []; + } + + $entry = [ + 'expires' => $now + self::REMOTE_CACHE_TTL, + 'total' => (int) $result->total_snippets, + 'snippets' => array_map( fn( Cloud_Snippet $snippet ) => $snippet->get_fields(), $result->snippets ), + ]; + + $cache = array_filter( $cache, fn( $cached ) => is_array( $cached ) && ( $cached['expires'] ?? 0 ) > $now ); + $cache = array_slice( $cache, 1 - self::COMMUNITY_CACHE_SIZE, null, true ); + $cache[ $cache_key ] = $entry; + set_transient( self::COMMUNITY_CACHE_KEY, $cache, self::REMOTE_CACHE_TTL ); + } + + // The cloud's count only describes the results when they are not narrowed down again here. + if ( $query->is_ai() || Search_Query::FIELDS === $query->get_fields() ) { + $this->set_remote_total( 'community', (int) ( $entry['total'] ?? 0 ) ); + } + + return self::attach_local_ids( array_map( fn( array $fields ) => new Cloud_Snippet( $fields ), $entry['snippets'] ) ); + } + + /** + * Record which cloud snippets have already been downloaded to this site. + * + * @param Cloud_Snippet[] $cloud_snippets Cloud snippets. + * + * @return Cloud_Snippet[] The same snippets, with local identifiers attached where known. + */ + private static function attach_local_ids( array $cloud_snippets ): array { + $local_ids = []; + + foreach ( get_snippets() as $local_snippet ) { + if ( $local_snippet->cloud_id && ! $local_snippet->trashed ) { + $local_ids[ $local_snippet->cloud_id ] = $local_snippet->id; + } + } + + foreach ( $cloud_snippets as $cloud_snippet ) { + $cloud_snippet->local_id = $cloud_snippet->local_id ? $cloud_snippet->local_id : $local_ids[ $cloud_snippet->id ] ?? null; + } + + return $cloud_snippets; + } + + /** + * Build the path shown for a cloud snippet. + * + * @param Cloud_Snippet $cloud_snippet Cloud snippet. + * @param string $source Source the snippet belongs to. + * + * @return string + */ + private static function get_cloud_snippet_path( Cloud_Snippet $cloud_snippet, string $source ): string { + if ( 'community' !== $source ) { + $state = $cloud_snippet->local_id ? __( 'Downloaded', 'code-snippets' ) : __( 'Not downloaded', 'code-snippets' ); + return __( 'Cloud Library', 'code-snippets' ) . " · $state"; + } + + // translators: %s: name of the author's cloud library. + $origin = $cloud_snippet->codevault ? sprintf( __( 'by %s', 'code-snippets' ), $cloud_snippet->codevault ) : ''; + + return implode( ' · ', array_filter( [ __( 'Community', 'code-snippets' ), $origin ] ) ); + } + + /** + * Search a list of cloud snippets. + * + * Results the cloud already found for the community are kept even when nothing + * matches here, as the cloud also matches fields such as tags, and an AI search + * matches by meaning rather than by words. They are only narrowed down again + * when a keyword search is limited to particular fields. + * + * @param Cloud_Snippet[] $cloud_snippets Cloud snippets to search. + * @param string $source Source the snippets belong to. + * @param Search_Query $query Search query. + * + * @return array> + */ + public function search_cloud_snippets( array $cloud_snippets, string $source, Search_Query $query ): array { + $can_read_code = self::can_read_code(); + $keeps_cloud_matches = 'community' === $source && ( $query->is_ai() || Search_Query::FIELDS === $query->get_fields() ); + $edit_url = code_snippets()->get_menu_url( 'edit', 'admin' ); + $items = []; + + foreach ( $cloud_snippets as $cloud_snippet ) { + $type = Snippet::get_type_from_scope( (string) $cloud_snippet->scope ); + $description = Search_Query::plain_text( (string) $cloud_snippet->description ); + $code = $can_read_code && 'cond' !== $type ? (string) $cloud_snippet->code : ''; + + $matched = $query->matched_fields( + [ + 'title' => (string) $cloud_snippet->name, + 'description' => $description, + 'code' => $code, + ] + ); + + if ( ! $matched && ! $keeps_cloud_matches ) { + continue; + } + + $item = [ + 'id' => "$source-$cloud_snippet->id", + 'source' => $source, + 'type' => $type, + 'title' => (string) $cloud_snippet->name, + 'description' => $query->excerpt( $description ), + 'url' => $cloud_snippet->local_id ? add_query_arg( 'id', $cloud_snippet->local_id, $edit_url ) : '', + 'path' => self::get_cloud_snippet_path( $cloud_snippet, $source ), + 'matched' => $matched, + ]; + + if ( $can_read_code ) { + $item['cloud'] = $cloud_snippet->get_fields(); + } + + if ( in_array( 'code', $matched, true ) ) { + $item['code'] = $query->find_code_line( $code ); + } + + $items[] = $item; + } + + // The cloud ranks AI results by how closely they answer the question, which is kept. + return $query->is_ai() ? $items : $this->rank( $items ); + } +} diff --git a/src/php/Settings/settings.php b/src/php/Settings/settings.php index 069d1f9eb..96c073d61 100644 --- a/src/php/Settings/settings.php +++ b/src/php/Settings/settings.php @@ -7,8 +7,11 @@ namespace Code_Snippets\Settings; +use Code_Snippets\Client\Docs_Client; use Code_Snippets\Client\Welcome_Client; use Code_Snippets\Controller\Cloud_Search_Controller; +use Code_Snippets\Search\Search_Index; +use Code_Snippets\Search\Universal_Search; use function add_action; use function Code_Snippets\clean_snippets_cache; use function Code_Snippets\flush_versioned_cache_groups; @@ -133,8 +136,9 @@ function do_settings_fields_with_headings( string $page, string $section ): void } $class = empty( $field['args']['class'] ) ? '' : ' class="' . esc_attr( $field['args']['class'] ) . '"'; + $anchor = ' id="' . esc_attr( Search_Index::SETTING_ANCHOR_PREFIX . $field['id'] ) . '"'; - echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped above. + echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped above. // A field with no title, such as a notice rendered by a callback, spans // the row rather than leaving an empty header cell for a screen reader. @@ -390,6 +394,8 @@ function process_settings_actions( array $input ): ?array { if ( isset( $input['debug']['reset_caches'] ) ) { Welcome_Client::clear_cache(); Cloud_Search_Controller::clear_caches(); + Docs_Client::clear_cache(); + Universal_Search::clear_caches(); clean_snippets_cache( code_snippets()->db->get_table_name( false ) ); if ( is_multisite() ) { diff --git a/tests/unit/Client/Docs_Client_Test.php b/tests/unit/Client/Docs_Client_Test.php new file mode 100644 index 000000000..0518d4e67 --- /dev/null +++ b/tests/unit/Client/Docs_Client_Test.php @@ -0,0 +1,189 @@ +requested_urls = []; + $this->should_fail = false; + Docs_Client::clear_cache(); + + add_filter( 'pre_http_request', [ $this, 'mock_request' ], 10, 3 ); + } + + /** + * Tear down after each test. + * + * @return void + */ + public function tear_down() { + remove_filter( 'pre_http_request', [ $this, 'mock_request' ] ); + Docs_Client::clear_cache(); + + parent::tear_down(); + } + + /** + * Intercept requests to the documentation site. + * + * @param mixed $preempt Existing preempted value. + * @param array $parsed_args Parsed HTTP request arguments. + * @param string $url Requested URL. + * + * @return mixed + */ + public function mock_request( $preempt, array $parsed_args, string $url ) { + if ( false === strpos( $url, 'codesnippets.pro/wp-content/uploads/cs_docs/cs-docs-index.json' ) ) { + return $preempt; + } + + $this->requested_urls[] = $url; + + if ( $this->should_fail ) { + return new WP_Error( 'http_request_failed', 'Could not connect.' ); + } + + $body = [ + 'generated' => 1790892144, + 'articles' => [ + [ + 'title' => 'Recovering with safe mode', + 'url' => 'https://codesnippets.pro/doc/safe-mode/', + 'excerpt' => '

What to do when a snippet breaks your site.

', + 'category' => [ 'Emergency Fixes', 'FAQ' ], + ], + [ + 'title' => 'Hosted elsewhere', + 'url' => 'https://example.com/doc/elsewhere/', + 'excerpt' => '', + 'category' => [], + ], + [ + 'title' => '', + 'url' => 'https://codesnippets.pro/doc/untitled/', + 'excerpt' => 'No title.', + ], + 'not an article', + ], + ]; + + return [ + 'headers' => [], + 'body' => wp_json_encode( $body ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'cookies' => [], + ]; + } + + /** + * Articles are reduced to plain text, and those that are untitled or hosted elsewhere are dropped. + * + * @return void + */ + public function test_builds_a_plain_text_index() { + $articles = ( new Docs_Client() )->get_articles(); + + $this->assertSame( + [ + [ + 'id' => substr( md5( 'https://codesnippets.pro/doc/safe-mode/' ), 0, 12 ), + 'title' => 'Recovering with safe mode', + 'url' => 'https://codesnippets.pro/doc/safe-mode/', + 'text' => 'What to do when a snippet breaks your site.', + 'category' => 'Emergency Fixes', + ], + ], + $articles + ); + } + + /** + * A response that is not an index yields no articles. + * + * @return void + */ + public function test_ignores_a_malformed_index() { + remove_filter( 'pre_http_request', [ $this, 'mock_request' ] ); + + $respond_with_list = fn() => [ + 'headers' => [], + 'body' => wp_json_encode( [ 'articles' => 'none' ] ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'cookies' => [], + ]; + + add_filter( 'pre_http_request', $respond_with_list ); + $articles = ( new Docs_Client() )->get_articles(); + remove_filter( 'pre_http_request', $respond_with_list ); + + $this->assertSame( [], $articles ); + } + + /** + * A fetched index is reused instead of being requested again. + * + * @return void + */ + public function test_caches_the_index() { + $client = new Docs_Client(); + $client->get_articles(); + $requests = count( $this->requested_urls ); + + $this->assertCount( 1, $client->get_articles() ); + $this->assertCount( $requests, $this->requested_urls ); + } + + /** + * A failed fetch yields no articles, and is not retried straight away. + * + * @return void + */ + public function test_failure_yields_no_articles_and_backs_off() { + $this->should_fail = true; + $client = new Docs_Client(); + + $this->assertSame( [], $client->get_articles() ); + $this->assertCount( 1, $this->requested_urls ); + + $this->should_fail = false; + + $this->assertSame( [], $client->get_articles() ); + $this->assertCount( 1, $this->requested_urls ); + } +} diff --git a/tests/unit/REST_API/Search/Search_REST_Controller_Test.php b/tests/unit/REST_API/Search/Search_REST_Controller_Test.php new file mode 100644 index 000000000..c1718a0d5 --- /dev/null +++ b/tests/unit/REST_API/Search/Search_REST_Controller_Test.php @@ -0,0 +1,238 @@ +user->create( [ 'role' => 'subscriber' ] ); + } + + /** + * Set up before each test. + * + * @return void + */ + public function set_up() { + parent::set_up(); + + global $wp_rest_server; + $wp_rest_server = null; + rest_get_server(); + + global $wpdb; + $table_name = code_snippets()->db->get_table_name(); + $wpdb->query( "TRUNCATE TABLE $table_name" ); + clean_snippets_cache( $table_name ); + + save_snippet( + new Snippet( + [ + 'name' => 'Disable admin bar', + 'code' => 'show_admin_bar( false );', + 'scope' => 'global', + ] + ) + ); + } + + /** + * Build a search request. + * + * @param array $params Query parameters. + * + * @return WP_REST_Request + */ + private function make_request( array $params ): WP_REST_Request { + $request = new WP_REST_Request( 'GET', '/' . Search_REST_Controller::get_base_route() ); + $request->set_query_params( $params ); + + return $request; + } + + /** + * The route is registered under the plugin's namespace. + * + * @return void + */ + public function test_route_is_registered() { + $this->assertArrayHasKey( '/code-snippets/v1/search', rest_get_server()->get_routes() ); + } + + /** + * Results are grouped by the source they came from. + * + * @return void + */ + public function test_returns_results_grouped_by_source() { + $response = rest_do_request( + $this->make_request( + [ + 'q' => ' admin bar ', + 'sources' => 'snippets,settings', + ] + ) + ); + + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'admin bar', $data['query'] ); + $this->assertSame( [ 'snippets', 'settings' ], array_column( $data['groups'], 'source' ) ); + $this->assertSame( 'Disable admin bar', $data['groups'][0]['items'][0]['title'] ); + } + + /** + * Markup and code in the term reach the matcher intact, so searching for + * code finds the snippets containing it. + * + * @return void + */ + public function test_code_in_the_term_is_kept_intact() { + save_snippet( + new Snippet( + [ + 'name' => 'Notice markup', + 'code' => '
Hello
', + 'scope' => 'content', + ] + ) + ); + + foreach ( [ '
', 'show_admin_bar( false', "make_request( + [ + 'q' => $term, + 'sources' => 'snippets', + ] + ) + ); + + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNotEmpty( $data['groups'], "No results for $term" ); + $this->assertCount( 1, $data['groups'][0]['items'], "Wrong results for $term" ); + } + } + + /** + * A term too short to search for is answered with no groups. + * + * @return void + */ + public function test_short_term_returns_no_groups() { + $response = rest_do_request( $this->make_request( [ 'q' => 'a' ] ) ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( [], $response->get_data()['groups'] ); + } + + /** + * The search term is required. + * + * @return void + */ + public function test_requires_a_search_term() { + $this->assertSame( 400, rest_do_request( $this->make_request( [] ) )->get_status() ); + } + + /** + * An unknown source is rejected rather than ignored. + * + * @return void + */ + public function test_rejects_unknown_sources() { + $response = rest_do_request( + $this->make_request( + [ + 'q' => 'admin', + 'sources' => 'snippets,users', + ] + ) + ); + + $this->assertSame( 400, $response->get_status() ); + } + + /** + * Only the known ways of searching the cloud are accepted. + * + * @return void + */ + public function test_accepts_only_known_search_methods() { + $valid = rest_do_request( + $this->make_request( + [ + 'q' => 'admin', + 'sources' => 'settings', + 'method' => 'ai', + ] + ) + ); + + $invalid = rest_do_request( + $this->make_request( + [ + 'q' => 'admin', + 'method' => 'telepathy', + ] + ) + ); + + $this->assertSame( 200, $valid->get_status() ); + $this->assertSame( 400, $invalid->get_status() ); + } + + /** + * Someone without access to snippets cannot search. + * + * @return void + */ + public function test_denies_users_without_snippet_access() { + wp_set_current_user( self::$subscriber_user_id ); + + $this->assertSame( 403, rest_do_request( $this->make_request( [ 'q' => 'admin' ] ) )->get_status() ); + } + + /** + * A signed-out visitor cannot search. + * + * @return void + */ + public function test_denies_signed_out_visitors() { + wp_set_current_user( 0 ); + + $this->assertSame( 401, rest_do_request( $this->make_request( [ 'q' => 'admin' ] ) )->get_status() ); + } +} diff --git a/tests/unit/Search/Search_Query_Test.php b/tests/unit/Search/Search_Query_Test.php new file mode 100644 index 000000000..ccaac3adf --- /dev/null +++ b/tests/unit/Search/Search_Query_Test.php @@ -0,0 +1,183 @@ +assertFalse( ( new Search_Query( 'a' ) )->is_searchable() ); + $this->assertFalse( ( new Search_Query( ' ' ) )->is_searchable() ); + $this->assertTrue( ( new Search_Query( 'ab' ) )->is_searchable() ); + } + + /** + * A query with no recognised fields has nothing to search. + * + * @return void + */ + public function test_unrecognised_fields_are_ignored() { + $query = new Search_Query( 'admin', [ 'title', 'author' ] ); + + $this->assertSame( [ 'title' ], $query->get_fields() ); + $this->assertFalse( ( new Search_Query( 'admin', [ 'author' ] ) )->is_searchable() ); + } + + /** + * Matching ignores case and reports each field that contains the term. + * + * @return void + */ + public function test_reports_the_fields_that_match() { + $query = new Search_Query( 'Admin Bar' ); + + $matched = $query->matched_fields( + [ + 'title' => 'Disable admin bar', + 'description' => 'Nothing relevant here.', + 'code' => 'show_ADMIN_bar( false );', + ] + ); + + $this->assertSame( [ 'title', 'code' ], $matched ); + } + + /** + * The words of a term may be spread across different fields, in any order. + * + * @return void + */ + public function test_words_may_be_spread_across_fields() { + $query = new Search_Query( 'toolbar disable' ); + + $matched = $query->matched_fields( + [ + 'title' => 'Disable admin bar', + 'description' => 'Hides the toolbar for subscribers.', + ] + ); + + $this->assertSame( [ 'title', 'description' ], $matched ); + } + + /** + * Nothing matches unless every word of the term is found. + * + * @return void + */ + public function test_every_word_must_be_found() { + $query = new Search_Query( 'admin footer' ); + + $this->assertSame( + [], + $query->matched_fields( + [ + 'title' => 'Disable admin bar', + 'description' => 'Hides the toolbar.', + ] + ) + ); + } + + /** + * A field that is not being searched cannot produce a match. + * + * @return void + */ + public function test_only_searches_the_requested_fields() { + $query = new Search_Query( 'show_admin_bar', [ 'title', 'description' ] ); + + $this->assertSame( + [], + $query->matched_fields( + [ + 'title' => 'Disable admin bar', + 'code' => 'show_admin_bar( false );', + ] + ) + ); + } + + /** + * Characters with a meaning in regular expressions are matched literally. + * + * @return void + */ + public function test_special_characters_are_matched_literally() { + $query = new Search_Query( '$wp->add(' ); + + $this->assertSame( [ 'code' ], $query->matched_fields( [ 'code' => 'return $wp->add( 1 );' ] ) ); + $this->assertSame( [], $query->matched_fields( [ 'code' => 'return $wp_add;' ] ) ); + } + + /** + * Markup is flattened to a single line of plain text. + * + * @return void + */ + public function test_plain_text_strips_markup() { + $this->assertSame( + 'Hides the bar & more', + Search_Query::plain_text( "

Hides the bar\n & more

" ) + ); + } + + /** + * Short text is returned whole. + * + * @return void + */ + public function test_excerpt_leaves_short_text_alone() { + $this->assertSame( 'Hides the toolbar.', ( new Search_Query( 'toolbar' ) )->excerpt( 'Hides the toolbar.' ) ); + } + + /** + * A match far into long text is brought into the excerpt. + * + * @return void + */ + public function test_excerpt_keeps_a_late_match_in_view() { + $text = str_repeat( 'lorem ipsum dolor ', 30 ) . 'the needle is here ' . str_repeat( 'sit amet ', 30 ); + $excerpt = ( new Search_Query( 'needle' ) )->excerpt( $text ); + + $this->assertStringContainsString( 'needle', $excerpt ); + $this->assertStringStartsWith( '…', $excerpt ); + $this->assertStringEndsWith( '…', $excerpt ); + $this->assertLessThan( 200, mb_strlen( $excerpt ) ); + } + + /** + * The code excerpt is the line containing the most words of the term, with + * the lines either side of it. + * + * @return void + */ + public function test_finds_the_most_relevant_line_of_code() { + $code = "add_action( 'admin_init', function () {\n\t// nothing\n\tshow_admin_bar( false );\n} );"; + + $this->assertSame( + [ + 'line' => 3, + 'text' => 'show_admin_bar( false );', + 'before' => '// nothing', + 'after' => '} );', + ], + ( new Search_Query( 'admin bar' ) )->find_code_line( $code ) + ); + + $this->assertSame( '', ( new Search_Query( 'admin_init' ) )->find_code_line( $code )['before'] ); + + $this->assertNull( ( new Search_Query( 'missing' ) )->find_code_line( $code ) ); + } +} diff --git a/tests/unit/Search/Universal_Search_Test.php b/tests/unit/Search/Universal_Search_Test.php new file mode 100644 index 000000000..4b507a04f --- /dev/null +++ b/tests/unit/Search/Universal_Search_Test.php @@ -0,0 +1,541 @@ +user->create( [ 'role' => 'editor' ] ); + } + + /** + * Set up before each test. + * + * @return void + */ + public function set_up() { + parent::set_up(); + + $this->search_methods = []; + Universal_Search::clear_caches(); + + add_filter( 'pre_http_request', [ $this, 'mock_cloud_request' ], 10, 3 ); + + $this->truncate_snippets_table(); + + save_snippet( + new Snippet( + [ + 'name' => 'Disable admin bar', + 'desc' => '

Turns off the toolbar for subscribers.

', + 'code' => "add_action( 'after_setup_theme', function () {\n\tshow_admin_bar( false );\n} );", + 'scope' => 'global', + ] + ) + ); + + save_snippet( + new Snippet( + [ + 'name' => 'Red background', + 'desc' => '', + 'code' => 'body { background: red; }', + 'scope' => 'site-css', + ] + ) + ); + } + + /** + * Clear all snippets from the database. + * + * @return void + */ + private function truncate_snippets_table(): void { + global $wpdb; + $table_name = code_snippets()->db->get_table_name(); + $wpdb->query( "TRUNCATE TABLE $table_name" ); + clean_snippets_cache( $table_name ); + } + + /** + * Tear down after each test. + * + * @return void + */ + public function tear_down() { + remove_filter( 'pre_http_request', [ $this, 'mock_cloud_request' ] ); + Universal_Search::clear_caches(); + + parent::tear_down(); + } + + /** + * Intercept requests to Code Snippets Cloud. + * + * @param mixed $preempt Existing preempted value. + * @param array $parsed_args Parsed HTTP request arguments. + * @param string $url Requested URL. + * + * @return mixed + */ + public function mock_cloud_request( $preempt, array $parsed_args, string $url ) { + if ( false === strpos( $url, 'public/search' ) ) { + return $preempt; + } + + wp_parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $params ); + $this->search_methods[] = $params['s_method'] ?? ''; + + $snippet = [ + 'id' => 501, + 'name' => 'Colour the admin bar', + 'description' => 'Red on staging, blue on production.', + 'code' => '#wpadminbar { background: red; }', + 'scope' => 'admin-css', + 'codevault' => 'jlewis', + ]; + + return [ + 'headers' => [], + 'body' => wp_json_encode( [ 'snippets' => [ $snippet ] ] ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'cookies' => [], + ]; + } + + /** + * Create a search service with a fixed documentation index. + * + * @return Universal_Search + */ + private function make_search(): Universal_Search { + $docs_client = $this->createStub( Docs_Client::class ); + + $docs_client->method( 'get_articles' )->willReturn( + [ + [ + 'id' => 'a1b2c3d4e5f6', + 'title' => 'Using the admin bar menu', + 'url' => 'https://codesnippets.pro/doc/admin-bar/', + 'text' => 'Activate, deactivate and edit snippets from the admin bar.', + 'category' => 'Features', + ], + ] + ); + + return new Universal_Search( new Cloud_Search_Controller( code_snippets()->cloud_connection ), $docs_client ); + } + + /** + * Search and return the items found for a single source. + * + * @param string $term Search term. + * @param string $source Source to search. + * @param string[] $fields Fields to match against. + * + * @return array> + */ + private function search_source( string $term, string $source, array $fields = Search_Query::FIELDS ): array { + $groups = $this->make_search()->search( new Search_Query( $term, $fields ), [ $source ] ); + + return $groups ? $groups[0]['items'] : []; + } + + /** + * A term too short to search for returns nothing. + * + * @return void + */ + public function test_short_term_returns_no_groups() { + $this->assertSame( [], $this->make_search()->search( new Search_Query( 'a' ), Universal_Search::get_sources() ) ); + } + + /** + * A snippet is found by its title, and described in plain text. + * + * @return void + */ + public function test_finds_snippet_by_title() { + $items = $this->search_source( 'admin bar', 'snippets' ); + + $this->assertCount( 1, $items ); + $this->assertSame( 'Disable admin bar', $items[0]['title'] ); + $this->assertSame( 'php', $items[0]['type'] ); + $this->assertSame( 'Turns off the toolbar for subscribers.', $items[0]['description'] ); + $this->assertSame( 'Functions', $items[0]['path'] ); + $this->assertContains( 'title', $items[0]['matched'] ); + $this->assertStringContainsString( 'id=' . $items[0]['snippet']['id'], $items[0]['url'] ); + $this->assertFalse( $items[0]['snippet']['active'] ); + } + + /** + * A match in code carries the line it was found on. + * + * @return void + */ + public function test_code_match_includes_the_matching_line() { + $items = $this->search_source( 'show_admin_bar', 'snippets' ); + + $this->assertCount( 1, $items ); + $this->assertSame( [ 'code' ], $items[0]['matched'] ); + $this->assertSame( 2, $items[0]['code']['line'] ); + $this->assertSame( 'show_admin_bar( false );', $items[0]['code']['text'] ); + } + + /** + * Leaving code out of the fields leaves code matches out of the results. + * + * @return void + */ + public function test_respects_the_requested_fields() { + $this->assertSame( [], $this->search_source( 'show_admin_bar', 'snippets', [ 'title', 'description' ] ) ); + } + + /** + * Trashed snippets are not returned. + * + * @return void + */ + public function test_skips_trashed_snippets() { + $items = $this->search_source( 'Red background', 'snippets' ); + $this->assertCount( 1, $items ); + + trash_snippet( $items[0]['snippet']['id'] ); + + $this->assertSame( [], $this->search_source( 'Red background', 'snippets' ) ); + } + + /** + * Someone who may only view snippets can find them by name, but never by code. + * + * @return void + */ + public function test_code_is_withheld_from_users_who_cannot_edit() { + add_filter( 'code_snippets/search/can_read_code', '__return_false' ); + + $code_items = $this->search_source( 'show_admin_bar', 'snippets' ); + $items = $this->search_source( 'admin bar', 'snippets' ); + $community_items = $this->search_source( 'admin bar', 'community' ); + + remove_filter( 'code_snippets/search/can_read_code', '__return_false' ); + + $this->assertSame( [], $code_items ); + $this->assertCount( 1, $items ); + $this->assertArrayNotHasKey( 'code', $items[0] ); + $this->assertArrayNotHasKey( 'cloud', $community_items[0] ); + } + + /** + * Someone with no access to snippets finds none. + * + * @return void + */ + public function test_snippets_are_hidden_without_view_permission() { + wp_set_current_user( self::$viewer_user_id ); + + $this->assertSame( [], $this->search_source( 'admin bar', 'snippets' ) ); + } + + /** + * A setting links to its tab, and to its own row within it. + * + * @return void + */ + public function test_setting_links_to_its_tab_and_field() { + $items = $this->search_source( 'Enable Admin Bar Menu', 'settings' ); + + $this->assertCount( 1, $items ); + $this->assertSame( 'setting', $items[0]['type'] ); + $this->assertSame( 'Settings › Interface', $items[0]['path'] ); + $this->assertStringContainsString( 'page=snippets-settings', $items[0]['url'] ); + $this->assertStringContainsString( 'section=interface', $items[0]['url'] ); + $this->assertStringEndsWith( '#setting-enable_admin_bar', $items[0]['url'] ); + } + + /** + * A setting is found by the text describing it. + * + * @return void + */ + public function test_setting_is_found_by_description() { + $items = $this->search_source( 'instead of spaces', 'settings' ); + + $this->assertCount( 1, $items ); + $this->assertSame( 'Indent With Tabs', $items[0]['title'] ); + $this->assertSame( [ 'description' ], $items[0]['matched'] ); + } + + /** + * Settings are only offered to someone who can open the settings screen. + * + * @return void + */ + public function test_settings_are_hidden_from_non_administrators() { + wp_set_current_user( self::$viewer_user_id ); + + $this->assertSame( [], $this->search_source( 'admin bar', 'settings' ) ); + } + + /** + * Stats reports link to their own chart on the Stats screen. + * + * @return void + */ + public function test_finds_stats_reports() { + $items = $this->search_source( 'Activation status', 'stats' ); + + $this->assertCount( 1, $items ); + $this->assertSame( 'stat', $items[0]['type'] ); + $this->assertStringContainsString( 'page=code-snippets-stats', $items[0]['url'] ); + $this->assertStringEndsWith( '#stats-chart-activation', $items[0]['url'] ); + } + + /** + * The welcome page sections are searchable. + * + * @return void + */ + public function test_finds_welcome_sections() { + $items = $this->search_source( 'Helpful articles', 'welcome' ); + + $this->assertNotEmpty( $items ); + $this->assertSame( 'guide', $items[0]['type'] ); + $this->assertStringContainsString( 'page=code-snippets-welcome', $items[0]['url'] ); + } + + /** + * Documentation articles are matched on their title and excerpt. + * + * @return void + */ + public function test_finds_docs_articles() { + $items = $this->search_source( 'deactivate', 'docs' ); + + $this->assertCount( 1, $items ); + $this->assertSame( 'doc', $items[0]['type'] ); + $this->assertSame( 'Docs › Features', $items[0]['path'] ); + $this->assertSame( 'https://codesnippets.pro/doc/admin-bar/', $items[0]['url'] ); + $this->assertSame( [ 'description' ], $items[0]['matched'] ); + } + + /** + * The community is searched without a cloud connection. + * + * @return void + */ + public function test_searches_the_community() { + $groups = $this->make_search()->search( new Search_Query( 'admin bar' ), [ 'community' ] ); + + $this->assertSame( [ 'community' ], array_column( $groups, 'source' ) ); + + $item = $groups[0]['items'][0]; + $this->assertSame( 'Colour the admin bar', $item['title'] ); + $this->assertSame( 'css', $item['type'] ); + $this->assertSame( 'Community · by jlewis', $item['path'] ); + $this->assertSame( 501, $item['cloud']['id'] ); + $this->assertSame( '', $item['url'] ); + $this->assertSame( [ 'term' ], $this->search_methods ); + } + + /** + * A community snippet already downloaded to this site links to its local copy. + * + * @return void + */ + public function test_downloaded_community_snippet_links_to_its_local_copy() { + $local = save_snippet( + new Snippet( + [ + 'name' => 'Downloaded copy', + 'code' => '#wpadminbar { background: red; }', + 'scope' => 'admin-css', + 'cloud_id' => 501, + ] + ) + ); + + $item = $this->search_source( 'admin bar', 'community' )[0]; + + $this->assertSame( $local->id, $item['cloud']['local_id'] ); + $this->assertStringContainsString( 'id=' . $local->id, $item['url'] ); + } + + /** + * An AI search is sent to the cloud as one, and keeps results that share no words with the question. + * + * @return void + */ + public function test_ai_search_keeps_the_cloud_results() { + $query = new Search_Query( 'stop the toolbar showing for clients', Search_Query::FIELDS, true, 'ai' ); + $groups = $this->make_search()->search( $query, [ 'community' ] ); + + $this->assertSame( [ 'ai' ], $this->search_methods ); + $this->assertSame( 'Colour the admin bar', $groups[0]['items'][0]['title'] ); + $this->assertSame( [], $groups[0]['items'][0]['matched'] ); + } + + /** + * A keyword search narrowed to particular fields only keeps the community results matching in them. + * + * @return void + */ + public function test_narrowed_keyword_search_filters_community_results() { + $this->assertSame( [], $this->search_source( 'staging', 'community', [ 'title' ] ) ); + $this->assertCount( 1, $this->search_source( 'staging', 'community', [ 'description' ] ) ); + } + + /** + * Repeating a search reuses the cloud results fetched for the first one, but an AI search is fetched on its own. + * + * @return void + */ + public function test_community_results_are_cached_by_term_and_method() { + $search = $this->make_search(); + + $search->search( new Search_Query( 'admin bar' ), [ 'community' ] ); + $search->search( new Search_Query( 'Admin Bar' ), [ 'community' ] ); + $this->assertSame( [ 'term' ], $this->search_methods ); + + $search->search( new Search_Query( 'admin bar', Search_Query::FIELDS, true, 'ai' ), [ 'community' ] ); + $this->assertSame( [ 'term', 'ai' ], $this->search_methods ); + } + + /** + * An unknown search method falls back to a keyword search. + * + * @return void + */ + public function test_unknown_method_is_a_keyword_search() { + $this->assertSame( 'term', ( new Search_Query( 'admin bar', Search_Query::FIELDS, true, 'other' ) )->get_method() ); + } + + /** + * A source that has not been added is not searched. + * + * @return void + */ + public function test_skips_sources_that_have_not_been_added() { + $this->assertNotContains( 'cloud', Universal_Search::get_sources() ); + $this->assertSame( [], $this->make_search()->search( new Search_Query( 'admin bar' ), [ 'cloud' ] ) ); + $this->assertSame( [], $this->search_methods ); + } + + /** + * A source added through the filters is searched like the built-in ones. + * + * @return void + */ + public function test_filters_can_add_a_source() { + $add_source = fn( array $sources ) => array_merge( $sources, [ 'extra' ] ); + + $search_source = function ( array $items, string $source, Search_Query $query, bool $network, Universal_Search $search ) { + if ( 'extra' !== $source ) { + return $items; + } + + $entry = [ + 'id' => 'one', + 'type' => 'guide', + 'title' => 'Extra admin bar guide', + 'description' => '', + 'url' => 'https://example.com/', + 'path' => 'Extra', + ]; + + return $search->search_entries( [ $entry ], $source, $query, $network ); + }; + + add_filter( 'code_snippets/search/sources', $add_source ); + add_filter( 'code_snippets/search/source_items', $search_source, 10, 5 ); + + $groups = $this->make_search()->search( new Search_Query( 'admin bar' ), [ 'extra' ] ); + + remove_filter( 'code_snippets/search/sources', $add_source ); + remove_filter( 'code_snippets/search/source_items', $search_source ); + + $this->assertSame( 'extra', $groups[0]['source'] ); + $this->assertSame( 'extra-one', $groups[0]['items'][0]['id'] ); + $this->assertSame( [ 'title' ], $groups[0]['items'][0]['matched'] ); + } + + /** + * Entries added to a source's index are searched with it. + * + * @return void + */ + public function test_filters_can_add_index_entries() { + $add_entry = function ( array $entries, string $source ) { + if ( 'stats' === $source ) { + $entries[] = [ + 'id' => 'extra', + 'type' => 'stat', + 'title' => 'Execution time', + 'description' => 'How long snippets take to run.', + 'url' => 'https://example.com/', + 'path' => 'Stats', + ]; + } + + return $entries; + }; + + add_filter( 'code_snippets/search/entries', $add_entry, 10, 2 ); + $items = $this->search_source( 'Execution time', 'stats' ); + remove_filter( 'code_snippets/search/entries', $add_entry ); + + $this->assertSame( 'stats-extra', $items[0]['id'] ); + } + + /** + * Results are capped per source, while the total still counts every match. + * + * @return void + */ + public function test_limits_items_but_reports_the_total() { + $groups = $this->make_search()->search( new Search_Query( 'the' ), [ 'settings' ], false, 2 ); + + $this->assertCount( 2, $groups[0]['items'] ); + $this->assertGreaterThan( 2, $groups[0]['total'] ); + } +} From 3d6fb315923ad1e2f3ffe70d7362a3263840a29d Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Sun, 18 Oct 2026 11:11:47 +0100 Subject: [PATCH 02/13] feat: add the universal search bar and results page, with Search with AI and the Cloud Library upgrade prompt --- src/css/common/_badges.scss | 1 + src/css/common/_theme.scss | 2 + src/css/common/_toolbar.scss | 2 + src/css/common/_universal-search.scss | 495 +++++++++++++++ src/css/manage.scss | 1 + src/css/manage/_search-results.scss | 225 +++++++ .../SearchResults/SearchResultActions.tsx | 175 ++++++ .../SearchResults/SearchResults.tsx | 391 ++++++++++++ .../ManageMenu/SnippetsTable/RowActions.tsx | 4 +- src/js/components/ManageMenu/subpages.tsx | 4 +- src/js/components/StatsMenu/StatsCharts.tsx | 3 +- src/js/components/StatsMenu/StatsMenu.tsx | 5 +- src/js/components/WelcomeMenu/Changelog.tsx | 2 +- src/js/components/WelcomeMenu/WelcomeMenu.tsx | 46 +- src/js/components/common/Badge.tsx | 1 + src/js/components/common/Toolbar.tsx | 3 + .../UniversalSearch/SearchResultParts.tsx | 148 +++++ .../UniversalSearch/UniversalSearch.tsx | 595 ++++++++++++++++++ .../cloud/CloudSnippetDownloadButton.tsx | 58 +- src/js/hooks/useUniversalSearch.ts | 204 ++++++ src/js/types/Search.ts | 63 ++ src/js/types/Window.ts | 2 + src/js/utils/restAPI.ts | 3 +- src/js/utils/text.ts | 29 + src/js/utils/urls.ts | 16 + 25 files changed, 2430 insertions(+), 48 deletions(-) create mode 100644 src/css/common/_universal-search.scss create mode 100644 src/css/manage/_search-results.scss create mode 100644 src/js/components/ManageMenu/SearchResults/SearchResultActions.tsx create mode 100644 src/js/components/ManageMenu/SearchResults/SearchResults.tsx create mode 100644 src/js/components/common/UniversalSearch/SearchResultParts.tsx create mode 100644 src/js/components/common/UniversalSearch/UniversalSearch.tsx create mode 100644 src/js/hooks/useUniversalSearch.ts create mode 100644 src/js/types/Search.ts diff --git a/src/css/common/_badges.scss b/src/css/common/_badges.scss index cd0849693..f70c32cb6 100644 --- a/src/css/common/_badges.scss +++ b/src/css/common/_badges.scss @@ -78,6 +78,7 @@ $badges: ( core: #0ca0a9, pro: #f7e8e3 #df9279, revisions: #50575e, + ai: #dfc5ef #6e249c, cloud: #009fb4, bundles: #50575e, cloud_search: #d27c00, diff --git a/src/css/common/_theme.scss b/src/css/common/_theme.scss index 1ead0310f..5affb5ab5 100644 --- a/src/css/common/_theme.scss +++ b/src/css/common/_theme.scss @@ -18,8 +18,10 @@ --cs-color-border-subtle: #dcdcde; --cs-color-info: #72aee6; --cs-color-success: #00a32a; + --cs-color-success-text: #007017; --cs-color-warning: #dba617; --cs-color-error: #d63638; + --cs-color-highlight: #fcecb4; --cs-cloud-status-public-color: #64baba; --cs-cloud-status-private-color: #cc96fb; --cs-cloud-status-unverified-color: #ea835e; diff --git a/src/css/common/_toolbar.scss b/src/css/common/_toolbar.scss index 03acfb063..77a57a2c8 100644 --- a/src/css/common/_toolbar.scss +++ b/src/css/common/_toolbar.scss @@ -1,4 +1,5 @@ @use 'upsell'; +@use 'universal-search'; #wpbody { padding-block-start: var(--cs-toolbar-block-size); @@ -25,6 +26,7 @@ .code-snippets-toolbar-upper { justify-content: space-between; + gap: 28px; block-size: 76px; box-sizing: border-box; padding-block: 16px; diff --git a/src/css/common/_universal-search.scss b/src/css/common/_universal-search.scss new file mode 100644 index 000000000..7d7f61c0c --- /dev/null +++ b/src/css/common/_universal-search.scss @@ -0,0 +1,495 @@ +@use 'theme'; +@use 'badges'; + +// Below this width the header cannot fit the search field beside its links, +// so the field collapses to a button that opens it over them. +$collapse-width: 1160px; + +.universal-search { + position: relative; + flex: 1; + display: flex; + justify-content: center; + min-inline-size: 0; + font-size: var(--cs-font-size-body); + + mark { + background: var(--cs-color-highlight); + color: inherit; + border-radius: 2px; + } +} + +.universal-search-field { + position: relative; + display: flex; + align-items: center; + inline-size: 100%; + max-inline-size: 500px; + + > .dashicons-search { + position: absolute; + inset-inline-start: 10px; + color: var(--cs-color-text-muted); + pointer-events: none; + } + + // The shared control styles in `_wp-admin.scss` set the border, radius and focus + // ring; this selector outranks them only to make room for the icon and key hint. + .code-snippets-toolbar & input.universal-search-input[type='search'] { + inline-size: 100%; + margin: 0; + padding-inline: 36px 40px; + background: var(--cs-color-surface); + line-height: normal; + } + + &:focus-within > .dashicons-search { + color: var(--cs-color-accent); + } + + kbd { + position: absolute; + inset-inline-end: 10px; + padding-block: 1px; + padding-inline: 6px; + border: 1px solid var(--cs-color-border); + border-radius: 3px; + background: none; + color: var(--cs-color-text-muted); + font-size: 11px; + line-height: 1.4; + pointer-events: none; + } +} + +.universal-search-toggle, +.universal-search-collapse { + display: none; + align-items: center; + justify-content: center; + inline-size: var(--cs-control-height); + block-size: var(--cs-control-height); + padding: 0; + border: 0; + border-radius: var(--cs-control-radius); + background: none; + color: var(--cs-color-text); + cursor: pointer; + + &:hover { + color: var(--cs-color-accent); + } + + &:focus-visible { + outline: 2px solid var(--cs-color-accent); + } +} + +.universal-search-dropdown { + position: absolute; + inset-block-start: calc(100% + 8px); + inset-inline-start: 50%; + transform: translateX(calc(-50% * var(--cs-direction-multiplier))); + z-index: 1000; + display: flex; + flex-direction: column; + inline-size: 700px; + max-inline-size: calc(100vw - 48px); + overflow: hidden; + border: 1px solid var(--cs-color-border); + border-radius: var(--cs-card-radius); + background: var(--cs-color-surface); + box-shadow: 0 12px 32px rgb(0 0 0 / 16%); + color: var(--cs-color-text); + + &[hidden] { + display: none; + } +} + +.universal-search-scopes { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding-block: 12px; + padding-inline: 16px; + border-block-end: 1px solid var(--cs-color-surface-muted); +} + +.universal-search-scope { + display: flex; + gap: 5px; + padding-block: 3px; + padding-inline: 10px; + border: 1px solid var(--cs-color-border); + border-radius: 999px; + background: var(--cs-color-surface); + color: var(--cs-color-text); + font-size: 12px; + line-height: 1.5; + cursor: pointer; + + &.is-active { + border-color: var(--cs-color-accent); + background: var(--cs-color-accent); + color: var(--cs-color-surface); + } + + &:focus-visible { + outline: 2px solid var(--cs-color-accent); + outline-offset: 1px; + } + + .universal-search-count { + opacity: 0.8; + } +} + +.universal-search-results { + max-block-size: min(560px, calc(100vh - 260px)); + overflow-y: auto; + padding-block-end: 6px; +} + +.universal-search-group-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-block: 12px 4px; + padding-inline: 16px; + color: var(--cs-color-text-muted); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + + .universal-search-count { + margin-inline-start: 6px; + color: var(--cs-color-text-disabled); + } +} + +.code-snippets-toolbar-upper .universal-search-option { + display: flex; + gap: 12px; + align-items: flex-start; + margin-block-start: -1px; + padding-block: 8px; + padding-inline: 16px; + border-block: 1px solid var(--cs-color-border-subtle); + color: var(--cs-color-text); + scroll-margin-block: 8px; + + &.is-active, + &:hover { + background: var(--cs-color-surface-info); + color: var(--cs-color-text); + } + + .badge { + flex: none; + min-block-size: 20px; + margin-block-start: 1px; + padding-inline: 6px; + font-size: 11px; + } +} + +.universal-search-option-text { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-inline-size: 0; +} + +.universal-search-option-title, +.universal-search-option-description { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.universal-search-option-title { + font-size: 14px; + font-weight: 500; +} + +.universal-search-option-description { + color: var(--cs-color-text-muted); + font-size: 12.5px; +} + +// The preview only shows what matched, so it never takes the pointer from the row beneath it. +.universal-search-code-popover { + pointer-events: none; + + .components-popover__content { + padding: 0; + overflow: hidden; + border-radius: var(--cs-control-radius); + } +} + +.universal-search-code-preview { + display: grid; + max-inline-size: min(560px, 80vw); + padding-block: 6px; + background: var(--cs-color-surface-subtle); + color: var(--cs-color-text-secondary); + font-family: Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.7; + + // The popover is rendered outside the search bar, so it needs its own highlight. + mark { + border-radius: 2px; + background: var(--cs-color-highlight); + color: inherit; + } +} + +.universal-search-code-preview-text { + overflow: hidden; + text-overflow: ellipsis; +} + +.universal-search-code-preview-line { + display: flex; + gap: 12px; + padding-inline: 12px; + white-space: pre; + + &.is-context .universal-search-code-preview-text { + filter: blur(2.5px); + user-select: none; + } +} + +.universal-search-code-preview-number { + flex: none; + min-inline-size: 2ch; + color: var(--cs-color-text-disabled); + text-align: end; + user-select: none; +} + +.universal-search-pending { + display: flex; + gap: 12px; + align-items: center; + padding-block: 12px 8px; + padding-inline: 16px; + color: var(--cs-color-text-subtle); + font-size: 12px; + animation: universal-search-pulse 1.2s infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +.universal-search-pending-badge, +.universal-search-pending-bar { + background: var(--cs-color-surface-muted); +} + +.universal-search-pending-badge { + inline-size: 44px; + block-size: 18px; + border-radius: 4px; +} + +.universal-search-pending-bar { + flex: 1; + max-inline-size: 320px; + block-size: 10px; + border-radius: 5px; +} + +@keyframes universal-search-pulse { + 0%, + 100% { + opacity: 0.45; + } + + 50% { + opacity: 1; + } +} + +.universal-search-empty { + margin: 0; + padding-block: 28px; + padding-inline: 16px; + color: var(--cs-color-text-muted); + font-size: 14px; + text-align: center; +} + +.universal-search-cloud-notice { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-block: 10px 4px; + margin-inline: 16px; + padding-block: 10px; + padding-inline: 12px; + border: 1px dashed var(--cs-color-border); + border-radius: 6px; + color: var(--cs-color-text-secondary); + font-size: 13px; + line-height: 1.5; + + a { + flex: none; + font-weight: 600; + } + + &.is-upsell { + justify-content: flex-start; + border-style: solid; + border-color: var(--cs-color-border-subtle); + background: var(--cs-color-surface-subtle); + + > span:not(.badge) { + flex: 1; + } + } +} + +.universal-search-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding-block: 9px; + padding-inline: 16px; + border-block-start: 1px solid var(--cs-color-border-subtle); + background: var(--cs-color-surface-subtle); + color: var(--cs-color-text-muted); + font-size: 12px; + + a { + font-size: 13px; + font-weight: 600; + } +} + +// The header's own link colours would otherwise apply inside the dropdown, +// which is nested in it; these selectors outrank them. +.code-snippets-toolbar-upper .universal-search-group-header a { + font-size: 12px; + font-weight: 500; + letter-spacing: 0; + text-transform: none; +} + +.code-snippets-toolbar-upper .universal-search-group-header a, +.code-snippets-toolbar-upper .universal-search-footer a, +.code-snippets-toolbar-upper .universal-search-cloud-notice a { + color: var(--cs-color-accent); + + &:hover { + color: var(--cs-color-accent-hover); + } +} + +@media (width <= $collapse-width) { + .code-snippets-toolbar-upper { + position: relative; + } + + .universal-search { + position: static; + flex: none; + margin-inline-start: auto; + } + + .code-snippets-toolbar-upper .universal-search + nav { + margin-inline-start: 0; + } + + .universal-search-toggle { + display: flex; + } + + .universal-search-field { + display: none; + } + + .universal-search.is-expanded { + .universal-search-field { + position: absolute; + inset-block-start: 50%; + inset-inline: 16px; + z-index: 3; + display: flex; + inline-size: auto; + max-inline-size: none; + transform: translateY(-50%); + background: var(--cs-color-surface); + + input.universal-search-input { + padding-inline-end: 12px; + } + + kbd { + display: none; + } + } + + .universal-search-collapse { + display: flex; + flex: none; + margin-inline-start: 4px; + } + + // The field now covers the header, so its backdrop hides the links beneath it. + &::before { + content: ''; + position: absolute; + inset: 0; + z-index: 2; + background: var(--cs-color-surface); + } + } + + .universal-search-dropdown { + inset-block-start: calc(100% - 10px); + inset-inline: 16px; + inline-size: auto; + max-inline-size: none; + transform: none; + } +} + +// Screens reached from a search result land on the element the result points +// at, which is picked out briefly so it can be found on the page. +.code-snippets-settings tr[id^='setting-']:target, +.stats-chart-card:target, +.code-snippets-welcome :target { + scroll-margin-block-start: 48px; + animation: universal-search-target 2.4s ease-out; + + @media (prefers-reduced-motion: reduce) { + outline: 2px solid var(--cs-color-accent); + outline-offset: 4px; + animation: none; + } +} + +@keyframes universal-search-target { + 0%, + 60% { + background-color: var(--cs-color-highlight); + box-shadow: 0 0 0 6px var(--cs-color-highlight); + } + + 100% { + background-color: transparent; + box-shadow: 0 0 0 6px transparent; + } +} diff --git a/src/css/manage.scss b/src/css/manage.scss index 5102b84b9..7d51e7597 100644 --- a/src/css/manage.scss +++ b/src/css/manage.scss @@ -14,6 +14,7 @@ @use 'manage/cloud-community'; @use 'manage/cloud-library'; @use 'manage/blueprints'; +@use 'manage/search-results'; @use 'common/page-header'; @use 'common/kebab-menu'; @use 'common/demo'; diff --git a/src/css/manage/_search-results.scss b/src/css/manage/_search-results.scss new file mode 100644 index 000000000..af3817057 --- /dev/null +++ b/src/css/manage/_search-results.scss @@ -0,0 +1,225 @@ +.search-results-page { + color: var(--cs-color-text); + + mark { + border-radius: 2px; + background: var(--cs-color-highlight); + color: inherit; + } + + .snippets-page-header { + justify-content: flex-start; + align-items: baseline; + gap: 12px; + } +} + +.search-results-total { + color: var(--cs-color-text-muted); + font-size: 14px; +} + +.search-results-fields { + display: flex; + flex-wrap: wrap; + gap: 8px 20px; + align-items: center; + margin-block: 4px 16px; + padding: 0; + border: 0; + + legend { + float: inline-start; + margin-inline-end: 4px; + padding: 0; + color: var(--cs-color-text-secondary); + font-weight: 600; + } +} + +.search-results-page .universal-search-cloud-notice { + margin-block: 0 16px; + margin-inline: 0; +} + +.search-results-upsell { + max-inline-size: 720px; +} + +.search-results-ai-intro { + margin-block: 0 16px; + color: var(--cs-color-text-secondary); +} + +.search-results-main { + display: flex; + flex-direction: column; + gap: 20px; +} + +.search-results-group { + overflow: hidden; + border: 1px solid var(--cs-color-border-subtle); + border-radius: var(--cs-card-radius); + background: var(--cs-color-surface); +} + +.search-results-group-heading { + display: flex; + gap: 8px; + align-items: baseline; + margin: 0; + padding-block: 18px 14px; + padding-inline: 24px; + border-block-end: 1px solid var(--cs-color-border-subtle); + font-size: 18px; + font-weight: 600; +} + +.search-results-count { + color: var(--cs-color-text-muted); + font-size: 14px; + font-weight: 600; +} + +.search-results-list { + margin: 0; +} + +.search-result { + display: flex; + gap: 16px; + align-items: flex-start; + margin: 0; + padding-block: 16px; + padding-inline: 24px; + + & + & { + border-block-start: 1px solid var(--cs-color-border-subtle); + } + + .badge { + flex: none; + min-inline-size: 52px; + margin-block-start: 1px; + } + + .row-actions { + padding-block-start: 2px; + color: var(--cs-color-text-subtle); + } + + .button-link { + font-size: 13px; + } + + .dashicons-external { + inline-size: 14px; + block-size: 14px; + margin-inline-start: 2px; + font-size: 14px; + vertical-align: text-bottom; + } +} + +.search-result-body { + flex: 1; + min-inline-size: 0; +} + +.search-result-title { + margin: 0; + font-size: 14px; + font-weight: 600; + line-height: 1.5; +} + +.search-result-title a, +.search-result .row-actions a, +.search-result .row-actions .button-link { + text-decoration: none; +} + +.search-result-description { + margin-block: 2px 0; + color: var(--cs-color-text-secondary); + font-size: 13px; + line-height: 1.5; +} + +.search-result-aside { + flex: none; + display: flex; + gap: 14px; + align-items: center; + max-inline-size: 40%; + min-block-size: 28px; +} + +.search-result-path { + overflow: hidden; + color: var(--cs-color-text-subtle); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.search-results-page .search-result-code-button { + display: inline-flex; + gap: 4px; + align-items: center; + white-space: nowrap; + + .dashicons { + inline-size: 16px; + block-size: 16px; + font-size: 16px; + } +} + +.search-result-error { + margin-inline-start: 8px; + color: var(--cs-color-error); +} + +.search-results-empty, +.search-results-pending { + margin: 0; + padding: 24px; + border: 1px solid var(--cs-color-border-subtle); + border-radius: var(--cs-card-radius); + background: var(--cs-color-surface); + color: var(--cs-color-text-muted); + font-size: 14px; +} + +.search-results-pending { + padding-block: 16px; + font-size: 13px; + animation: universal-search-pulse 1.2s infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +.search-results-truncated { + margin: 0; + padding-block: 12px; + padding-inline: 24px; + border-block-start: 1px solid var(--cs-color-border-subtle); + background: var(--cs-color-surface-subtle); + color: var(--cs-color-text-muted); + font-size: 12px; +} + +@media (width <= 782px) { + .search-result { + flex-wrap: wrap; + } + + .search-result-aside { + max-inline-size: none; + padding-inline-start: 68px; + } +} diff --git a/src/js/components/ManageMenu/SearchResults/SearchResultActions.tsx b/src/js/components/ManageMenu/SearchResults/SearchResultActions.tsx new file mode 100644 index 000000000..f1f65d93e --- /dev/null +++ b/src/js/components/ManageMenu/SearchResults/SearchResultActions.tsx @@ -0,0 +1,175 @@ +import { __, sprintf } from '@wordpress/i18n' +import React, { useState } from 'react' +import { useSnippetsAPI } from '../../../hooks/useSnippetsAPI' +import { handleUnknownError } from '../../../utils/errors' +import { isLicensed } from '../../../utils/screen' +import { getSnippetEditUrl, isProSnippet } from '../../../utils/snippets/snippets' +import { Button } from '../../common/Button' +import { useCloudSnippetDownload } from '../../common/cloud/CloudSnippetDownloadButton' +import { CloudSnippetPreviewModal, SnippetPreviewModal } from '../../common/snippets/SnippetPreviewModal' +import { SnippetActionButton } from '../SnippetsTable/RowActions' +import type { ReactNode } from 'react' +import type { CloudSnippetSchema } from '../../../types/schema/CloudSnippetSchema' +import type { SearchItem, SearchSnippetSummary } from '../../../types/Search' +import type { Snippet } from '../../../types/Snippet' + +/** + * Separate a result's actions with a bar, as the row actions of the snippets table are. + */ +const joinActions = (actions: ReactNode[]): ReactNode => + actions + .filter(action => action) + .reduce((joined, action) => null === joined ? action : <>{joined} | {action}, null) + +interface SnippetPreviewActionProps { + summary: SearchSnippetSummary +} + +/** + * Search results never carry a snippet's full code, so it is fetched when the preview is asked for. + */ +const SnippetPreviewAction: React.FC = ({ summary }) => { + const api = useSnippetsAPI() + const [snippet, setSnippet] = useState() + const [isOpen, setIsOpen] = useState(false) + + return ( + <> + api.fetch(summary.id, summary.network).then(fetched => { + setSnippet(fetched) + setIsOpen(true) + })} + /> + + {isOpen && snippet && } + + ) +} + +interface SnippetActionsProps { + item: SearchItem + summary: SearchSnippetSummary +} + +const SnippetActions: React.FC = ({ item, summary }) => + joinActions([ + + {summary.locked ? __('View', 'code-snippets') : __('Edit', 'code-snippets')} + , + + ]) + +export interface SnippetActivationSwitchProps { + item: SearchItem + summary: SearchSnippetSummary + isActive: boolean + setIsActive: (isActive: boolean) => void +} + +/** + * The same switch the snippets table uses to activate and deactivate a snippet. + */ +export const SnippetActivationSwitch: React.FC = ({ item, summary, isActive, setIsActive }) => { + const api = useSnippetsAPI() + const [isWorking, setIsWorking] = useState(false) + const label = isActive + // translators: %s: snippet name. + ? sprintf(__('Deactivate %s', 'code-snippets'), item.title) + // translators: %s: snippet name. + : sprintf(__('Activate %s', 'code-snippets'), item.title) + + return ( + { + setIsWorking(true) + + ;(isActive ? api.deactivate(summary) : api.activate(summary)) + .then(() => setIsActive(!isActive)) + .catch(handleUnknownError) + .finally(() => setIsWorking(false)) + }} + /> + ) +} + +interface CloudActionsProps { + item: SearchItem + snippet: CloudSnippetSchema +} + +const CloudActions: React.FC = ({ item, snippet }) => { + const [isPreviewOpen, setIsPreviewOpen] = useState(false) + const { isDownloading, localSnippetId, errorMessage, download } = useCloudSnippetDownload({ snippet }) + const canDownload = !isProSnippet(snippet) || isLicensed() + + return ( + <> + {joinActions([ + , + + localSnippetId + ? {__('Edit', 'code-snippets')} + : canDownload && ( + ), + + 'cloud' === item.source && ( + + {__('Open Cloud Dashboard', 'code-snippets')} + {__('(opens in a new tab)', 'code-snippets')} + ) + ])} + + {errorMessage && {errorMessage}} + + {isPreviewOpen && } + + ) +} + +export interface SearchResultActionsProps { + item: SearchItem +} + +export const SearchResultActions: React.FC = ({ item }) => { + if (item.snippet) { + return + } + + if (item.cloud) { + return + } + + switch (item.source) { + case 'settings': + return {__('Open setting', 'code-snippets')} + + case 'stats': + return {__('View stats', 'code-snippets')} + + case 'docs': + return ( + + {__('Read article', 'code-snippets')} + + {__('(opens in a new tab)', 'code-snippets')} + + ) + + default: + return item.url ? {__('Open', 'code-snippets')} : null + } +} diff --git a/src/js/components/ManageMenu/SearchResults/SearchResults.tsx b/src/js/components/ManageMenu/SearchResults/SearchResults.tsx new file mode 100644 index 000000000..ced4e9f0f --- /dev/null +++ b/src/js/components/ManageMenu/SearchResults/SearchResults.tsx @@ -0,0 +1,391 @@ +import { CheckboxControl } from '@wordpress/components' +import { __, sprintf } from '@wordpress/i18n' +import React, { useId, useState } from 'react' +import { WithRestAPIContext } from '../../../hooks/useRestAPI' +import { WithSnippetsAPIContext } from '../../../hooks/useSnippetsAPI' +import { isSearchSourceAvailable, useUniversalSearch } from '../../../hooks/useUniversalSearch' +import { SEARCH_FIELDS, SEARCH_PRO_SOURCES, SEARCH_SOURCES } from '../../../types/Search' +import { isLicensed, shouldShowUpsell } from '../../../utils/screen' +import { buildUrl, fetchQueryParam } from '../../../utils/urls' +import { WithCloudSnippetDownloadsContext } from '../../common/cloud/WithCloudSnippetDownloadsContext' +import { ScreenMetaSlot } from '../../common/ScreenMetaSlot' +import { SubnavTabs, getTabFromQuery } from '../../common/SubnavTabs' +import { + AI_SEARCH_TAB, + CloudSearchNotice, + Highlight, + PRICING_URL, + SEARCH_LOADING_LABELS, + SEARCH_SOURCE_LABELS, + SearchCodePreview, + SearchItemBadge, + formatResultCount, + getSearchItemUrl, + opensInNewTab +} from '../../common/UniversalSearch/SearchResultParts' +import { SearchResultActions, SnippetActivationSwitch } from './SearchResultActions' +import type { UniversalSearch } from '../../../hooks/useUniversalSearch' +import type { SearchCodeExcerpt, SearchField, SearchGroup, SearchItem, SearchSource } from '../../../types/Search' +import type { SubnavTab } from '../../common/SubnavTabs' + +type SourceFilter = 'all' | SearchSource | typeof AI_SEARCH_TAB + +const SOURCE_QUERY_PARAM = 'source' + +const RESULTS_PER_SOURCE = 50 + +const AI_SOURCES: readonly SearchSource[] = ['community'] + +const FIELD_LABELS: Record = { + title: __('Title', 'code-snippets'), + description: __('Description', 'code-snippets'), + code: __('Code', 'code-snippets') +} + +/** + * A Pro source is listed without a licence only to show what it offers, which + * is left out when upgrade prompts are turned off. + */ +const isSourceListed = (source: SearchSource): boolean => + isSearchSourceAvailable(source) || SEARCH_PRO_SOURCES.includes(source) && shouldShowUpsell() + +const getSourceTabs = (): SubnavTab[] => [ + { name: 'all', label: __('All results', 'code-snippets') }, + ...SEARCH_SOURCES + .filter(isSourceListed) + .map(source => ({ name: source, label: SEARCH_SOURCE_LABELS[source], pro: SEARCH_PRO_SOURCES.includes(source) })), + { name: AI_SEARCH_TAB, label: __('Search with AI', 'code-snippets') } +] + +const isLockedTab = (filter: SourceFilter): boolean => + 'all' !== filter && AI_SEARCH_TAB !== filter && SEARCH_PRO_SOURCES.includes(filter) && !isLicensed() + +const isPending = (loadingSources: SearchSource[], filter: SourceFilter): boolean => + loadingSources.some(source => 'all' === filter || source === filter) + +const getPendingLabels = (loadingSources: SearchSource[], filter: SourceFilter): string[] => + loadingSources + .filter(source => 'all' === filter || source === filter) + .flatMap(source => SEARCH_LOADING_LABELS[source] ?? []) + +const countResults = (groups: SearchGroup[]): number => + groups.reduce((total, group) => total + group.total, 0) + +interface CodeMatchButtonProps { + code: SearchCodeExcerpt + term: string +} + +/** + * Shows the line of code a result matched on, in the same preview as the search bar. + */ +const CodeMatchButton: React.FC = ({ code, term }) => { + const [anchor, setAnchor] = useState(null) + const [isOpen, setIsOpen] = useState(false) + + return ( + <> + + + {isOpen && anchor && } + + ) +} + +interface SearchResultProps { + item: SearchItem + term: string + fields: readonly SearchField[] +} + +const SearchResult: React.FC = ({ item, term, fields }) => { + const [isActive, setIsActive] = useState(item.snippet?.active ?? false) + const url = getSearchItemUrl(item, term) + + // Only the fields being matched in are highlighted, so a highlight always marks a match. + const highlightIn = (field: SearchField) => fields.includes(field) ? term : '' + + return ( +
  • + + +
    +

    + {item.url + ? + + + : } +

    + + {item.description && ( +

    + +

    )} + +
    + +
    +
    + +
    + {item.path && {item.path}} + + {item.code && } + + {item.snippet && 'cond' !== item.type && ( + )} +
    +
  • + ) +} + +interface SearchResultGroupProps { + group: SearchGroup + term: string + fields: readonly SearchField[] +} + +const SearchResultGroup: React.FC = ({ group, term, fields }) => { + const headingId = useId() + + return ( +
    +

    + {SEARCH_SOURCE_LABELS[group.source]} + {group.total.toLocaleString()} +

    + +
      + {group.items.map(item => )} +
    + + {group.total > group.items.length && ( +

    + {sprintf( + // translators: 1: number of results shown, 2: total number of results. + __('Showing the first %1$s of %2$s results. Search for something more specific to narrow them down.', 'code-snippets'), + group.items.length.toLocaleString(), + group.total.toLocaleString() + )} +

    )} +
    + ) +} + +interface FieldFiltersProps { + fields: readonly SearchField[] + setFields: (fields: SearchField[]) => void +} + +const FieldFilters: React.FC = ({ fields, setFields }) => +
    + {__('Match in:', 'code-snippets')} + {SEARCH_FIELDS.map(field => + setFields(SEARCH_FIELDS.filter(other => + other === field ? checked : fields.includes(other)))} + />)} +
    + +interface SearchResultsListProps { + search: UniversalSearch + filter: SourceFilter + fields: readonly SearchField[] +} + +const SearchResultsList: React.FC = ({ search, filter, fields }) => { + const { term, isSearchable, groups, loadingSources, hasFailed } = search + const visibleGroups = groups.filter(group => 'all' === filter || group.source === filter) + const pendingLabels = getPendingLabels(loadingSources, filter) + + return ( +
    +
    + {isSearchable && !hasFailed && (0 < pendingLabels.length + ? pendingLabels.join(' ') + : formatResultCount(countResults(visibleGroups)))} +
    + + {!isSearchable && ( +

    + {0 < fields.length + ? __('Type at least two characters in the search bar above to search snippets, settings, docs and cloud.', 'code-snippets') + : __('Choose at least one place to match in.', 'code-snippets')} +

    )} + + {hasFailed && ( +

    + {__('Snippets and settings could not be searched. Reload the page to try again.', 'code-snippets')} +

    )} + + {isSearchable && !hasFailed && 0 === visibleGroups.length && !isPending(loadingSources, filter) && ( +

    + {__('Nothing matched. Try a different term or another source.', 'code-snippets')} +

    )} + + {visibleGroups.map(group => + )} + + {pendingLabels.map(label => +

    {label}

    )} +
    + ) +} + +interface CloudLibraryUpsellProps { + query: string +} + +/** + * Shown in place of Cloud Library results on a site without Pro. + */ +const CloudLibraryUpsell: React.FC = ({ query }) => +
    +

    + {// translators: %s: search query. + sprintf(__('Search your own Cloud Library for “%s”', 'code-snippets'), query.trim())} +

    + +

    + {__('With Code Snippets Pro, the search bar also looks through every snippet saved to your Cloud Library, including private snippets and those synced from your other sites, so you can find and download them from here.', 'code-snippets')} +

    + + +
    + +const AiSearchIntro = () => +

    + {__('AI search finds community snippets by what they do, so describe what you need in your own words.', 'code-snippets')} +

    + +const SearchResultsPage = () => { + const [query] = useState(() => fetchQueryParam('s') ?? '') + const [tabs] = useState(getSourceTabs) + const [currentTab, setCurrentTab] = useState(() => getTabFromQuery(tabs, SOURCE_QUERY_PARAM)) + const [fields, setFields] = useState(SEARCH_FIELDS) + const filter = currentTab.name + const isAiTab = AI_SEARCH_TAB === filter + const search = useUniversalSearch({ query, fields, perSource: RESULTS_PER_SOURCE, excerpts: false }) + + // An AI search takes longer and asks more of the cloud, so it only runs once its tab is opened. + const aiSearch = useUniversalSearch({ + query, + perSource: RESULTS_PER_SOURCE, + excerpts: false, + sources: AI_SOURCES, + method: 'ai', + enabled: isAiTab + }) + + const shownSearch = isAiTab ? aiSearch : search + const visibleGroups = isAiTab + ? aiSearch.groups + : search.groups.filter(group => 'all' === filter || group.source === filter) + + const getTabCount = (tab: SubnavTab): number | undefined => { + if (AI_SEARCH_TAB === tab.name) { + return isAiTab && 0 === aiSearch.loadingSources.length ? aiSearch.total : undefined + } + + if (isLockedTab(tab.name)) { + return undefined + } + + if ('all' === tab.name) { + return 0 === search.loadingSources.length ? search.total : undefined + } + + return search.loadingSources.includes(tab.name) + ? undefined + : search.groups.find(group => group.source === tab.name)?.total ?? 0 + } + + return ( +
    + + + + +
    +

    + {search.isSearchable + // translators: %s: search query. + ? sprintf(__('Search results for “%s”', 'code-snippets'), search.term) + : __('Search', 'code-snippets')} +

    + + {search.isSearchable && !isLockedTab(filter) && !isPending(shownSearch.loadingSources, isAiTab ? 'all' : filter) && ( + {formatResultCount(countResults(visibleGroups))})} +
    + +
    + + {isLockedTab(filter) && } + + {isAiTab && } + + {!isAiTab && !isLockedTab(filter) && <> + + {'all' === filter && isLicensed() && } + } + + {!isLockedTab(filter) && ( + )} +
    + ) +} + +export const SearchResults = () => + + + + + + + diff --git a/src/js/components/ManageMenu/SnippetsTable/RowActions.tsx b/src/js/components/ManageMenu/SnippetsTable/RowActions.tsx index ae320c95f..70bcb25ae 100644 --- a/src/js/components/ManageMenu/SnippetsTable/RowActions.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/RowActions.tsx @@ -35,7 +35,7 @@ const PreviewButton: React.FC = ({ snippet }) => { ) } -interface SnippetActionButtonProps { +export interface SnippetActionButtonProps { action: () => Promise label: ReactNode workingLabel?: string @@ -45,7 +45,7 @@ interface SnippetActionButtonProps { enum SnippetActionStatus { Ready, Working, Errored } -const SnippetActionButton: React.FC = ({ action, label, workingLabel, failedLabel, className }) => { +export const SnippetActionButton: React.FC = ({ action, label, workingLabel, failedLabel, className }) => { const [status, setStatus] = useState(SnippetActionStatus.Ready) return ( diff --git a/src/js/components/ManageMenu/subpages.tsx b/src/js/components/ManageMenu/subpages.tsx index a052b744d..4c60ee0c5 100644 --- a/src/js/components/ManageMenu/subpages.tsx +++ b/src/js/components/ManageMenu/subpages.tsx @@ -2,10 +2,11 @@ import { AiAgentDemo } from './AiAgentDemo/AiAgentDemo' import { BlueprintsDemo } from './BlueprintsDemo/BlueprintsDemo' import { CloudLibraryDemo } from './CloudLibraryDemo/CloudLibraryDemo' import { CommunityCloud } from './CommunityCloud/CommunityCloud' +import { SearchResults } from './SearchResults/SearchResults' import { SnippetsTable } from './SnippetsTable' import type React from 'react' -export const SUBPAGES = ['snippets', 'blueprints', 'cloud-community', 'cloud-library', 'ai-agent'] as const +export const SUBPAGES = ['snippets', 'blueprints', 'cloud-community', 'cloud-library', 'ai-agent', 'search'] as const export type SubpageName = typeof SUBPAGES[number] @@ -38,4 +39,5 @@ export const SUBPAGE_ENTRIES: Record = { 'cloud-community': { Component: CommunityCloud }, 'cloud-library': { Component: CloudLibraryDemo, demo: 'quiet' }, 'ai-agent': { Component: AiAgentDemo, demo: 'announce' }, + 'search': { Component: SearchResults }, } diff --git a/src/js/components/StatsMenu/StatsCharts.tsx b/src/js/components/StatsMenu/StatsCharts.tsx index d8bd09b40..b0f06e91b 100644 --- a/src/js/components/StatsMenu/StatsCharts.tsx +++ b/src/js/components/StatsMenu/StatsCharts.tsx @@ -142,6 +142,7 @@ export const StatsChart = ({ views }: StatsChartProps) =>
    = ({ chart, count, label }) => -
    +
    {count} {label} diff --git a/src/js/components/StatsMenu/StatsMenu.tsx b/src/js/components/StatsMenu/StatsMenu.tsx index c629ecb55..b5aaef899 100644 --- a/src/js/components/StatsMenu/StatsMenu.tsx +++ b/src/js/components/StatsMenu/StatsMenu.tsx @@ -1,5 +1,6 @@ -import React from 'react' +import React, { useEffect } from 'react' import { WithRestAPIContext } from '../../hooks/useRestAPI' +import { revealLocationHash } from '../../utils/urls' import { ScreenMetaSlot } from '../common/ScreenMetaSlot' import { Toolbar } from '../common/Toolbar' import { StatsDashboard } from './StatsDashboard' @@ -7,6 +8,8 @@ import { StatsDashboard } from './StatsDashboard' export const StatsMenu: React.FC = () => { const summary = window.CODE_SNIPPETS_STATS + useEffect(revealLocationHash, []) + return ( <> diff --git a/src/js/components/WelcomeMenu/Changelog.tsx b/src/js/components/WelcomeMenu/Changelog.tsx index cc10a5308..1dd414bc4 100644 --- a/src/js/components/WelcomeMenu/Changelog.tsx +++ b/src/js/components/WelcomeMenu/Changelog.tsx @@ -55,7 +55,7 @@ const ChangelogSection: React.FC = ({ section, entries }) export const Changelog = () => -
    +

    {__('Latest changes', 'code-snippets')}

    = ({ partners }) => <> -

    {__('Exclusive deals from our partners', 'code-snippets')}

    +

    {__('Exclusive deals from our partners', 'code-snippets')}

      {partners.map(({ title, follow_url, image_url }) =>
    • @@ -76,7 +77,7 @@ interface ArticlesProps { const Articles: React.FC = ({ articles }) => <> -

      {__('Helpful articles', 'code-snippets')}

      +

      {__('Helpful articles', 'code-snippets')}

        {articles.map(({ title, follow_url, image_url, description, category }) =>
      • @@ -98,26 +99,31 @@ const Articles: React.FC = ({ articles }) =>
      -export const WelcomeMenu = () => - <> - +export const WelcomeMenu = () => { + useEffect(revealLocationHash, []) - + return ( + <> + -
      -

      {__('Resources and Updates', 'code-snippets')}

      -
      + -
      +
      +

      {__('Resources and Updates', 'code-snippets')}

      +
      -
      +
      -
      - - -
      +
      - {DATA?.features && } - {DATA?.partners && } -
      - +
      + + +
      + + {DATA?.features && } + {DATA?.partners && } +
      + + ) +} diff --git a/src/js/components/common/Badge.tsx b/src/js/components/common/Badge.tsx index 235a57751..653816ed4 100644 --- a/src/js/components/common/Badge.tsx +++ b/src/js/components/common/Badge.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from 'react' import type { SnippetType } from '../../types/Snippet' export type BadgeName = SnippetType | 'core' | 'pro' | 'ai' | 'cloud' | 'bundles' | 'cloud_search' | 'beta' + | 'neutral' const badgeIcons: Partial> = { cond: 'randomize', diff --git a/src/js/components/common/Toolbar.tsx b/src/js/components/common/Toolbar.tsx index 3813dceeb..f7220e7f1 100644 --- a/src/js/components/common/Toolbar.tsx +++ b/src/js/components/common/Toolbar.tsx @@ -11,6 +11,7 @@ import { CommunityIcon } from './icons/CommunityIcon' import { LibraryIcon } from './icons/LibraryIcon' import { SettingsIcon } from './icons/SettingsIcon' import { SnippetsIcon } from './icons/SnippetsIcon' +import { UniversalSearch } from './UniversalSearch/UniversalSearch' import { UpsellDialog } from './UpsellDialog' import type { SVGProps } from 'react' @@ -115,6 +116,8 @@ const UpperNav: React.FC = () => {
      {__('Code Snippets', 'code-snippets')}
    + +