mirror of
https://github.com/LadybirdBrowser/ladybird
synced 2026-04-25 17:25:08 +02:00
LibWebView: Add a SQL history store
Add a HistoryStore abstraction with transient and persisted backends, normalize recorded URLs, and skip non-browsable schemes. Cover lookup and persistence in TestHistoryStore so history-driven features can share one backend.
This commit is contained in:
committed by
Andreas Kling
parent
2b47098301
commit
54f14609f4
Notes:
github-actions[bot]
2026-04-16 19:03:35 +00:00
Author: https://github.com/awesomekling Commit: https://github.com/LadybirdBrowser/ladybird/commit/54f14609f4e Pull-request: https://github.com/LadybirdBrowser/ladybird/pull/8933 Reviewed-by: https://github.com/trflynn89
@@ -178,6 +178,10 @@
|
|||||||
# cmakedefine01 LINE_EDITOR_DEBUG
|
# cmakedefine01 LINE_EDITOR_DEBUG
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#ifndef WEBVIEW_HISTORY_DEBUG
|
||||||
|
# cmakedefine01 WEBVIEW_HISTORY_DEBUG
|
||||||
|
#endif
|
||||||
|
|
||||||
#ifndef LZW_DEBUG
|
#ifndef LZW_DEBUG
|
||||||
# cmakedefine01 LZW_DEBUG
|
# cmakedefine01 LZW_DEBUG
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ set(SOURCES
|
|||||||
DOMNodeProperties.cpp
|
DOMNodeProperties.cpp
|
||||||
FileDownloader.cpp
|
FileDownloader.cpp
|
||||||
HeadlessWebView.cpp
|
HeadlessWebView.cpp
|
||||||
|
HistoryStore.cpp
|
||||||
HelperProcess.cpp
|
HelperProcess.cpp
|
||||||
Menu.cpp
|
Menu.cpp
|
||||||
Mutation.cpp
|
Mutation.cpp
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class Application;
|
|||||||
class Autocomplete;
|
class Autocomplete;
|
||||||
class BookmarkStore;
|
class BookmarkStore;
|
||||||
class CookieJar;
|
class CookieJar;
|
||||||
|
class HistoryStore;
|
||||||
class Menu;
|
class Menu;
|
||||||
class OutOfProcessWebView;
|
class OutOfProcessWebView;
|
||||||
class ProcessManager;
|
class ProcessManager;
|
||||||
|
|||||||
383
Libraries/LibWebView/HistoryStore.cpp
Normal file
383
Libraries/LibWebView/HistoryStore.cpp
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026-present, the Ladybird developers.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <AK/Debug.h>
|
||||||
|
#include <AK/QuickSort.h>
|
||||||
|
#include <LibDatabase/Database.h>
|
||||||
|
#include <LibURL/URL.h>
|
||||||
|
#include <LibWebView/HistoryStore.h>
|
||||||
|
|
||||||
|
namespace WebView {
|
||||||
|
|
||||||
|
static constexpr auto DEFAULT_AUTOCOMPLETE_SUGGESTION_LIMIT = 8uz;
|
||||||
|
|
||||||
|
static bool matches_query(HistoryEntry const& entry, StringView query)
|
||||||
|
{
|
||||||
|
if (entry.url.contains(query, CaseSensitivity::CaseInsensitive))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return entry.title.has_value()
|
||||||
|
&& entry.title->contains(query, CaseSensitivity::CaseInsensitive);
|
||||||
|
}
|
||||||
|
|
||||||
|
static u8 match_rank(HistoryEntry const& entry, StringView query)
|
||||||
|
{
|
||||||
|
auto url = entry.url.bytes_as_string_view();
|
||||||
|
|
||||||
|
if (entry.url.equals_ignoring_ascii_case(query))
|
||||||
|
return 0;
|
||||||
|
if (url.starts_with(query, CaseSensitivity::CaseInsensitive))
|
||||||
|
return 1;
|
||||||
|
if (auto scheme_separator = url.find("://"sv); scheme_separator.has_value() && url.substring_view(*scheme_separator + 3).starts_with(query, CaseSensitivity::CaseInsensitive))
|
||||||
|
return 2;
|
||||||
|
if (entry.title.has_value() && entry.title->starts_with_bytes(query, CaseSensitivity::CaseInsensitive))
|
||||||
|
return 3;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sort_matching_entries(Vector<HistoryEntry const*>& matches, StringView query)
|
||||||
|
{
|
||||||
|
quick_sort(matches, [&](auto const* left, auto const* right) {
|
||||||
|
auto left_rank = match_rank(*left, query);
|
||||||
|
auto right_rank = match_rank(*right, 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorOr<NonnullOwnPtr<HistoryStore>> HistoryStore::create(Database::Database& database)
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
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.get_entry = TRY(database.prepare_statement(R"#(
|
||||||
|
SELECT title, visit_count, last_visited_time
|
||||||
|
FROM History
|
||||||
|
WHERE url = ?;
|
||||||
|
)#"sv));
|
||||||
|
statements.search_entries = TRY(database.prepare_statement(R"#(
|
||||||
|
SELECT url
|
||||||
|
FROM History
|
||||||
|
WHERE INSTR(LOWER(url), LOWER(?)) > 0
|
||||||
|
OR INSTR(LOWER(title), LOWER(?)) > 0
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN LOWER(url) = LOWER(?) THEN 0
|
||||||
|
WHEN LOWER(url) LIKE LOWER(?) || '%' THEN 1
|
||||||
|
WHEN INSTR(LOWER(url), '://' || LOWER(?)) > 0 THEN 2
|
||||||
|
WHEN LOWER(title) LIKE LOWER(?) || '%' THEN 3
|
||||||
|
ELSE 4
|
||||||
|
END,
|
||||||
|
visit_count DESC,
|
||||||
|
last_visited_time DESC,
|
||||||
|
url ASC
|
||||||
|
LIMIT ?;
|
||||||
|
)#"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));
|
||||||
|
|
||||||
|
return adopt_own(*new HistoryStore { PersistedStorage { database, statements } });
|
||||||
|
}
|
||||||
|
|
||||||
|
NonnullOwnPtr<HistoryStore> HistoryStore::create()
|
||||||
|
{
|
||||||
|
return adopt_own(*new HistoryStore { OptionalNone {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
NonnullOwnPtr<HistoryStore> HistoryStore::create_disabled()
|
||||||
|
{
|
||||||
|
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Opening disabled history store");
|
||||||
|
|
||||||
|
return adopt_own(*new HistoryStore { OptionalNone {}, true });
|
||||||
|
}
|
||||||
|
|
||||||
|
HistoryStore::HistoryStore(Optional<PersistedStorage> persisted_storage, bool is_disabled)
|
||||||
|
: m_persisted_storage(move(persisted_storage))
|
||||||
|
, m_is_disabled(is_disabled)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
HistoryStore::~HistoryStore() = default;
|
||||||
|
|
||||||
|
Optional<String> HistoryStore::normalize_url(URL::URL const& url)
|
||||||
|
{
|
||||||
|
if (url.scheme().is_empty())
|
||||||
|
return {};
|
||||||
|
|
||||||
|
if (url.scheme().is_one_of("about"sv, "data"sv))
|
||||||
|
return {};
|
||||||
|
|
||||||
|
auto normalized_url = url.serialize(URL::ExcludeFragment::Yes);
|
||||||
|
if (normalized_url.is_empty())
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (m_persisted_storage.has_value())
|
||||||
|
m_persisted_storage->record_visit(*normalized_url, title, visited_at);
|
||||||
|
else
|
||||||
|
m_transient_storage.record_visit(normalized_url.release_value(), move(title), visited_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
void HistoryStore::update_title(URL::URL const& url, String const& title)
|
||||||
|
{
|
||||||
|
if (m_is_disabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (title.is_empty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
auto normalized_url = normalize_url(url);
|
||||||
|
if (!normalized_url.has_value())
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (m_persisted_storage.has_value())
|
||||||
|
m_persisted_storage->update_title(*normalized_url, title);
|
||||||
|
else
|
||||||
|
m_transient_storage.update_title(*normalized_url, title);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {};
|
||||||
|
|
||||||
|
if (m_persisted_storage.has_value())
|
||||||
|
return m_persisted_storage->entry_for_url(*normalized_url);
|
||||||
|
return m_transient_storage.entry_for_url(*normalized_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector<String> HistoryStore::autocomplete_suggestions(StringView query, size_t limit)
|
||||||
|
{
|
||||||
|
if (m_is_disabled)
|
||||||
|
return {};
|
||||||
|
|
||||||
|
auto trimmed_query = query.trim_whitespace();
|
||||||
|
if (trimmed_query.is_empty())
|
||||||
|
return {};
|
||||||
|
|
||||||
|
if (m_persisted_storage.has_value())
|
||||||
|
return m_persisted_storage->autocomplete_suggestions(trimmed_query, limit);
|
||||||
|
return m_transient_storage.autocomplete_suggestions(trimmed_query, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
void HistoryStore::clear()
|
||||||
|
{
|
||||||
|
if (m_is_disabled)
|
||||||
|
return;
|
||||||
|
if (m_persisted_storage.has_value())
|
||||||
|
m_persisted_storage->clear();
|
||||||
|
else
|
||||||
|
m_transient_storage.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void HistoryStore::remove_entries_accessed_since(UnixDateTime since)
|
||||||
|
{
|
||||||
|
if (m_is_disabled)
|
||||||
|
return;
|
||||||
|
if (m_persisted_storage.has_value())
|
||||||
|
m_persisted_storage->remove_entries_accessed_since(since);
|
||||||
|
else
|
||||||
|
m_transient_storage.remove_entries_accessed_since(since);
|
||||||
|
}
|
||||||
|
|
||||||
|
void HistoryStore::TransientStorage::record_visit(String url, Optional<String> title, UnixDateTime visited_at)
|
||||||
|
{
|
||||||
|
auto entry = m_entries.find(url);
|
||||||
|
if (entry == m_entries.end()) {
|
||||||
|
auto new_entry = HistoryEntry {
|
||||||
|
.url = url,
|
||||||
|
.title = move(title),
|
||||||
|
.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 title)
|
||||||
|
{
|
||||||
|
auto entry = m_entries.find(url);
|
||||||
|
if (entry == m_entries.end())
|
||||||
|
return;
|
||||||
|
|
||||||
|
entry->value.title = move(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<HistoryEntry> HistoryStore::TransientStorage::entry_for_url(String const& url)
|
||||||
|
{
|
||||||
|
auto entry = m_entries.get(url);
|
||||||
|
if (!entry.has_value())
|
||||||
|
return {};
|
||||||
|
|
||||||
|
return *entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector<String> HistoryStore::TransientStorage::autocomplete_suggestions(StringView query, size_t limit)
|
||||||
|
{
|
||||||
|
Vector<HistoryEntry const*> matches;
|
||||||
|
|
||||||
|
for (auto const& entry : m_entries) {
|
||||||
|
if (matches_query(entry.value, query))
|
||||||
|
matches.append(&entry.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
sort_matching_entries(matches, query);
|
||||||
|
|
||||||
|
Vector<String> suggestions;
|
||||||
|
suggestions.ensure_capacity(min(limit, matches.size()));
|
||||||
|
|
||||||
|
for (size_t i = 0; i < matches.size() && i < limit; ++i)
|
||||||
|
suggestions.unchecked_append(matches[i]->url);
|
||||||
|
|
||||||
|
return suggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
void HistoryStore::TransientStorage::clear()
|
||||||
|
{
|
||||||
|
m_entries.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void HistoryStore::PersistedStorage::record_visit(String const& url, Optional<String> const& title, UnixDateTime visited_at)
|
||||||
|
{
|
||||||
|
database.execute_statement(
|
||||||
|
statements.upsert_entry,
|
||||||
|
{},
|
||||||
|
url,
|
||||||
|
title.value_or(String {}),
|
||||||
|
visited_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
void HistoryStore::PersistedStorage::update_title(String const& url, String const& title)
|
||||||
|
{
|
||||||
|
database.execute_statement(
|
||||||
|
statements.update_title,
|
||||||
|
{},
|
||||||
|
title,
|
||||||
|
url);
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<HistoryEntry> HistoryStore::PersistedStorage::entry_for_url(String const& url)
|
||||||
|
{
|
||||||
|
Optional<HistoryEntry> entry;
|
||||||
|
|
||||||
|
database.execute_statement(
|
||||||
|
statements.get_entry,
|
||||||
|
[&](auto statement_id) {
|
||||||
|
auto title = database.result_column<String>(statement_id, 0);
|
||||||
|
|
||||||
|
entry = HistoryEntry {
|
||||||
|
.url = url,
|
||||||
|
.title = title.is_empty() ? Optional<String> {} : Optional<String> { move(title) },
|
||||||
|
.visit_count = database.result_column<u64>(statement_id, 1),
|
||||||
|
.last_visited_time = database.result_column<UnixDateTime>(statement_id, 2),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
url);
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector<String> HistoryStore::PersistedStorage::autocomplete_suggestions(StringView query, size_t limit)
|
||||||
|
{
|
||||||
|
Vector<String> suggestions;
|
||||||
|
suggestions.ensure_capacity(min(limit, DEFAULT_AUTOCOMPLETE_SUGGESTION_LIMIT));
|
||||||
|
|
||||||
|
database.execute_statement(
|
||||||
|
statements.search_entries,
|
||||||
|
[&](auto statement_id) {
|
||||||
|
suggestions.append(database.result_column<String>(statement_id, 0));
|
||||||
|
},
|
||||||
|
MUST(String::from_utf8(query)),
|
||||||
|
MUST(String::from_utf8(query)),
|
||||||
|
MUST(String::from_utf8(query)),
|
||||||
|
MUST(String::from_utf8(query)),
|
||||||
|
MUST(String::from_utf8(query)),
|
||||||
|
MUST(String::from_utf8(query)),
|
||||||
|
static_cast<i64>(limit));
|
||||||
|
|
||||||
|
return suggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
void HistoryStore::PersistedStorage::clear()
|
||||||
|
{
|
||||||
|
database.execute_statement(statements.clear_entries, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void HistoryStore::PersistedStorage::remove_entries_accessed_since(UnixDateTime since)
|
||||||
|
{
|
||||||
|
database.execute_statement(statements.delete_entries_accessed_since, {}, since);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
94
Libraries/LibWebView/HistoryStore.h
Normal file
94
Libraries/LibWebView/HistoryStore.h
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026-present, the Ladybird developers.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <AK/HashMap.h>
|
||||||
|
#include <AK/NonnullOwnPtr.h>
|
||||||
|
#include <AK/Optional.h>
|
||||||
|
#include <AK/String.h>
|
||||||
|
#include <AK/Time.h>
|
||||||
|
#include <LibDatabase/Forward.h>
|
||||||
|
#include <LibURL/Forward.h>
|
||||||
|
#include <LibWebView/Export.h>
|
||||||
|
|
||||||
|
namespace WebView {
|
||||||
|
|
||||||
|
struct WEBVIEW_API HistoryEntry {
|
||||||
|
String url;
|
||||||
|
Optional<String> title;
|
||||||
|
u64 visit_count { 0 };
|
||||||
|
UnixDateTime last_visited_time;
|
||||||
|
};
|
||||||
|
|
||||||
|
class WEBVIEW_API HistoryStore {
|
||||||
|
AK_MAKE_NONCOPYABLE(HistoryStore);
|
||||||
|
AK_MAKE_NONMOVABLE(HistoryStore);
|
||||||
|
|
||||||
|
public:
|
||||||
|
static ErrorOr<NonnullOwnPtr<HistoryStore>> create(Database::Database&);
|
||||||
|
static NonnullOwnPtr<HistoryStore> create();
|
||||||
|
static NonnullOwnPtr<HistoryStore> create_disabled();
|
||||||
|
|
||||||
|
~HistoryStore();
|
||||||
|
|
||||||
|
void record_visit(URL::URL const&, Optional<String> title = {}, UnixDateTime visited_at = UnixDateTime::now());
|
||||||
|
void update_title(URL::URL const&, String const& title);
|
||||||
|
|
||||||
|
Optional<HistoryEntry> entry_for_url(URL::URL const&);
|
||||||
|
Vector<String> autocomplete_suggestions(StringView query, size_t limit = 8);
|
||||||
|
|
||||||
|
void clear();
|
||||||
|
void remove_entries_accessed_since(UnixDateTime since);
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Statements {
|
||||||
|
Database::StatementID upsert_entry { 0 };
|
||||||
|
Database::StatementID update_title { 0 };
|
||||||
|
Database::StatementID get_entry { 0 };
|
||||||
|
Database::StatementID search_entries { 0 };
|
||||||
|
Database::StatementID clear_entries { 0 };
|
||||||
|
Database::StatementID delete_entries_accessed_since { 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
class TransientStorage {
|
||||||
|
public:
|
||||||
|
void record_visit(String url, Optional<String> title, UnixDateTime visited_at);
|
||||||
|
void update_title(String const& url, String title);
|
||||||
|
|
||||||
|
Optional<HistoryEntry> entry_for_url(String const& url);
|
||||||
|
Vector<String> autocomplete_suggestions(StringView query, size_t limit);
|
||||||
|
|
||||||
|
void clear();
|
||||||
|
void remove_entries_accessed_since(UnixDateTime since);
|
||||||
|
|
||||||
|
private:
|
||||||
|
HashMap<String, HistoryEntry> m_entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PersistedStorage {
|
||||||
|
void record_visit(String const& url, Optional<String> const& title, UnixDateTime visited_at);
|
||||||
|
void update_title(String const& url, String const& title);
|
||||||
|
|
||||||
|
Optional<HistoryEntry> entry_for_url(String const& url);
|
||||||
|
Vector<String> autocomplete_suggestions(StringView query, size_t limit);
|
||||||
|
|
||||||
|
void clear();
|
||||||
|
void remove_entries_accessed_since(UnixDateTime since);
|
||||||
|
|
||||||
|
Database::Database& database;
|
||||||
|
Statements statements;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
bool m_is_disabled { false };
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ set(JOB_DEBUG ON)
|
|||||||
set(JS_BYTECODE_DEBUG ON)
|
set(JS_BYTECODE_DEBUG ON)
|
||||||
set(JS_MODULE_DEBUG ON)
|
set(JS_MODULE_DEBUG ON)
|
||||||
set(LEXER_DEBUG ON)
|
set(LEXER_DEBUG ON)
|
||||||
|
set(WEBVIEW_HISTORY_DEBUG ON)
|
||||||
set(LIBWEB_CSS_ANIMATION_DEBUG ON)
|
set(LIBWEB_CSS_ANIMATION_DEBUG ON)
|
||||||
set(LIBWEB_CSS_DEBUG ON)
|
set(LIBWEB_CSS_DEBUG ON)
|
||||||
set(LIBWEB_WASM_DEBUG ON)
|
set(LIBWEB_WASM_DEBUG ON)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
set(TEST_SOURCES
|
set(TEST_SOURCES
|
||||||
|
TestHistoryStore.cpp
|
||||||
TestWebViewURL.cpp
|
TestWebViewURL.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
foreach(source IN LISTS TEST_SOURCES)
|
foreach(source IN LISTS TEST_SOURCES)
|
||||||
ladybird_test("${source}" LibWebView LIBS LibWebView LibURL)
|
ladybird_test("${source}" LibWebView LIBS LibDatabase LibWebView LibURL)
|
||||||
endforeach()
|
endforeach()
|
||||||
|
|||||||
110
Tests/LibWebView/TestHistoryStore.cpp
Normal file
110
Tests/LibWebView/TestHistoryStore.cpp
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026-present, the Ladybird developers.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <AK/Random.h>
|
||||||
|
#include <AK/ScopeGuard.h>
|
||||||
|
#include <LibCore/Directory.h>
|
||||||
|
#include <LibCore/StandardPaths.h>
|
||||||
|
#include <LibDatabase/Database.h>
|
||||||
|
#include <LibFileSystem/FileSystem.h>
|
||||||
|
#include <LibTest/TestCase.h>
|
||||||
|
#include <LibURL/Parser.h>
|
||||||
|
#include <LibWebView/HistoryStore.h>
|
||||||
|
|
||||||
|
static URL::URL parse_url(StringView url)
|
||||||
|
{
|
||||||
|
auto parsed_url = URL::Parser::basic_parse(url);
|
||||||
|
VERIFY(parsed_url.has_value());
|
||||||
|
return parsed_url.release_value();
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE(record_and_lookup_history_entries)
|
||||||
|
{
|
||||||
|
auto store = WebView::HistoryStore::create();
|
||||||
|
|
||||||
|
auto visited_at = UnixDateTime::from_seconds_since_epoch(1234);
|
||||||
|
store->record_visit(parse_url("https://example.com/path#fragment"sv), "Example page"_string, visited_at);
|
||||||
|
store->record_visit(parse_url("https://example.com/path"sv), {}, visited_at);
|
||||||
|
|
||||||
|
auto entry = store->entry_for_url(parse_url("https://example.com/path"sv));
|
||||||
|
VERIFY(entry.has_value());
|
||||||
|
|
||||||
|
EXPECT_EQ(entry->url, "https://example.com/path"_string);
|
||||||
|
EXPECT_EQ(entry->title, Optional<String> { "Example page"_string });
|
||||||
|
EXPECT_EQ(entry->visit_count, 2u);
|
||||||
|
EXPECT_EQ(entry->last_visited_time, visited_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE(history_autocomplete_prefers_url_prefix_then_recency)
|
||||||
|
{
|
||||||
|
auto store = WebView::HistoryStore::create();
|
||||||
|
|
||||||
|
store->record_visit(parse_url("https://beta.example.com/"sv), "Alpha reference"_string, UnixDateTime::from_seconds_since_epoch(10));
|
||||||
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE(non_browsable_urls_are_not_recorded)
|
||||||
|
{
|
||||||
|
auto store = WebView::HistoryStore::create();
|
||||||
|
|
||||||
|
store->record_visit(parse_url("about:blank"sv));
|
||||||
|
store->record_visit(parse_url("data:text/plain,hello"sv));
|
||||||
|
|
||||||
|
EXPECT(!store->entry_for_url(parse_url("about:blank"sv)).has_value());
|
||||||
|
EXPECT(!store->entry_for_url(parse_url("data:text/plain,hello"sv)).has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE(disabled_history_store_ignores_updates)
|
||||||
|
{
|
||||||
|
auto store = WebView::HistoryStore::create_disabled();
|
||||||
|
auto url = parse_url("https://example.com/"sv);
|
||||||
|
|
||||||
|
store->record_visit(url, "Example"_string, UnixDateTime::from_seconds_since_epoch(10));
|
||||||
|
store->update_title(url, "Example title"_string);
|
||||||
|
store->remove_entries_accessed_since(UnixDateTime::from_seconds_since_epoch(0));
|
||||||
|
store->clear();
|
||||||
|
|
||||||
|
EXPECT(!store->entry_for_url(url).has_value());
|
||||||
|
EXPECT(store->autocomplete_suggestions("example"sv, 8).is_empty());
|
||||||
|
}
|
||||||
|
TEST_CASE(persisted_history_survives_reopen)
|
||||||
|
{
|
||||||
|
auto database_directory = ByteString::formatted(
|
||||||
|
"{}/ladybird-history-store-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));
|
||||||
|
store->record_visit(parse_url("https://persist.example.com/"sv), "Persisted title"_string, UnixDateTime::from_seconds_since_epoch(77));
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
auto database = TRY_OR_FAIL(Database::Database::create(database_directory, "HistoryStore"sv));
|
||||||
|
auto store = TRY_OR_FAIL(WebView::HistoryStore::create(*database));
|
||||||
|
|
||||||
|
auto entry = store->entry_for_url(parse_url("https://persist.example.com/"sv));
|
||||||
|
VERIFY(entry.has_value());
|
||||||
|
|
||||||
|
EXPECT_EQ(entry->title, Optional<String> { "Persisted title"_string });
|
||||||
|
EXPECT_EQ(entry->visit_count, 1u);
|
||||||
|
EXPECT_EQ(entry->last_visited_time, UnixDateTime::from_seconds_since_epoch(77));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user