This adds a WebUI to view the local browsing history, with controls to search and delete entries. The APIs used to search history are paginated to prevent excessive query sizes.
852 lines
28 KiB
C++
852 lines
28 KiB
C++
/*
|
|
* Copyright (c) 2026-present, the Ladybird developers.
|
|
*
|
|
* SPDX-License-Identifier: BSD-2-Clause
|
|
*/
|
|
|
|
#include <AK/Debug.h>
|
|
#include <AK/QuickSort.h>
|
|
#include <AK/Utf8View.h>
|
|
#include <LibDatabase/Database.h>
|
|
#include <LibURL/Parser.h>
|
|
#include <LibURL/URL.h>
|
|
#include <LibWebView/HistoryDebug.h>
|
|
#include <LibWebView/HistoryStore.h>
|
|
|
|
namespace WebView {
|
|
|
|
static constexpr auto DEFAULT_AUTOCOMPLETE_SUGGESTION_LIMIT = 8uz;
|
|
static constexpr size_t MINIMUM_TITLE_AUTOCOMPLETE_QUERY_LENGTH = 3;
|
|
|
|
static Optional<StringView> url_without_scheme(StringView url)
|
|
{
|
|
auto scheme_separator = url.find("://"sv);
|
|
if (!scheme_separator.has_value())
|
|
return {};
|
|
|
|
return url.substring_view(*scheme_separator + 3);
|
|
}
|
|
|
|
static StringView autocomplete_searchable_url(StringView url)
|
|
{
|
|
auto stripped_url = url_without_scheme(url).value_or(url);
|
|
if (stripped_url.starts_with("www."sv, CaseSensitivity::CaseInsensitive))
|
|
stripped_url = stripped_url.substring_view(4);
|
|
|
|
return stripped_url;
|
|
}
|
|
|
|
static StringView autocomplete_url_query(StringView query)
|
|
{
|
|
auto stripped_query = url_without_scheme(query).value_or(query);
|
|
if (stripped_query.starts_with("www."sv, CaseSensitivity::CaseInsensitive))
|
|
stripped_query = stripped_query.substring_view(4);
|
|
|
|
return stripped_query;
|
|
}
|
|
|
|
static StringView autocomplete_title_query(StringView query)
|
|
{
|
|
if (Utf8View { query }.length() < MINIMUM_TITLE_AUTOCOMPLETE_QUERY_LENGTH)
|
|
return {};
|
|
|
|
return query;
|
|
}
|
|
|
|
static StringView autocomplete_url_contains_query(StringView query)
|
|
{
|
|
// Non-prefix URL matches get noisy very quickly, so only enable them
|
|
// once the user has typed enough to disambiguate path fragments.
|
|
if (Utf8View { query }.length() < MINIMUM_TITLE_AUTOCOMPLETE_QUERY_LENGTH)
|
|
return {};
|
|
|
|
return query;
|
|
}
|
|
|
|
static bool matches_query(HistoryEntry const& entry, StringView title_query, StringView url_query)
|
|
{
|
|
auto searchable_url = autocomplete_searchable_url(entry.url.bytes_as_string_view());
|
|
if (!url_query.is_empty() && searchable_url.starts_with(url_query, CaseSensitivity::CaseInsensitive))
|
|
return true;
|
|
|
|
auto url_contains_query = autocomplete_url_contains_query(url_query);
|
|
if (!url_contains_query.is_empty() && searchable_url.contains(url_contains_query, CaseSensitivity::CaseInsensitive))
|
|
return true;
|
|
|
|
return !title_query.is_empty()
|
|
&& entry.title.has_value()
|
|
&& entry.title->contains(title_query, CaseSensitivity::CaseInsensitive);
|
|
}
|
|
|
|
static u8 match_rank(HistoryEntry const& entry, StringView title_query, StringView url_query)
|
|
{
|
|
auto searchable_url = autocomplete_searchable_url(entry.url.bytes_as_string_view());
|
|
|
|
if (!url_query.is_empty()) {
|
|
if (searchable_url.equals_ignoring_ascii_case(url_query))
|
|
return 0;
|
|
if (searchable_url.starts_with(url_query, CaseSensitivity::CaseInsensitive))
|
|
return 1;
|
|
}
|
|
|
|
if (!title_query.is_empty() && entry.title.has_value() && entry.title->starts_with_bytes(title_query, CaseSensitivity::CaseInsensitive))
|
|
return 2;
|
|
|
|
return 3;
|
|
}
|
|
|
|
static void sort_matching_entries(Vector<HistoryEntry const*>& matches, StringView title_query, StringView url_query)
|
|
{
|
|
quick_sort(matches, [&](auto const* left, auto const* right) {
|
|
auto left_rank = match_rank(*left, title_query, url_query);
|
|
auto right_rank = match_rank(*right, title_query, url_query);
|
|
if (left_rank != right_rank)
|
|
return left_rank < right_rank;
|
|
|
|
if (left->visit_count != right->visit_count)
|
|
return left->visit_count > right->visit_count;
|
|
|
|
if (left->last_visited_time != right->last_visited_time)
|
|
return left->last_visited_time > right->last_visited_time;
|
|
|
|
return left->url < right->url;
|
|
});
|
|
}
|
|
|
|
[[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())
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Opening persisted history store at {}", database_path->string());
|
|
else
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Opening memory-backed persisted history store");
|
|
|
|
Statements statements {};
|
|
|
|
auto create_history_table = TRY(database.prepare_statement(R"#(
|
|
CREATE TABLE IF NOT EXISTS History (
|
|
url TEXT PRIMARY KEY,
|
|
title TEXT NOT NULL,
|
|
favicon TEXT,
|
|
visit_count INTEGER NOT NULL,
|
|
last_visited_time INTEGER NOT NULL
|
|
);
|
|
)#"sv));
|
|
database.execute_statement(create_history_table, {});
|
|
|
|
auto create_last_visited_index = TRY(database.prepare_statement(R"#(
|
|
CREATE INDEX IF NOT EXISTS HistoryLastVisitedTimeIndex
|
|
ON History(last_visited_time DESC);
|
|
)#"sv));
|
|
database.execute_statement(create_last_visited_index, {});
|
|
|
|
statements.upsert_entry = TRY(database.prepare_statement(R"#(
|
|
INSERT INTO History (url, title, visit_count, last_visited_time)
|
|
VALUES (?, ?, 1, ?)
|
|
ON CONFLICT(url) DO UPDATE SET
|
|
title = CASE
|
|
WHEN excluded.title != '' THEN excluded.title
|
|
ELSE History.title
|
|
END,
|
|
visit_count = History.visit_count + 1,
|
|
last_visited_time = excluded.last_visited_time;
|
|
)#"sv));
|
|
statements.update_title = TRY(database.prepare_statement(R"#(
|
|
UPDATE History
|
|
SET title = ?
|
|
WHERE url = ?;
|
|
)#"sv));
|
|
statements.update_favicon = TRY(database.prepare_statement(R"#(
|
|
UPDATE History
|
|
SET favicon = ?
|
|
WHERE url = ?;
|
|
)#"sv));
|
|
statements.get_entry = TRY(database.prepare_statement(R"#(
|
|
SELECT title, visit_count, last_visited_time, COALESCE(favicon, '')
|
|
FROM History
|
|
WHERE url = ?;
|
|
)#"sv));
|
|
statements.search_entries = TRY(database.prepare_statement(R"#(
|
|
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)
|
|
ELSE url
|
|
END) LIKE 'www.%'
|
|
THEN SUBSTR(CASE
|
|
WHEN INSTR(url, '://') > 0 THEN SUBSTR(url, INSTR(url, '://') + 3)
|
|
ELSE url
|
|
END, 5)
|
|
ELSE CASE
|
|
WHEN INSTR(url, '://') > 0 THEN SUBSTR(url, INSTR(url, '://') + 3)
|
|
ELSE url
|
|
END
|
|
END AS searchable_url
|
|
FROM History
|
|
)
|
|
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 ?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 ?4;
|
|
)#"sv));
|
|
statements.list_entries = TRY(database.prepare_statement(R"#(
|
|
SELECT url, title, visit_count, last_visited_time, 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)
|
|
ELSE url
|
|
END) LIKE 'www.%'
|
|
THEN SUBSTR(CASE
|
|
WHEN INSTR(url, '://') > 0 THEN SUBSTR(url, INSTR(url, '://') + 3)
|
|
ELSE url
|
|
END, 5)
|
|
ELSE CASE
|
|
WHEN INSTR(url, '://') > 0 THEN SUBSTR(url, INSTR(url, '://') + 3)
|
|
ELSE url
|
|
END
|
|
END AS searchable_url
|
|
FROM History
|
|
)
|
|
WHERE ((?1 = '' AND ?2 = '')
|
|
OR (?1 != '' AND INSTR(LOWER(title), LOWER(?1)) > 0)
|
|
OR (?2 != '' AND INSTR(LOWER(searchable_url), LOWER(?2)) > 0))
|
|
ORDER BY last_visited_time DESC, url ASC
|
|
LIMIT ?3 OFFSET ?4;
|
|
)#"sv));
|
|
statements.clear_entries = TRY(database.prepare_statement("DELETE FROM History;"sv));
|
|
statements.delete_entry = TRY(database.prepare_statement("DELETE FROM History WHERE url = ?;"sv));
|
|
statements.delete_entries_accessed_since = TRY(database.prepare_statement("DELETE FROM History WHERE last_visited_time >= ?;"sv));
|
|
statements.all_urls = TRY(database.prepare_statement("SELECT url FROM History;"sv));
|
|
|
|
return adopt_own(*new HistoryStore { adopt_own<StorageImpl>(*new PersistedStorage { database, move(statements) }) });
|
|
}
|
|
|
|
NonnullOwnPtr<HistoryStore> HistoryStore::create()
|
|
{
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Opening transient history store");
|
|
|
|
return adopt_own(*new HistoryStore { adopt_own<StorageImpl>(*new TransientStorage {}) });
|
|
}
|
|
|
|
NonnullOwnPtr<HistoryStore> HistoryStore::create_disabled()
|
|
{
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Opening disabled history store");
|
|
|
|
return adopt_own(*new HistoryStore { adopt_own<StorageImpl>(*new TransientStorage {}), true });
|
|
}
|
|
|
|
HistoryStore::HistoryStore(NonnullOwnPtr<StorageImpl>&& storage, bool is_disabled)
|
|
: m_storage(move(storage))
|
|
, m_is_disabled(is_disabled)
|
|
{
|
|
}
|
|
|
|
HistoryStore::~HistoryStore() = default;
|
|
|
|
Optional<String> HistoryStore::normalize_url(URL::URL const& url)
|
|
{
|
|
if (url.scheme().is_empty()) {
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Skipping history entry without a scheme: {}", url);
|
|
return {};
|
|
}
|
|
|
|
if (url.scheme().is_one_of("about"sv, "data"sv)) {
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Skipping non-browsable history URL: {}", url);
|
|
return {};
|
|
}
|
|
|
|
auto normalized_url = url.serialize(URL::ExcludeFragment::Yes);
|
|
if (normalized_url.is_empty()) {
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Skipping history entry with an empty normalized URL: {}", url);
|
|
return {};
|
|
}
|
|
|
|
return normalized_url;
|
|
}
|
|
|
|
void HistoryStore::record_visit(URL::URL const& url, Optional<String> title, UnixDateTime visited_at)
|
|
{
|
|
if (m_is_disabled)
|
|
return;
|
|
|
|
auto normalized_url = normalize_url(url);
|
|
if (!normalized_url.has_value())
|
|
return;
|
|
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Recording visit in {} store: url='{}' title='{}' visited_at={}",
|
|
m_storage->name(),
|
|
*normalized_url,
|
|
title.has_value() ? title->bytes_as_string_view() : "<none>"sv,
|
|
visited_at.seconds_since_epoch());
|
|
|
|
m_storage->record_visit(*normalized_url, title, visited_at);
|
|
}
|
|
|
|
void HistoryStore::update_title(URL::URL const& url, String const& title)
|
|
{
|
|
if (m_is_disabled)
|
|
return;
|
|
|
|
if (title.is_empty()) {
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Ignoring empty history title update for {}", url);
|
|
return;
|
|
}
|
|
|
|
auto normalized_url = normalize_url(url);
|
|
if (!normalized_url.has_value())
|
|
return;
|
|
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Updating history title in {} store: url='{}' title='{}'",
|
|
m_storage->name(),
|
|
*normalized_url,
|
|
title);
|
|
|
|
m_storage->update_title(*normalized_url, title);
|
|
}
|
|
|
|
void HistoryStore::update_favicon(URL::URL const& url, String const& favicon_base64_png)
|
|
{
|
|
if (favicon_base64_png.is_empty()) {
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Ignoring empty history favicon update for {}", url);
|
|
return;
|
|
}
|
|
|
|
auto normalized_url = normalize_url(url);
|
|
if (!normalized_url.has_value())
|
|
return;
|
|
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Updating history favicon in {} store: url='{}' bytes={}",
|
|
m_storage->name(),
|
|
*normalized_url,
|
|
favicon_base64_png.bytes().size());
|
|
|
|
m_storage->update_favicon(*normalized_url, favicon_base64_png);
|
|
}
|
|
|
|
void HistoryStore::record_closed_tab(URL::URL const& url, UnixDateTime closed_at)
|
|
{
|
|
m_recently_closed_entries.empend(RecentlyClosedEntry {
|
|
.urls = { url },
|
|
.was_window = false,
|
|
.active_tab_index = 0,
|
|
.closed_time = closed_at,
|
|
});
|
|
}
|
|
|
|
void HistoryStore::record_closed_window(Vector<URL::URL> urls, size_t active_tab_index, UnixDateTime closed_at)
|
|
{
|
|
if (urls.is_empty())
|
|
return;
|
|
|
|
m_recently_closed_entries.empend(RecentlyClosedEntry {
|
|
.urls = move(urls),
|
|
.was_window = true,
|
|
.active_tab_index = 0,
|
|
.closed_time = closed_at,
|
|
});
|
|
|
|
auto& entry = m_recently_closed_entries.last();
|
|
entry.active_tab_index = active_tab_index < entry.urls.size() ? active_tab_index : entry.urls.size() - 1;
|
|
}
|
|
|
|
bool HistoryStore::has_recently_closed_entries() const
|
|
{
|
|
return !m_recently_closed_entries.is_empty();
|
|
}
|
|
|
|
Optional<RecentlyClosedEntry const&> HistoryStore::most_recently_closed_entry() const
|
|
{
|
|
if (m_recently_closed_entries.is_empty())
|
|
return {};
|
|
|
|
return m_recently_closed_entries.last();
|
|
}
|
|
|
|
Optional<RecentlyClosedEntry> HistoryStore::pop_most_recently_closed_entry()
|
|
{
|
|
if (m_recently_closed_entries.is_empty())
|
|
return {};
|
|
|
|
return m_recently_closed_entries.take_last();
|
|
}
|
|
|
|
Optional<HistoryEntry> HistoryStore::entry_for_url(URL::URL const& url)
|
|
{
|
|
if (m_is_disabled)
|
|
return {};
|
|
|
|
auto normalized_url = normalize_url(url);
|
|
if (!normalized_url.has_value())
|
|
return {};
|
|
|
|
auto entry = m_storage->entry_for_url(*normalized_url);
|
|
|
|
if (entry.has_value()) {
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Found history entry for '{}': title='{}' visits={} last_visited={} has_favicon={}",
|
|
entry->url,
|
|
entry->title.has_value() ? entry->title->bytes_as_string_view() : "<none>"sv,
|
|
entry->visit_count,
|
|
entry->last_visited_time.seconds_since_epoch(),
|
|
entry->favicon_base64_png.has_value());
|
|
} else {
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] No history entry found for '{}'", *normalized_url);
|
|
}
|
|
|
|
return entry;
|
|
}
|
|
|
|
Vector<HistoryEntry> HistoryStore::autocomplete_entries(StringView query, size_t limit)
|
|
{
|
|
if (m_is_disabled)
|
|
return {};
|
|
|
|
auto trimmed_query = query.trim_whitespace();
|
|
if (trimmed_query.is_empty()) {
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] History autocomplete query is empty after trimming");
|
|
return {};
|
|
}
|
|
|
|
auto title_query = autocomplete_title_query(trimmed_query);
|
|
auto url_query = autocomplete_url_query(trimmed_query);
|
|
|
|
auto entries = m_storage->autocomplete_entries(title_query, url_query, limit);
|
|
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] {} history autocomplete suggestions for '{}' (title_query='{}', url_query='{}', limit={}): {}",
|
|
m_storage->name(),
|
|
trimmed_query,
|
|
title_query,
|
|
url_query,
|
|
limit,
|
|
log_history_entries(entries));
|
|
|
|
return entries;
|
|
}
|
|
|
|
Vector<HistoryEntry> HistoryStore::list_entries(StringView query, size_t offset, size_t limit)
|
|
{
|
|
if (m_is_disabled || limit == 0)
|
|
return {};
|
|
|
|
auto title_query = query.trim_whitespace();
|
|
auto url_query = autocomplete_url_query(title_query);
|
|
|
|
auto entries = m_storage->list_entries(title_query, url_query, offset, limit);
|
|
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] {} history page entries for '{}' (title_query='{}', url_query='{}', offset={}, limit={}): {}",
|
|
m_storage->name(),
|
|
title_query,
|
|
title_query,
|
|
url_query,
|
|
offset,
|
|
limit,
|
|
log_history_entries(entries));
|
|
|
|
return entries;
|
|
}
|
|
|
|
void HistoryStore::clear()
|
|
{
|
|
if (m_is_disabled)
|
|
return;
|
|
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Clearing {} history store", m_storage->name());
|
|
m_storage->clear();
|
|
m_recently_closed_entries.clear();
|
|
}
|
|
|
|
void HistoryStore::remove_entry_for_url(URL::URL const& url)
|
|
{
|
|
if (m_is_disabled)
|
|
return;
|
|
|
|
auto normalized_url = normalize_url(url);
|
|
if (!normalized_url.has_value())
|
|
return;
|
|
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Removing history entry for '{}'", *normalized_url);
|
|
m_storage->remove_entry_for_url(*normalized_url);
|
|
}
|
|
|
|
static Optional<String> site_key_for_history_entry(URL::URL const& url)
|
|
{
|
|
if (!url.host().has_value() || url.host()->is_empty_host())
|
|
return {};
|
|
|
|
if (auto registrable_domain = url.host()->registrable_domain(); registrable_domain.has_value())
|
|
return registrable_domain.release_value();
|
|
|
|
return url.serialized_host();
|
|
}
|
|
|
|
static bool history_entry_matches_site_key(StringView entry_url, StringView site_key)
|
|
{
|
|
auto parsed_url = URL::Parser::basic_parse(entry_url);
|
|
if (!parsed_url.has_value())
|
|
return false;
|
|
|
|
auto const& host = parsed_url->host();
|
|
if (!host.has_value() || host->is_empty_host())
|
|
return false;
|
|
|
|
auto serialized_host = parsed_url->serialized_host();
|
|
auto serialized_host_view = serialized_host.bytes_as_string_view();
|
|
if (serialized_host_view.equals_ignoring_ascii_case(site_key))
|
|
return true;
|
|
|
|
return serialized_host_view.length() > site_key.length()
|
|
&& serialized_host_view.ends_with(site_key, CaseSensitivity::CaseInsensitive)
|
|
&& serialized_host_view[serialized_host_view.length() - site_key.length() - 1] == '.';
|
|
}
|
|
|
|
void HistoryStore::remove_entries_for_same_site(URL::URL const& url)
|
|
{
|
|
if (m_is_disabled)
|
|
return;
|
|
|
|
auto site_key = site_key_for_history_entry(url);
|
|
if (!site_key.has_value()) {
|
|
remove_entry_for_url(url);
|
|
return;
|
|
}
|
|
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Removing history entries for site '{}'", *site_key);
|
|
m_storage->remove_entries_for_same_site(*site_key);
|
|
}
|
|
|
|
void HistoryStore::remove_entries_accessed_since(UnixDateTime since)
|
|
{
|
|
if (m_is_disabled)
|
|
return;
|
|
|
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Removing {} history entries accessed since {}",
|
|
m_storage->name(),
|
|
since.seconds_since_epoch());
|
|
m_storage->remove_entries_accessed_since(since);
|
|
m_recently_closed_entries.remove_all_matching([&](auto const& entry) {
|
|
return entry.closed_time >= since;
|
|
});
|
|
}
|
|
|
|
void HistoryStore::TransientStorage::record_visit(String const& url, Optional<String> const& title, UnixDateTime visited_at)
|
|
{
|
|
auto entry = m_entries.find(url);
|
|
if (entry == m_entries.end()) {
|
|
auto new_entry = HistoryEntry {
|
|
.url = url,
|
|
.title = move(title),
|
|
.favicon_base64_png = {},
|
|
.visit_count = 1,
|
|
.last_visited_time = visited_at,
|
|
};
|
|
m_entries.set(
|
|
move(url),
|
|
move(new_entry));
|
|
return;
|
|
}
|
|
|
|
entry->value.visit_count++;
|
|
entry->value.last_visited_time = visited_at;
|
|
if (title.has_value() && !title->is_empty())
|
|
entry->value.title = move(title);
|
|
}
|
|
|
|
void HistoryStore::TransientStorage::update_title(String const& url, String const& title)
|
|
{
|
|
auto entry = m_entries.find(url);
|
|
if (entry == m_entries.end())
|
|
return;
|
|
|
|
entry->value.title = move(title);
|
|
}
|
|
|
|
void HistoryStore::TransientStorage::update_favicon(String const& url, String const& favicon_base64_png)
|
|
{
|
|
auto entry = m_entries.find(url);
|
|
if (entry == m_entries.end())
|
|
return;
|
|
|
|
entry->value.favicon_base64_png = move(favicon_base64_png);
|
|
}
|
|
|
|
Optional<HistoryEntry> HistoryStore::TransientStorage::entry_for_url(String const& url)
|
|
{
|
|
auto entry = m_entries.get(url);
|
|
if (!entry.has_value())
|
|
return {};
|
|
|
|
return *entry;
|
|
}
|
|
|
|
Vector<HistoryEntry> HistoryStore::TransientStorage::autocomplete_entries(StringView title_query, StringView url_query, size_t limit)
|
|
{
|
|
Vector<HistoryEntry const*> matches;
|
|
|
|
for (auto const& entry : m_entries) {
|
|
if (matches_query(entry.value, title_query, url_query))
|
|
matches.append(&entry.value);
|
|
}
|
|
|
|
sort_matching_entries(matches, title_query, url_query);
|
|
|
|
Vector<HistoryEntry> entries;
|
|
entries.ensure_capacity(min(limit, matches.size()));
|
|
|
|
for (size_t i = 0; i < matches.size() && i < limit; ++i)
|
|
entries.unchecked_append(*matches[i]);
|
|
|
|
return entries;
|
|
}
|
|
|
|
static bool matches_history_page_query(HistoryEntry const& entry, StringView title_query, StringView url_query)
|
|
{
|
|
if (title_query.is_empty() && url_query.is_empty())
|
|
return true;
|
|
|
|
auto searchable_url = autocomplete_searchable_url(entry.url.bytes_as_string_view());
|
|
if (!url_query.is_empty() && searchable_url.contains(url_query, CaseSensitivity::CaseInsensitive))
|
|
return true;
|
|
|
|
return !title_query.is_empty()
|
|
&& entry.title.has_value()
|
|
&& entry.title->contains(title_query, CaseSensitivity::CaseInsensitive);
|
|
}
|
|
|
|
static void sort_entries_for_history_page(Vector<HistoryEntry const*>& matches)
|
|
{
|
|
quick_sort(matches, [](auto const* left, auto const* right) {
|
|
if (left->last_visited_time != right->last_visited_time)
|
|
return left->last_visited_time > right->last_visited_time;
|
|
return left->url < right->url;
|
|
});
|
|
}
|
|
|
|
Vector<HistoryEntry> HistoryStore::TransientStorage::list_entries(StringView title_query, StringView url_query, size_t offset, size_t limit)
|
|
{
|
|
Vector<HistoryEntry const*> matches;
|
|
|
|
for (auto const& entry : m_entries) {
|
|
if (matches_history_page_query(entry.value, title_query, url_query))
|
|
matches.append(&entry.value);
|
|
}
|
|
|
|
sort_entries_for_history_page(matches);
|
|
|
|
Vector<HistoryEntry> entries;
|
|
if (offset >= matches.size())
|
|
return entries;
|
|
|
|
auto end = min(matches.size(), offset + limit);
|
|
entries.ensure_capacity(end - offset);
|
|
|
|
for (size_t i = offset; i < end; ++i)
|
|
entries.unchecked_append(*matches[i]);
|
|
|
|
return entries;
|
|
}
|
|
|
|
void HistoryStore::TransientStorage::clear()
|
|
{
|
|
m_entries.clear();
|
|
}
|
|
|
|
void HistoryStore::TransientStorage::remove_entry_for_url(String const& url)
|
|
{
|
|
m_entries.remove(url);
|
|
}
|
|
|
|
void HistoryStore::TransientStorage::remove_entries_for_same_site(StringView site_key)
|
|
{
|
|
m_entries.remove_all_matching([&](auto const&, auto const& entry) {
|
|
return history_entry_matches_site_key(entry.url, site_key);
|
|
});
|
|
}
|
|
|
|
void HistoryStore::TransientStorage::remove_entries_accessed_since(UnixDateTime since)
|
|
{
|
|
m_entries.remove_all_matching([&](auto const&, auto const& entry) {
|
|
return entry.last_visited_time >= since;
|
|
});
|
|
}
|
|
|
|
HistoryStore::PersistedStorage::PersistedStorage(Database::Database& database, Statements&& statements)
|
|
: m_database(database)
|
|
, m_statements(move(statements))
|
|
{
|
|
}
|
|
|
|
HistoryStore::PersistedStorage::~PersistedStorage() = default;
|
|
|
|
void HistoryStore::PersistedStorage::record_visit(String const& url, Optional<String> const& title, UnixDateTime visited_at)
|
|
{
|
|
m_database.execute_statement(
|
|
m_statements.upsert_entry,
|
|
{},
|
|
url,
|
|
title.value_or(String {}),
|
|
visited_at);
|
|
}
|
|
|
|
void HistoryStore::PersistedStorage::update_title(String const& url, String const& title)
|
|
{
|
|
m_database.execute_statement(
|
|
m_statements.update_title,
|
|
{},
|
|
title,
|
|
url);
|
|
}
|
|
|
|
void HistoryStore::PersistedStorage::update_favicon(String const& url, String const& favicon_base64_png)
|
|
{
|
|
m_database.execute_statement(
|
|
m_statements.update_favicon,
|
|
{},
|
|
favicon_base64_png,
|
|
url);
|
|
}
|
|
|
|
Optional<HistoryEntry> HistoryStore::PersistedStorage::entry_for_url(String const& url)
|
|
{
|
|
Optional<HistoryEntry> entry;
|
|
|
|
m_database.execute_statement(
|
|
m_statements.get_entry,
|
|
[&](auto statement_id) {
|
|
auto title = m_database.result_column<String>(statement_id, 0);
|
|
auto favicon = m_database.result_column<String>(statement_id, 3);
|
|
|
|
entry = HistoryEntry {
|
|
.url = url,
|
|
.title = title.is_empty() ? Optional<String> {} : Optional<String> { move(title) },
|
|
.favicon_base64_png = favicon.is_empty() ? Optional<String> {} : Optional<String> { move(favicon) },
|
|
.visit_count = m_database.result_column<u64>(statement_id, 1),
|
|
.last_visited_time = m_database.result_column<UnixDateTime>(statement_id, 2),
|
|
};
|
|
},
|
|
url);
|
|
|
|
return entry;
|
|
}
|
|
|
|
Vector<HistoryEntry> HistoryStore::PersistedStorage::autocomplete_entries(StringView title_query, StringView url_query, size_t 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)));
|
|
|
|
m_database.execute_statement(
|
|
m_statements.search_entries,
|
|
[&](auto statement_id) {
|
|
auto title = m_database.result_column<String>(statement_id, 1);
|
|
auto favicon = m_database.result_column<String>(statement_id, 4);
|
|
|
|
entries.append(HistoryEntry {
|
|
.url = m_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 = m_database.result_column<u64>(statement_id, 2),
|
|
.last_visited_time = m_database.result_column<UnixDateTime>(statement_id, 3),
|
|
});
|
|
},
|
|
url_query_string,
|
|
url_contains_query_string,
|
|
title_query_string,
|
|
static_cast<i64>(limit));
|
|
|
|
return entries;
|
|
}
|
|
|
|
Vector<HistoryEntry> HistoryStore::PersistedStorage::list_entries(StringView title_query, StringView url_query, size_t offset, size_t limit)
|
|
{
|
|
Vector<HistoryEntry> entries;
|
|
entries.ensure_capacity(limit);
|
|
auto title_query_string = MUST(String::from_utf8(title_query));
|
|
auto url_query_string = MUST(String::from_utf8(url_query));
|
|
|
|
m_database.execute_statement(
|
|
m_statements.list_entries,
|
|
[&](auto statement_id) {
|
|
auto title = m_database.result_column<String>(statement_id, 1);
|
|
auto favicon = m_database.result_column<String>(statement_id, 4);
|
|
|
|
entries.append(HistoryEntry {
|
|
.url = m_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 = m_database.result_column<u64>(statement_id, 2),
|
|
.last_visited_time = m_database.result_column<UnixDateTime>(statement_id, 3),
|
|
});
|
|
},
|
|
title_query_string,
|
|
url_query_string,
|
|
static_cast<i64>(limit),
|
|
static_cast<i64>(offset));
|
|
|
|
return entries;
|
|
}
|
|
|
|
void HistoryStore::PersistedStorage::clear()
|
|
{
|
|
m_database.execute_statement(m_statements.clear_entries, {});
|
|
}
|
|
|
|
void HistoryStore::PersistedStorage::remove_entry_for_url(String const& url)
|
|
{
|
|
m_database.execute_statement(m_statements.delete_entry, {}, url);
|
|
}
|
|
|
|
void HistoryStore::PersistedStorage::remove_entries_for_same_site(StringView site_key)
|
|
{
|
|
Vector<String> urls_to_remove;
|
|
|
|
m_database.execute_statement(
|
|
m_statements.all_urls,
|
|
[&](auto statement_id) {
|
|
auto url = m_database.result_column<String>(statement_id, 0);
|
|
if (history_entry_matches_site_key(url.bytes_as_string_view(), site_key))
|
|
urls_to_remove.append(move(url));
|
|
});
|
|
|
|
for (auto const& url : urls_to_remove)
|
|
remove_entry_for_url(url);
|
|
}
|
|
|
|
void HistoryStore::PersistedStorage::remove_entries_accessed_since(UnixDateTime since)
|
|
{
|
|
m_database.execute_statement(m_statements.delete_entries_accessed_since, {}, since);
|
|
}
|
|
|
|
}
|