diff --git a/Libraries/LibWebView/Autocomplete.cpp b/Libraries/LibWebView/Autocomplete.cpp index 0902ad6ee64..b4604450a7c 100644 --- a/Libraries/LibWebView/Autocomplete.cpp +++ b/Libraries/LibWebView/Autocomplete.cpp @@ -18,6 +18,7 @@ #include #include #include +#include namespace WebView { @@ -44,52 +45,215 @@ Optional find_autocomplete_engine_by_name(StringView Autocomplete::Autocomplete() = default; Autocomplete::~Autocomplete() = default; -static Vector merge_suggestions(Vector history_suggestions, Vector remote_suggestions) -{ - history_suggestions.ensure_capacity(history_suggestions.size() + remote_suggestions.size()); - - for (auto& suggestion : remote_suggestions) { - if (!history_suggestions.contains_slow(suggestion)) - history_suggestions.unchecked_append(move(suggestion)); - } - - return history_suggestions; -} - -void Autocomplete::query_autocomplete_engine(String query) +void Autocomplete::cancel_pending_query() { if (m_request) { m_request->stop(); m_request.clear(); } + // Buffered callbacks may still arrive after we stop the request, so clear + // the active query as well and let the stale-response check discard them. + m_query = {}; + m_history_suggestions.clear(); +} + +StringView autocomplete_section_title(AutocompleteSuggestionSection section) +{ + switch (section) { + case AutocompleteSuggestionSection::None: + return {}; + case AutocompleteSuggestionSection::History: + return "History"sv; + case AutocompleteSuggestionSection::SearchSuggestions: + return "Search Suggestions"sv; + } + + VERIFY_NOT_REACHED(); +} + +[[maybe_unused]] static ByteString log_autocomplete_suggestions(Vector const& suggestions) +{ + Vector values; + values.ensure_capacity(suggestions.size()); + + for (auto const& suggestion : suggestions) + values.unchecked_append(suggestion.text.bytes_as_string_view()); + + return ByteString::formatted("[{}]", ByteString::join(", "sv, values)); +} + +static Vector make_history_suggestions(Vector history_entries) +{ + Vector suggestions; + suggestions.ensure_capacity(history_entries.size()); + + for (auto& entry : history_entries) { + suggestions.unchecked_append({ + .source = AutocompleteSuggestionSource::History, + .section = AutocompleteSuggestionSection::History, + .text = move(entry.url), + .title = move(entry.title), + .favicon_base64_png = move(entry.favicon_base64_png), + }); + } + + return suggestions; +} + +static Optional literal_url_suggestion(StringView query) +{ + if (query.is_empty() || !location_looks_like_url(query)) + return {}; + + return AutocompleteSuggestion { + .source = AutocompleteSuggestionSource::LiteralURL, + .section = AutocompleteSuggestionSection::None, + .text = MUST(String::from_utf8(query)), + .title = {}, + .favicon_base64_png = {}, + }; +} + +static Optional preferred_literal_url_suggestion(StringView query, Vector const& history_suggestions) +{ + auto literal_suggestion = literal_url_suggestion(query); + if (!literal_suggestion.has_value()) + return {}; + + // Once history still provides a richer completion for the typed prefix, + // keep that row instead of promoting a raw literal URL suggestion. + if (!history_suggestions.is_empty() && autocomplete_url_can_complete(query, history_suggestions.first().text)) { + dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Suppressing literal URL suggestion '{}' because top history suggestion '{}' still completes the query", + literal_suggestion->text, + history_suggestions.first().text); + return {}; + } + + return literal_suggestion; +} + +static bool suggestions_match_for_deduplication(AutocompleteSuggestion const& existing_suggestion, AutocompleteSuggestion const& suggestion) +{ + if (existing_suggestion.text == suggestion.text) + return true; + + if (existing_suggestion.source == AutocompleteSuggestionSource::Search + || suggestion.source == AutocompleteSuggestionSource::Search) + return false; + + return autocomplete_urls_match(existing_suggestion.text, suggestion.text); +} + +static bool should_replace_existing_suggestion(AutocompleteSuggestion const& existing_suggestion, AutocompleteSuggestion const& suggestion) +{ + return existing_suggestion.source == AutocompleteSuggestionSource::LiteralURL + && suggestion.source == AutocompleteSuggestionSource::History; +} + +static void append_suggestion_if_unique(Vector& suggestions, size_t max_suggestions, AutocompleteSuggestion suggestion) +{ + if (suggestions.size() >= max_suggestions) + return; + + for (auto& existing_suggestion : suggestions) { + if (!suggestions_match_for_deduplication(existing_suggestion, suggestion)) + continue; + + if (should_replace_existing_suggestion(existing_suggestion, suggestion)) + existing_suggestion = move(suggestion); + + return; + } + + suggestions.unchecked_append(move(suggestion)); +} + +static Vector merge_suggestions( + Optional literal_url_suggestion, + Vector history_suggestions, + Vector remote_suggestions, + size_t max_suggestions) +{ + Vector suggestions; + suggestions.ensure_capacity(min(max_suggestions, history_suggestions.size() + remote_suggestions.size() + (literal_url_suggestion.has_value() ? 1 : 0))); + + if (literal_url_suggestion.has_value()) + append_suggestion_if_unique(suggestions, max_suggestions, literal_url_suggestion.release_value()); + + for (auto& suggestion : history_suggestions) + append_suggestion_if_unique(suggestions, max_suggestions, move(suggestion)); + + for (auto& suggestion : remote_suggestions) { + auto remote_suggestion = AutocompleteSuggestion { + .source = AutocompleteSuggestionSource::Search, + .section = AutocompleteSuggestionSection::SearchSuggestions, + .text = move(suggestion), + .title = {}, + .favicon_base64_png = {}, + }; + append_suggestion_if_unique(suggestions, max_suggestions, move(remote_suggestion)); + } + + return suggestions; +} + +static bool should_defer_intermediate_suggestions(Vector const& suggestions) +{ + // A lone history row tends to be a transient placeholder while remote + // suggestions are still in flight, so wait for the merged final list. + return suggestions.size() == 1 + && suggestions.first().source == AutocompleteSuggestionSource::History; +} + +void Autocomplete::query_autocomplete_engine(String query, size_t max_suggestions) +{ + if (m_request) { + m_request->stop(); + m_request.clear(); + } + + m_max_suggestions = max_suggestions; + auto trimmed_query = MUST(String::from_utf8(query.bytes_as_string_view().trim_whitespace())); m_query = move(query); dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Autocomplete query='{}' trimmed='{}'", m_query, trimmed_query); - m_history_suggestions = Application::history_store().autocomplete_suggestions(trimmed_query); + m_history_suggestions = make_history_suggestions(Application::history_store().autocomplete_entries(trimmed_query, m_max_suggestions)); + auto literal_suggestion = preferred_literal_url_suggestion(trimmed_query, m_history_suggestions); - dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] History autocomplete suggestions for '{}': {}", trimmed_query, history_log_suggestions(m_history_suggestions)); + dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] History autocomplete suggestions for '{}': {}", trimmed_query, log_autocomplete_suggestions(m_history_suggestions)); - invoke_autocomplete_query_complete(m_history_suggestions); + auto immediate_suggestions = merge_suggestions(literal_suggestion, m_history_suggestions, {}, m_max_suggestions); if (trimmed_query.is_empty()) { + invoke_autocomplete_query_complete(move(immediate_suggestions), AutocompleteResultKind::Final); dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Skipping remote autocomplete for empty query"); return; } if (trimmed_query.starts_with_bytes(file_url_prefix)) { + invoke_autocomplete_query_complete(move(immediate_suggestions), AutocompleteResultKind::Final); dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Skipping remote autocomplete for file URL query '{}'", trimmed_query); return; } auto engine = Application::settings().autocomplete_engine(); if (!engine.has_value()) { + invoke_autocomplete_query_complete(move(immediate_suggestions), AutocompleteResultKind::Final); dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Skipping remote autocomplete because no engine is configured"); return; } + if (!immediate_suggestions.is_empty() && !should_defer_intermediate_suggestions(immediate_suggestions)) { + invoke_autocomplete_query_complete(move(immediate_suggestions), AutocompleteResultKind::Intermediate); + } else if (!immediate_suggestions.is_empty()) { + dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Deferring singleton history intermediate result for '{}' until remote autocomplete responds", trimmed_query); + } else { + dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Deferring empty history autocomplete results for '{}' until remote autocomplete responds", trimmed_query); + } + dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Fetching remote autocomplete suggestions from {} for '{}'", engine->name, m_query); auto url_string = MUST(String::formatted(engine->query_url, URL::percent_encode(m_query))); @@ -101,7 +265,7 @@ void Autocomplete::query_autocomplete_engine(String query) m_request = Application::request_server_client().start_request("GET"sv, *url); m_request->set_buffered_request_finished_callback( - [this, engine = engine.release_value(), query = m_query](u64, Requests::RequestTimingInfo const&, Optional const& network_error, HTTP::HeaderList const& response_headers, Optional response_code, Optional const& reason_phrase, ReadonlyBytes payload) { + [this, engine = engine.release_value(), query = m_query, literal_suggestion](u64, Requests::RequestTimingInfo const&, Optional const& network_error, HTTP::HeaderList const& response_headers, Optional response_code, Optional const& reason_phrase, ReadonlyBytes payload) { Core::deferred_invoke([this]() { m_request.clear(); }); if (m_query != query) { @@ -111,12 +275,12 @@ void Autocomplete::query_autocomplete_engine(String query) if (network_error.has_value()) { warnln("Unable to fetch autocomplete suggestions: {}", Requests::network_error_to_string(*network_error)); - invoke_autocomplete_query_complete(m_history_suggestions); + invoke_autocomplete_query_complete(merge_suggestions(literal_suggestion, m_history_suggestions, {}, m_max_suggestions), AutocompleteResultKind::Final); return; } if (response_code.has_value() && *response_code >= 400) { warnln("Received error response code {} from autocomplete engine: {}", *response_code, reason_phrase); - invoke_autocomplete_query_complete(m_history_suggestions); + invoke_autocomplete_query_complete(merge_suggestions(literal_suggestion, m_history_suggestions, {}, m_max_suggestions), AutocompleteResultKind::Final); return; } @@ -124,17 +288,17 @@ void Autocomplete::query_autocomplete_engine(String query) if (auto result = received_autocomplete_respsonse(engine, content_type, payload); result.is_error()) { warnln("Unable to handle autocomplete response: {}", result.error()); - invoke_autocomplete_query_complete(m_history_suggestions); + invoke_autocomplete_query_complete(merge_suggestions(literal_suggestion, m_history_suggestions, {}, m_max_suggestions), AutocompleteResultKind::Final); } else { auto remote_suggestions = result.release_value(); dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Remote autocomplete suggestions for '{}': {}", query, history_log_suggestions(remote_suggestions)); - auto merged_suggestions = merge_suggestions(m_history_suggestions, move(remote_suggestions)); + auto merged_suggestions = merge_suggestions(literal_suggestion, m_history_suggestions, move(remote_suggestions), m_max_suggestions); - dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Merged autocomplete suggestions for '{}': {}", query, history_log_suggestions(merged_suggestions)); + dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Merged autocomplete suggestions for '{}': {}", query, log_autocomplete_suggestions(merged_suggestions)); - invoke_autocomplete_query_complete(move(merged_suggestions)); + invoke_autocomplete_query_complete(move(merged_suggestions), AutocompleteResultKind::Final); } }); } @@ -248,12 +412,15 @@ ErrorOr> Autocomplete::received_autocomplete_respsonse(Autocomple return Error::from_string_literal("Invalid engine name"); } -void Autocomplete::invoke_autocomplete_query_complete(Vector suggestions) const +void Autocomplete::invoke_autocomplete_query_complete(Vector suggestions, AutocompleteResultKind result_kind) const { - dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Delivering {} autocomplete suggestion(s)", suggestions.size()); + dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Delivering {} autocomplete suggestion(s) as a {} result: {}", + suggestions.size(), + result_kind == AutocompleteResultKind::Final ? "final"sv : "intermediate"sv, + log_autocomplete_suggestions(suggestions)); if (on_autocomplete_query_complete) - on_autocomplete_query_complete(move(suggestions)); + on_autocomplete_query_complete(move(suggestions), result_kind); } } diff --git a/Libraries/LibWebView/Autocomplete.h b/Libraries/LibWebView/Autocomplete.h index cb9ee66d64e..c1bc71800d6 100644 --- a/Libraries/LibWebView/Autocomplete.h +++ b/Libraries/LibWebView/Autocomplete.h @@ -9,8 +9,10 @@ #include #include +#include #include #include +#include #include #include #include @@ -22,24 +24,56 @@ struct AutocompleteEngine { StringView query_url; }; +enum class AutocompleteResultKind { + Intermediate, + Final, +}; + +static constexpr auto default_autocomplete_suggestion_limit = 8uz; + +enum class AutocompleteSuggestionSource { + LiteralURL, + History, + Search, +}; + +enum class AutocompleteSuggestionSection { + None, + History, + SearchSuggestions, +}; + +struct WEBVIEW_API AutocompleteSuggestion { + AutocompleteSuggestionSource source { AutocompleteSuggestionSource::Search }; + AutocompleteSuggestionSection section { AutocompleteSuggestionSection::None }; + String text; + Optional title; + Optional favicon_base64_png; +}; + WEBVIEW_API ReadonlySpan autocomplete_engines(); WEBVIEW_API Optional find_autocomplete_engine_by_name(StringView name); +WEBVIEW_API StringView autocomplete_section_title(AutocompleteSuggestionSection); +WEBVIEW_API bool autocomplete_urls_match(StringView left, StringView right); +WEBVIEW_API bool autocomplete_url_can_complete(StringView query, StringView suggestion); class WEBVIEW_API Autocomplete { public: Autocomplete(); ~Autocomplete(); - Function)> on_autocomplete_query_complete; + Function, AutocompleteResultKind)> on_autocomplete_query_complete; - void query_autocomplete_engine(String); + void query_autocomplete_engine(String, size_t max_suggestions = default_autocomplete_suggestion_limit); + void cancel_pending_query(); private: static ErrorOr> received_autocomplete_respsonse(AutocompleteEngine const&, Optional content_type, StringView response); - void invoke_autocomplete_query_complete(Vector suggestions) const; + void invoke_autocomplete_query_complete(Vector suggestions, AutocompleteResultKind) const; String m_query; - Vector m_history_suggestions; + size_t m_max_suggestions { default_autocomplete_suggestion_limit }; + Vector m_history_suggestions; RefPtr m_request; }; diff --git a/Libraries/LibWebView/HistoryStore.cpp b/Libraries/LibWebView/HistoryStore.cpp index 9e75f61fe48..364f09ba3d9 100644 --- a/Libraries/LibWebView/HistoryStore.cpp +++ b/Libraries/LibWebView/HistoryStore.cpp @@ -112,6 +112,15 @@ static void sort_matching_entries(Vector& matches, StringVi }); } +[[maybe_unused]] static ByteString log_history_entries(Vector const& entries) +{ + Vector suggestions; + suggestions.ensure_capacity(entries.size()); + for (auto const& entry : entries) + suggestions.unchecked_append(entry.url); + return history_log_suggestions(suggestions); +} + ErrorOr> HistoryStore::create(Database::Database& database) { if (auto database_path = database.database_path(); database_path.has_value()) @@ -165,13 +174,14 @@ ErrorOr> HistoryStore::create(Database::Database& da WHERE url = ?; )#"sv)); statements.search_entries = TRY(database.prepare_statement(R"#( - SELECT url + SELECT url, title, visit_count, last_visited_time, COALESCE(favicon, '') FROM ( SELECT url, title, visit_count, last_visited_time, + COALESCE(favicon, '') AS favicon, CASE WHEN LOWER(CASE WHEN INSTR(url, '://') > 0 THEN SUBSTR(url, INSTR(url, '://') + 3) @@ -188,20 +198,20 @@ ErrorOr> HistoryStore::create(Database::Database& da END AS searchable_url FROM History ) - WHERE ((? != '' AND LOWER(searchable_url) LIKE LOWER(?) || '%') - OR (? != '' AND INSTR(LOWER(searchable_url), LOWER(?)) > 0) - OR (? != '' AND INSTR(LOWER(title), LOWER(?)) > 0)) + WHERE ((?1 != '' AND LOWER(searchable_url) LIKE LOWER(?1) || '%') + OR (?2 != '' AND INSTR(LOWER(searchable_url), LOWER(?2)) > 0) + OR (?3 != '' AND INSTR(LOWER(title), LOWER(?3)) > 0)) ORDER BY CASE - WHEN ? != '' AND LOWER(searchable_url) = LOWER(?) THEN 0 - WHEN ? != '' AND LOWER(searchable_url) LIKE LOWER(?) || '%' THEN 1 - WHEN ? != '' AND LOWER(title) LIKE LOWER(?) || '%' THEN 2 + WHEN ?1 != '' AND LOWER(searchable_url) = LOWER(?1) THEN 0 + WHEN ?1 != '' AND LOWER(searchable_url) LIKE LOWER(?1) || '%' THEN 1 + WHEN ?3 != '' AND LOWER(title) LIKE LOWER(?3) || '%' THEN 2 ELSE 3 END, visit_count DESC, last_visited_time DESC, url ASC - LIMIT ?; + LIMIT ?4; )#"sv)); statements.clear_entries = TRY(database.prepare_statement("DELETE FROM History;"sv)); statements.delete_entries_accessed_since = TRY(database.prepare_statement("DELETE FROM History WHERE last_visited_time >= ?;"sv)); @@ -347,7 +357,7 @@ Optional HistoryStore::entry_for_url(URL::URL const& url) return entry; } -Vector HistoryStore::autocomplete_suggestions(StringView query, size_t limit) +Vector HistoryStore::autocomplete_entries(StringView query, size_t limit) { if (m_is_disabled) return {}; @@ -361,9 +371,9 @@ Vector HistoryStore::autocomplete_suggestions(StringView query, size_t l auto title_query = autocomplete_title_query(trimmed_query); auto url_query = autocomplete_url_query(trimmed_query); - auto suggestions = m_persisted_storage.has_value() - ? m_persisted_storage->autocomplete_suggestions(title_query, url_query, limit) - : m_transient_storage.autocomplete_suggestions(title_query, url_query, limit); + auto entries = m_persisted_storage.has_value() + ? m_persisted_storage->autocomplete_entries(title_query, url_query, limit) + : m_transient_storage.autocomplete_entries(title_query, url_query, limit); dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] {} history autocomplete suggestions for '{}' (title_query='{}', url_query='{}', limit={}): {}", m_persisted_storage.has_value() ? "SQL"sv : "Transient"sv, @@ -371,9 +381,9 @@ Vector HistoryStore::autocomplete_suggestions(StringView query, size_t l title_query, url_query, limit, - history_log_suggestions(suggestions)); + log_history_entries(entries)); - return suggestions; + return entries; } void HistoryStore::clear() @@ -452,7 +462,7 @@ Optional HistoryStore::TransientStorage::entry_for_url(String cons return *entry; } -Vector HistoryStore::TransientStorage::autocomplete_suggestions(StringView title_query, StringView url_query, size_t limit) +Vector HistoryStore::TransientStorage::autocomplete_entries(StringView title_query, StringView url_query, size_t limit) { Vector matches; @@ -463,13 +473,13 @@ Vector HistoryStore::TransientStorage::autocomplete_suggestions(StringVi sort_matching_entries(matches, title_query, url_query); - Vector suggestions; - suggestions.ensure_capacity(min(limit, matches.size())); + Vector entries; + entries.ensure_capacity(min(limit, matches.size())); for (size_t i = 0; i < matches.size() && i < limit; ++i) - suggestions.unchecked_append(matches[i]->url); + entries.unchecked_append(*matches[i]); - return suggestions; + return entries; } void HistoryStore::TransientStorage::clear() @@ -535,10 +545,10 @@ Optional HistoryStore::PersistedStorage::entry_for_url(String cons return entry; } -Vector HistoryStore::PersistedStorage::autocomplete_suggestions(StringView title_query, StringView url_query, size_t limit) +Vector HistoryStore::PersistedStorage::autocomplete_entries(StringView title_query, StringView url_query, size_t limit) { - Vector suggestions; - suggestions.ensure_capacity(min(limit, DEFAULT_AUTOCOMPLETE_SUGGESTION_LIMIT)); + Vector entries; + entries.ensure_capacity(min(limit, DEFAULT_AUTOCOMPLETE_SUGGESTION_LIMIT)); auto url_query_string = MUST(String::from_utf8(url_query)); auto title_query_string = MUST(String::from_utf8(title_query)); auto url_contains_query_string = MUST(String::from_utf8(autocomplete_url_contains_query(url_query))); @@ -546,23 +556,23 @@ Vector HistoryStore::PersistedStorage::autocomplete_suggestions(StringVi database.execute_statement( statements.search_entries, [&](auto statement_id) { - suggestions.append(database.result_column(statement_id, 0)); + auto title = database.result_column(statement_id, 1); + auto favicon = database.result_column(statement_id, 4); + + entries.append(HistoryEntry { + .url = database.result_column(statement_id, 0), + .title = title.is_empty() ? Optional {} : Optional { move(title) }, + .favicon_base64_png = favicon.is_empty() ? Optional {} : Optional { move(favicon) }, + .visit_count = database.result_column(statement_id, 2), + .last_visited_time = database.result_column(statement_id, 3), + }); }, url_query_string, - url_query_string, url_contains_query_string, - url_contains_query_string, - title_query_string, - title_query_string, - url_query_string, - url_query_string, - url_query_string, - url_query_string, - title_query_string, title_query_string, static_cast(limit)); - return suggestions; + return entries; } void HistoryStore::PersistedStorage::clear() diff --git a/Libraries/LibWebView/HistoryStore.h b/Libraries/LibWebView/HistoryStore.h index 2f2464209d5..fa03b596a5e 100644 --- a/Libraries/LibWebView/HistoryStore.h +++ b/Libraries/LibWebView/HistoryStore.h @@ -33,6 +33,7 @@ public: static ErrorOr> create(Database::Database&); static NonnullOwnPtr create(); static NonnullOwnPtr create_disabled(); + static Optional normalize_url(URL::URL const&); ~HistoryStore(); @@ -41,7 +42,7 @@ public: void update_favicon(URL::URL const&, String const& favicon_base64_png); Optional entry_for_url(URL::URL const&); - Vector autocomplete_suggestions(StringView query, size_t limit = 8); + Vector autocomplete_entries(StringView query, size_t limit = 8); void clear(); void remove_entries_accessed_since(UnixDateTime since); @@ -64,7 +65,7 @@ private: void update_favicon(String const& url, String favicon_base64_png); Optional entry_for_url(String const& url); - Vector autocomplete_suggestions(StringView title_query, StringView url_query, size_t limit); + Vector autocomplete_entries(StringView title_query, StringView url_query, size_t limit); void clear(); void remove_entries_accessed_since(UnixDateTime since); @@ -79,7 +80,7 @@ private: void update_favicon(String const& url, String const& favicon_base64_png); Optional entry_for_url(String const& url); - Vector autocomplete_suggestions(StringView title_query, StringView url_query, size_t limit); + Vector autocomplete_entries(StringView title_query, StringView url_query, size_t limit); void clear(); void remove_entries_accessed_since(UnixDateTime since); @@ -89,7 +90,6 @@ private: }; explicit HistoryStore(Optional, bool is_disabled = false); - static Optional normalize_url(URL::URL const&); Optional m_persisted_storage; TransientStorage m_transient_storage; diff --git a/Libraries/LibWebView/URL.cpp b/Libraries/LibWebView/URL.cpp index ab1f0bb4c23..493f559e690 100644 --- a/Libraries/LibWebView/URL.cpp +++ b/Libraries/LibWebView/URL.cpp @@ -7,9 +7,11 @@ */ #include +#include #include #include #include +#include #include namespace WebView { @@ -72,6 +74,90 @@ Optional sanitize_url(StringView location, Optional cons return url; } +bool location_looks_like_url(StringView location, AppendTLD append_tld) +{ + return sanitize_url(location, {}, append_tld).has_value(); +} + +static String normalized_web_url_for_autocomplete_comparison(URL::URL const& url) +{ + VERIFY(url.scheme().is_one_of("http"sv, "https"sv)); + + // Address bar suggestions intentionally treat `http` and `https` variants + // of the same web location as equivalent. Normalize away the scheme, + // leading `www.`, default root slash, and default port so comparisons + // match what the user actually typed. + StringBuilder builder; + + if (!url.username().is_empty() || !url.password().is_empty()) { + builder.append(url.username()); + if (!url.password().is_empty()) { + builder.append(':'); + builder.append(url.password()); + } + builder.append('@'); + } + + auto host = url.serialized_host(); + auto host_view = host.bytes_as_string_view(); + if (host_view.starts_with("www."sv, CaseSensitivity::CaseInsensitive)) + host_view = host_view.substring_view(4); + builder.append(host_view); + + auto default_port = URL::default_port_for_scheme(url.scheme()); + if (url.port().has_value() && (!default_port.has_value() || *url.port() != *default_port)) + builder.appendff(":{}", *url.port()); + + auto path = url.serialize_path(); + if (path != "/"sv) + builder.append(path); + + if (url.query().has_value()) { + builder.append('?'); + builder.append(*url.query()); + } + + return MUST(builder.to_string()); +} + +static String normalized_url_for_autocomplete_prefix_matching(URL::URL const& url) +{ + if (url.scheme().is_one_of("http"sv, "https"sv)) + return normalized_web_url_for_autocomplete_comparison(url); + + return url.serialize(URL::ExcludeFragment::Yes); +} + +bool autocomplete_urls_match(StringView left, StringView right) +{ + auto left_url = sanitize_url(left); + auto right_url = sanitize_url(right); + if (!left_url.has_value() || !right_url.has_value()) + return false; + + if (left_url->scheme().is_one_of("http"sv, "https"sv) + && right_url->scheme().is_one_of("http"sv, "https"sv)) + return normalized_web_url_for_autocomplete_comparison(*left_url) == normalized_web_url_for_autocomplete_comparison(*right_url); + + return left_url->equals(*right_url, URL::ExcludeFragment::Yes); +} + +bool autocomplete_url_can_complete(StringView query, StringView suggestion) +{ + auto query_url = sanitize_url(query); + auto suggestion_url = sanitize_url(suggestion); + if (!query_url.has_value() || !suggestion_url.has_value()) + return false; + + auto normalized_query = normalized_url_for_autocomplete_prefix_matching(*query_url); + auto normalized_suggestion = normalized_url_for_autocomplete_prefix_matching(*suggestion_url); + + if (normalized_suggestion.bytes_as_string_view().length() <= normalized_query.bytes_as_string_view().length()) + return false; + + return normalized_suggestion.starts_with_bytes(normalized_query, CaseSensitivity::CaseInsensitive); +} + Vector sanitize_urls(ReadonlySpan raw_urls, URL::URL const& new_tab_page_url) { Vector sanitized_urls; diff --git a/Libraries/LibWebView/URL.h b/Libraries/LibWebView/URL.h index 43723040dc3..7d2b04ad680 100644 --- a/Libraries/LibWebView/URL.h +++ b/Libraries/LibWebView/URL.h @@ -19,6 +19,7 @@ enum class AppendTLD { Yes, }; WEBVIEW_API Optional sanitize_url(StringView, Optional const& search_engine = {}, AppendTLD = AppendTLD::No); +WEBVIEW_API bool location_looks_like_url(StringView, AppendTLD = AppendTLD::No); WEBVIEW_API Vector sanitize_urls(ReadonlySpan raw_urls, URL::URL const& new_tab_page_url); struct URLParts { diff --git a/Tests/LibWebView/TestHistoryStore.cpp b/Tests/LibWebView/TestHistoryStore.cpp index a1daf87f7b2..ae81056dd8e 100644 --- a/Tests/LibWebView/TestHistoryStore.cpp +++ b/Tests/LibWebView/TestHistoryStore.cpp @@ -32,41 +32,59 @@ static void expect_history_autocomplete_ignores_url_boilerplate(WebView::History { populate_history_for_url_autocomplete_tests(store); - EXPECT(store.autocomplete_suggestions("https://"sv, 8).is_empty()); - EXPECT(store.autocomplete_suggestions("https://www."sv, 8).is_empty()); - EXPECT(store.autocomplete_suggestions("www."sv, 8).is_empty()); + EXPECT(store.autocomplete_entries("https://"sv, 8).is_empty()); + EXPECT(store.autocomplete_entries("https://www."sv, 8).is_empty()); + EXPECT(store.autocomplete_entries("www."sv, 8).is_empty()); - auto git_suggestions = store.autocomplete_suggestions("git"sv, 8); - VERIFY(git_suggestions.size() == 1); - EXPECT_EQ(git_suggestions[0], "https://github.com/LadybirdBrowser/ladybird"_string); + auto git_entries = store.autocomplete_entries("git"sv, 8); + VERIFY(git_entries.size() == 1); + EXPECT_EQ(git_entries[0].url, "https://github.com/LadybirdBrowser/ladybird"_string); - auto https_goo_suggestions = store.autocomplete_suggestions("https://goo"sv, 8); - VERIFY(https_goo_suggestions.size() == 1); - EXPECT_EQ(https_goo_suggestions[0], "https://www.google.com/"_string); + auto https_goo_entries = store.autocomplete_entries("https://goo"sv, 8); + VERIFY(https_goo_entries.size() == 1); + EXPECT_EQ(https_goo_entries[0].url, "https://www.google.com/"_string); } static void expect_history_autocomplete_requires_three_characters_for_title_matches(WebView::HistoryStore& store) { store.record_visit(parse_url("https://example.com/"sv), "Foo bar baz wip wap wop"_string, UnixDateTime::from_seconds_since_epoch(10)); - EXPECT(store.autocomplete_suggestions("w"sv, 8).is_empty()); - EXPECT(store.autocomplete_suggestions("wi"sv, 8).is_empty()); + EXPECT(store.autocomplete_entries("w"sv, 8).is_empty()); + EXPECT(store.autocomplete_entries("wi"sv, 8).is_empty()); - auto suggestions = store.autocomplete_suggestions("wip"sv, 8); - VERIFY(suggestions.size() == 1); - EXPECT_EQ(suggestions[0], "https://example.com/"_string); + auto entries = store.autocomplete_entries("wip"sv, 8); + VERIFY(entries.size() == 1); + EXPECT_EQ(entries[0].url, "https://example.com/"_string); } static void expect_history_autocomplete_requires_three_characters_for_non_prefix_url_matches(WebView::HistoryStore& store) { store.record_visit(parse_url("https://example.com/wip-path"sv), "Example"_string, UnixDateTime::from_seconds_since_epoch(10)); - EXPECT(store.autocomplete_suggestions("w"sv, 8).is_empty()); - EXPECT(store.autocomplete_suggestions("wi"sv, 8).is_empty()); + EXPECT(store.autocomplete_entries("w"sv, 8).is_empty()); + EXPECT(store.autocomplete_entries("wi"sv, 8).is_empty()); - auto suggestions = store.autocomplete_suggestions("wip"sv, 8); - VERIFY(suggestions.size() == 1); - EXPECT_EQ(suggestions[0], "https://example.com/wip-path"_string); + auto entries = store.autocomplete_entries("wip"sv, 8); + VERIFY(entries.size() == 1); + EXPECT_EQ(entries[0].url, "https://example.com/wip-path"_string); +} + +static void expect_history_autocomplete_entries_include_metadata(WebView::HistoryStore& store) +{ + auto google_url = parse_url("https://www.google.com/"sv); + auto github_url = parse_url("https://github.com/LadybirdBrowser/ladybird"sv); + + store.record_visit(google_url, "Google"_string, UnixDateTime::from_seconds_since_epoch(20)); + store.update_favicon(google_url, "Zm9v"_string); + store.record_visit(github_url, "Ladybird repository"_string, UnixDateTime::from_seconds_since_epoch(10)); + + auto entries = store.autocomplete_entries("goo"sv, 8); + VERIFY(entries.size() == 1); + EXPECT_EQ(entries[0].url, "https://www.google.com/"_string); + EXPECT_EQ(entries[0].title, Optional { "Google"_string }); + EXPECT_EQ(entries[0].favicon_base64_png, Optional { "Zm9v"_string }); + EXPECT_EQ(entries[0].visit_count, 1u); + EXPECT_EQ(entries[0].last_visited_time, UnixDateTime::from_seconds_since_epoch(20)); } TEST_CASE(record_and_lookup_history_entries) @@ -94,12 +112,12 @@ TEST_CASE(history_autocomplete_prefers_url_prefix_then_recency) store->record_visit(parse_url("https://alpha.example.com/"sv), "Something else"_string, UnixDateTime::from_seconds_since_epoch(20)); store->record_visit(parse_url("https://docs.example.com/"sv), "Alpha docs"_string, UnixDateTime::from_seconds_since_epoch(30)); - auto suggestions = store->autocomplete_suggestions("alpha"sv, 8); + auto entries = store->autocomplete_entries("alpha"sv, 8); - VERIFY(suggestions.size() == 3); - EXPECT_EQ(suggestions[0], "https://alpha.example.com/"_string); - EXPECT_EQ(suggestions[1], "https://docs.example.com/"_string); - EXPECT_EQ(suggestions[2], "https://beta.example.com/"_string); + VERIFY(entries.size() == 3); + EXPECT_EQ(entries[0].url, "https://alpha.example.com/"_string); + EXPECT_EQ(entries[1].url, "https://docs.example.com/"_string); + EXPECT_EQ(entries[2].url, "https://beta.example.com/"_string); } TEST_CASE(history_autocomplete_trims_whitespace) @@ -108,10 +126,10 @@ TEST_CASE(history_autocomplete_trims_whitespace) store->record_visit(parse_url("https://ladybird.dev/"sv), "Ladybird"_string, UnixDateTime::from_seconds_since_epoch(10)); - auto suggestions = store->autocomplete_suggestions(" ladybird "sv, 8); + auto entries = store->autocomplete_entries(" ladybird "sv, 8); - VERIFY(suggestions.size() == 1); - EXPECT_EQ(suggestions[0], "https://ladybird.dev/"_string); + VERIFY(entries.size() == 1); + EXPECT_EQ(entries[0].url, "https://ladybird.dev/"_string); } TEST_CASE(history_autocomplete_ignores_www_prefix_for_host_matches) @@ -121,11 +139,11 @@ TEST_CASE(history_autocomplete_ignores_www_prefix_for_host_matches) store->record_visit(parse_url("https://www.google.com/"sv), "Google"_string, UnixDateTime::from_seconds_since_epoch(20)); store->record_visit(parse_url("https://www.goodreads.com/"sv), "Goodreads"_string, UnixDateTime::from_seconds_since_epoch(10)); - auto suggestions = store->autocomplete_suggestions("goo"sv, 8); + auto entries = store->autocomplete_entries("goo"sv, 8); - VERIFY(suggestions.size() == 2); - EXPECT_EQ(suggestions[0], "https://www.google.com/"_string); - EXPECT_EQ(suggestions[1], "https://www.goodreads.com/"_string); + VERIFY(entries.size() == 2); + EXPECT_EQ(entries[0].url, "https://www.google.com/"_string); + EXPECT_EQ(entries[1].url, "https://www.goodreads.com/"_string); } TEST_CASE(history_autocomplete_ignores_scheme_and_www_boilerplate_prefixes) @@ -158,6 +176,13 @@ TEST_CASE(history_favicon_updates_entry) VERIFY(entry.has_value()); EXPECT_EQ(entry->favicon_base64_png, Optional { "Zm9v"_string }); } + +TEST_CASE(history_autocomplete_entries_include_metadata) +{ + auto store = WebView::HistoryStore::create(); + expect_history_autocomplete_entries_include_metadata(*store); +} + TEST_CASE(non_browsable_urls_are_not_recorded) { auto store = WebView::HistoryStore::create(); @@ -269,3 +294,21 @@ TEST_CASE(persisted_history_autocomplete_requires_three_characters_for_non_prefi expect_history_autocomplete_requires_three_characters_for_non_prefix_url_matches(*store); } + +TEST_CASE(persisted_history_autocomplete_entries_include_metadata) +{ + auto database_directory = ByteString::formatted( + "{}/ladybird-history-store-entry-autocomplete-test-{}", + Core::StandardPaths::tempfile_directory(), + generate_random_uuid()); + TRY_OR_FAIL(Core::Directory::create(database_directory, Core::Directory::CreateDirectories::Yes)); + + auto cleanup = ScopeGuard([&] { + MUST(FileSystem::remove(database_directory, FileSystem::RecursionMode::Allowed)); + }); + + auto database = TRY_OR_FAIL(Database::Database::create(database_directory, "HistoryStore"sv)); + auto store = TRY_OR_FAIL(WebView::HistoryStore::create(*database)); + + expect_history_autocomplete_entries_include_metadata(*store); +} diff --git a/Tests/LibWebView/TestWebViewURL.cpp b/Tests/LibWebView/TestWebViewURL.cpp index 0ed51695466..55aa43aa9bf 100644 --- a/Tests/LibWebView/TestWebViewURL.cpp +++ b/Tests/LibWebView/TestWebViewURL.cpp @@ -6,6 +6,7 @@ */ #include +#include #include #include @@ -49,6 +50,36 @@ static void expect_search_url_equals_sanitized_url(StringView url) EXPECT_EQ(sanitized_url->to_string(), search_url); } +static void expect_location_looks_like_url(StringView location, WebView::AppendTLD append_tld = WebView::AppendTLD::No) +{ + EXPECT(WebView::location_looks_like_url(location, append_tld)); +} + +static void expect_location_does_not_look_like_url(StringView location, WebView::AppendTLD append_tld = WebView::AppendTLD::No) +{ + EXPECT(!WebView::location_looks_like_url(location, append_tld)); +} + +static void expect_autocomplete_urls_match(StringView left, StringView right) +{ + EXPECT(WebView::autocomplete_urls_match(left, right)); +} + +static void expect_autocomplete_urls_do_not_match(StringView left, StringView right) +{ + EXPECT(!WebView::autocomplete_urls_match(left, right)); +} + +static void expect_autocomplete_url_can_complete(StringView query, StringView suggestion) +{ + EXPECT(WebView::autocomplete_url_can_complete(query, suggestion)); +} + +static void expect_autocomplete_url_cannot_complete(StringView query, StringView suggestion) +{ + EXPECT(!WebView::autocomplete_url_can_complete(query, suggestion)); +} + TEST_CASE(invalid_url) { EXPECT(!WebView::break_url_into_parts(""sv).has_value()); @@ -199,3 +230,41 @@ TEST_CASE(location_to_search_or_url) // FIXME: Add support for opening mailto: scheme (below). Firefox opens mailto: locations // expect_url_equals_sanitized_url("mailto:hello@example.com"sv, "mailto:hello@example.com"sv); } + +TEST_CASE(location_looks_like_url) +{ + expect_location_looks_like_url("example.org"sv); + expect_location_looks_like_url("example.com"sv); + expect_location_looks_like_url("localhost"sv); + expect_location_looks_like_url("https://example.def"sv); + + expect_location_does_not_look_like_url("hello"sv); + expect_location_does_not_look_like_url("hello world"sv); + expect_location_does_not_look_like_url("example.org hello"sv); + expect_location_does_not_look_like_url("example.def"sv); + + expect_location_looks_like_url("example"sv, WebView::AppendTLD::Yes); +} + +TEST_CASE(autocomplete_url_matching) +{ + expect_autocomplete_urls_match("google.com"sv, "https://www.google.com/"sv); + expect_autocomplete_urls_match("example.com/path?q=1"sv, "https://www.example.com/path?q=1"sv); + expect_autocomplete_urls_match("http://example.com"sv, "https://example.com/"sv); + + expect_autocomplete_urls_do_not_match("https://example.com/path"sv, "https://example.com/other"sv); + expect_autocomplete_urls_do_not_match("https://example.com"sv, "https://example.com:8443"sv); + expect_autocomplete_urls_do_not_match("hello"sv, "https://hello.example/"sv); +} + +TEST_CASE(autocomplete_url_completion) +{ + expect_autocomplete_url_can_complete("reddit.co"sv, "https://reddit.com/"sv); + expect_autocomplete_url_can_complete("https://reddit.co"sv, "https://www.reddit.com/"sv); + expect_autocomplete_url_can_complete("www.reddit.co"sv, "https://www.reddit.com/"sv); + + expect_autocomplete_url_cannot_complete("reddit.com"sv, "https://reddit.com/"sv); + expect_autocomplete_url_cannot_complete("reddit.co"sv, "https://reddit.co/"sv); + expect_autocomplete_url_cannot_complete("reddit.co"sv, "https://reddit.net/"sv); + expect_autocomplete_url_cannot_complete("reddit.com/r"sv, "https://reddit.com/"sv); +}