LibWebView: Structure autocomplete suggestions

Replace the frontend-facing Vector<String> flow with structured
AutocompleteSuggestion objects carrying source, section, title,
and favicon metadata.

Build merged history and literal-URL rows in LibWebView, deduplicate
equivalent URL suggestions, move the autocomplete URL helpers out
of URL.h, and update the history and URL tests around the new model.
This commit is contained in:
Andreas Kling
2026-04-16 09:34:46 +02:00
committed by Andreas Kling
parent 87b6703054
commit 66e0035c9e
Notes: github-actions[bot] 2026-04-16 19:03:15 +00:00
8 changed files with 508 additions and 98 deletions

View File

@@ -18,6 +18,7 @@
#include <LibWebView/Autocomplete.h>
#include <LibWebView/HistoryDebug.h>
#include <LibWebView/HistoryStore.h>
#include <LibWebView/URL.h>
namespace WebView {
@@ -44,52 +45,215 @@ Optional<AutocompleteEngine const&> find_autocomplete_engine_by_name(StringView
Autocomplete::Autocomplete() = default;
Autocomplete::~Autocomplete() = default;
static Vector<String> merge_suggestions(Vector<String> history_suggestions, Vector<String> 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<AutocompleteSuggestion> const& suggestions)
{
Vector<ByteString> 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<AutocompleteSuggestion> make_history_suggestions(Vector<HistoryEntry> history_entries)
{
Vector<AutocompleteSuggestion> 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<AutocompleteSuggestion> 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<AutocompleteSuggestion> preferred_literal_url_suggestion(StringView query, Vector<AutocompleteSuggestion> 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<AutocompleteSuggestion>& 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<AutocompleteSuggestion> merge_suggestions(
Optional<AutocompleteSuggestion> literal_url_suggestion,
Vector<AutocompleteSuggestion> history_suggestions,
Vector<String> remote_suggestions,
size_t max_suggestions)
{
Vector<AutocompleteSuggestion> 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<AutocompleteSuggestion> 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<Requests::NetworkError> const& network_error, HTTP::HeaderList const& response_headers, Optional<u32> response_code, Optional<String> const& reason_phrase, ReadonlyBytes payload) {
[this, engine = engine.release_value(), query = m_query, literal_suggestion](u64, Requests::RequestTimingInfo const&, Optional<Requests::NetworkError> const& network_error, HTTP::HeaderList const& response_headers, Optional<u32> response_code, Optional<String> 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<Vector<String>> Autocomplete::received_autocomplete_respsonse(Autocomple
return Error::from_string_literal("Invalid engine name");
}
void Autocomplete::invoke_autocomplete_query_complete(Vector<String> suggestions) const
void Autocomplete::invoke_autocomplete_query_complete(Vector<AutocompleteSuggestion> 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);
}
}

View File

@@ -9,8 +9,10 @@
#include <AK/Error.h>
#include <AK/Function.h>
#include <AK/Optional.h>
#include <AK/RefPtr.h>
#include <AK/String.h>
#include <AK/StringView.h>
#include <AK/Vector.h>
#include <LibRequests/Forward.h>
#include <LibWebView/Forward.h>
@@ -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<String> title;
Optional<String> favicon_base64_png;
};
WEBVIEW_API ReadonlySpan<AutocompleteEngine> autocomplete_engines();
WEBVIEW_API Optional<AutocompleteEngine const&> 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<void(Vector<String>)> on_autocomplete_query_complete;
Function<void(Vector<AutocompleteSuggestion>, 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<Vector<String>> received_autocomplete_respsonse(AutocompleteEngine const&, Optional<ByteString const&> content_type, StringView response);
void invoke_autocomplete_query_complete(Vector<String> suggestions) const;
void invoke_autocomplete_query_complete(Vector<AutocompleteSuggestion> suggestions, AutocompleteResultKind) const;
String m_query;
Vector<String> m_history_suggestions;
size_t m_max_suggestions { default_autocomplete_suggestion_limit };
Vector<AutocompleteSuggestion> m_history_suggestions;
RefPtr<Requests::Request> m_request;
};

View File

@@ -112,6 +112,15 @@ static void sort_matching_entries(Vector<HistoryEntry const*>& matches, StringVi
});
}
[[maybe_unused]] static ByteString log_history_entries(Vector<HistoryEntry> const& entries)
{
Vector<String> suggestions;
suggestions.ensure_capacity(entries.size());
for (auto const& entry : entries)
suggestions.unchecked_append(entry.url);
return history_log_suggestions(suggestions);
}
ErrorOr<NonnullOwnPtr<HistoryStore>> HistoryStore::create(Database::Database& database)
{
if (auto database_path = database.database_path(); database_path.has_value())
@@ -165,13 +174,14 @@ ErrorOr<NonnullOwnPtr<HistoryStore>> 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<NonnullOwnPtr<HistoryStore>> 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<HistoryEntry> HistoryStore::entry_for_url(URL::URL const& url)
return entry;
}
Vector<String> HistoryStore::autocomplete_suggestions(StringView query, size_t limit)
Vector<HistoryEntry> HistoryStore::autocomplete_entries(StringView query, size_t limit)
{
if (m_is_disabled)
return {};
@@ -361,9 +371,9 @@ Vector<String> 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<String> 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<HistoryEntry> HistoryStore::TransientStorage::entry_for_url(String cons
return *entry;
}
Vector<String> HistoryStore::TransientStorage::autocomplete_suggestions(StringView title_query, StringView url_query, size_t limit)
Vector<HistoryEntry> HistoryStore::TransientStorage::autocomplete_entries(StringView title_query, StringView url_query, size_t limit)
{
Vector<HistoryEntry const*> matches;
@@ -463,13 +473,13 @@ Vector<String> HistoryStore::TransientStorage::autocomplete_suggestions(StringVi
sort_matching_entries(matches, title_query, url_query);
Vector<String> suggestions;
suggestions.ensure_capacity(min(limit, matches.size()));
Vector<HistoryEntry> 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<HistoryEntry> HistoryStore::PersistedStorage::entry_for_url(String cons
return entry;
}
Vector<String> HistoryStore::PersistedStorage::autocomplete_suggestions(StringView title_query, StringView url_query, size_t limit)
Vector<HistoryEntry> HistoryStore::PersistedStorage::autocomplete_entries(StringView title_query, StringView url_query, size_t limit)
{
Vector<String> suggestions;
suggestions.ensure_capacity(min(limit, DEFAULT_AUTOCOMPLETE_SUGGESTION_LIMIT));
Vector<HistoryEntry> 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<String> HistoryStore::PersistedStorage::autocomplete_suggestions(StringVi
database.execute_statement(
statements.search_entries,
[&](auto statement_id) {
suggestions.append(database.result_column<String>(statement_id, 0));
auto title = database.result_column<String>(statement_id, 1);
auto favicon = database.result_column<String>(statement_id, 4);
entries.append(HistoryEntry {
.url = database.result_column<String>(statement_id, 0),
.title = title.is_empty() ? Optional<String> {} : Optional<String> { move(title) },
.favicon_base64_png = favicon.is_empty() ? Optional<String> {} : Optional<String> { move(favicon) },
.visit_count = database.result_column<u64>(statement_id, 2),
.last_visited_time = database.result_column<UnixDateTime>(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<i64>(limit));
return suggestions;
return entries;
}
void HistoryStore::PersistedStorage::clear()

View File

@@ -33,6 +33,7 @@ public:
static ErrorOr<NonnullOwnPtr<HistoryStore>> create(Database::Database&);
static NonnullOwnPtr<HistoryStore> create();
static NonnullOwnPtr<HistoryStore> create_disabled();
static Optional<String> normalize_url(URL::URL const&);
~HistoryStore();
@@ -41,7 +42,7 @@ public:
void update_favicon(URL::URL const&, String const& favicon_base64_png);
Optional<HistoryEntry> entry_for_url(URL::URL const&);
Vector<String> autocomplete_suggestions(StringView query, size_t limit = 8);
Vector<HistoryEntry> 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<HistoryEntry> entry_for_url(String const& url);
Vector<String> autocomplete_suggestions(StringView title_query, StringView url_query, size_t limit);
Vector<HistoryEntry> 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<HistoryEntry> entry_for_url(String const& url);
Vector<String> autocomplete_suggestions(StringView title_query, StringView url_query, size_t limit);
Vector<HistoryEntry> 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<PersistedStorage>, bool is_disabled = false);
static Optional<String> normalize_url(URL::URL const&);
Optional<PersistedStorage> m_persisted_storage;
TransientStorage m_transient_storage;

View File

@@ -7,9 +7,11 @@
*/
#include <AK/String.h>
#include <AK/StringBuilder.h>
#include <LibFileSystem/FileSystem.h>
#include <LibURL/Parser.h>
#include <LibURL/PublicSuffixData.h>
#include <LibWebView/Autocomplete.h>
#include <LibWebView/URL.h>
namespace WebView {
@@ -72,6 +74,90 @@ Optional<URL::URL> sanitize_url(StringView location, Optional<SearchEngine> 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<URL::URL> sanitize_urls(ReadonlySpan<ByteString> raw_urls, URL::URL const& new_tab_page_url)
{
Vector<URL::URL> sanitized_urls;

View File

@@ -19,6 +19,7 @@ enum class AppendTLD {
Yes,
};
WEBVIEW_API Optional<URL::URL> sanitize_url(StringView, Optional<SearchEngine> const& search_engine = {}, AppendTLD = AppendTLD::No);
WEBVIEW_API bool location_looks_like_url(StringView, AppendTLD = AppendTLD::No);
WEBVIEW_API Vector<URL::URL> sanitize_urls(ReadonlySpan<ByteString> raw_urls, URL::URL const& new_tab_page_url);
struct URLParts {

View File

@@ -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<String> { "Google"_string });
EXPECT_EQ(entries[0].favicon_base64_png, Optional<String> { "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<String> { "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);
}

View File

@@ -6,6 +6,7 @@
*/
#include <LibTest/TestCase.h>
#include <LibWebView/Autocomplete.h>
#include <LibWebView/SearchEngine.h>
#include <LibWebView/URL.h>
@@ -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);
}