LibWebView: Add bookmark import/export to about:bookmarks

Implement bookmark import/export in about:bookmarks using Netscape
bookmark HTML in JavaScript.
Import parsed items into BookmarkStore under an "Imported Bookmarks"
folder, and treat internal WebUI about: pages as potentially
trustworthy so SecureContext APIs are available there.
This commit is contained in:
mikiubo 2026-04-15 09:10:16 +02:00 committed by Tim Flynn
parent 22c1b72588
commit 131014427c
6 changed files with 326 additions and 1 deletions

View file

@ -11,6 +11,9 @@
--tree-selected-color: #d8d8f8;
--empty-text-color: #888;
--url-text-color: #666;
--menu-background-color: #fff;
--menu-border-color: #d8d8d8;
--menu-shadow-color: rgba(0, 0, 0, 0.12);
}
}
@ -20,9 +23,82 @@
--tree-selected-color: #35355a;
--empty-text-color: #777;
--url-text-color: #999;
--menu-background-color: #2a2a2a;
--menu-border-color: #4a4a4a;
--menu-shadow-color: rgba(0, 0, 0, 0.4);
}
}
.header-menu {
position: relative;
margin-left: auto;
}
.menu-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: inherit;
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
}
.menu-toggle:hover {
background-color: var(--tree-hover-color);
}
.menu-toggle svg {
width: 20px;
height: 20px;
}
.menu-dropdown {
position: absolute;
top: 100%;
right: 0;
z-index: 10;
display: flex;
flex-direction: column;
min-width: 200px;
padding: 4px;
margin-top: 4px;
background-color: var(--menu-background-color);
border: 1px solid var(--menu-border-color);
border-radius: 6px;
box-shadow: 0 4px 12px var(--menu-shadow-color);
}
.menu-dropdown.hidden {
display: none;
}
.menu-item {
color: inherit;
background: none;
border: none;
border-radius: 4px;
padding: 8px 12px;
font-size: 14px;
text-align: left;
cursor: pointer;
}
.menu-item:hover {
background-color: var(--tree-hover-color);
}
.tree-empty {
color: var(--empty-text-color);
@ -158,6 +234,20 @@
<img src="resource://icons/128x128/app-browser-dark.png" />
</picture>
<h1>Bookmarks</h1>
<div class="header-menu">
<button class="menu-toggle" id="menu-toggle" aria-label="Bookmark options">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="12" cy="5" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="12" cy="19" r="2" />
</svg>
</button>
<div class="menu-dropdown hidden" id="menu-dropdown">
<button class="menu-item" id="import-button">Import from HTML...</button>
<button class="menu-item" id="export-button">Export to HTML...</button>
</div>
</div>
<input type="file" id="import-file" accept=".html,text/html" hidden />
</header>
<div class="card">
@ -463,6 +553,175 @@
renderTree();
}
});
const menuToggle = document.getElementById("menu-toggle");
const menuDropdown = document.getElementById("menu-dropdown");
const importButton = document.getElementById("import-button");
const exportButton = document.getElementById("export-button");
const importFileInput = document.getElementById("import-file");
function closeMenu() {
menuDropdown.classList.add("hidden");
}
menuToggle.addEventListener("click", event => {
event.stopPropagation();
menuDropdown.classList.toggle("hidden");
});
document.addEventListener("click", event => {
if (!menuDropdown.contains(event.target) && event.target !== menuToggle) {
closeMenu();
}
});
// Import: parse Netscape Bookmark HTML format using DOMParser
function parseNetscapeBookmarks(html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
function parseItems(dl) {
const items = [];
if (!dl) {
return items;
}
for (const dt of dl.children) {
if (dt.tagName !== "DT") continue;
const anchor = dt.querySelector(":scope > A");
if (anchor) {
const item = {
id: crypto.randomUUID(),
type: "bookmark",
url: anchor.getAttribute("href") || "",
title: anchor.textContent.trim() || null,
};
const icon = anchor.getAttribute("icon");
if (icon) {
const match = icon.match(/^data:image\/png;base64,(.+)$/i);
if (match) {
item.favicon = match[1];
}
}
const addDate = anchor.getAttribute("add_date");
if (addDate && addDate !== "0") {
item.dateAdded = Number(addDate) * 1000;
}
const lastModified = anchor.getAttribute("last_modified");
if (lastModified && lastModified !== "0") {
item.lastModified = Number(lastModified) * 1000;
}
if (item.url) {
items.push(item);
}
continue;
}
const heading = dt.querySelector(":scope > H3");
if (heading) {
const childDl = dt.querySelector(":scope > DL");
const folder = {
id: crypto.randomUUID(),
type: "folder",
title: heading.textContent.trim() || null,
children: parseItems(childDl),
};
const addDate = heading.getAttribute("add_date");
if (addDate && addDate !== "0") {
folder.dateAdded = Number(addDate) * 1000;
}
const lastModified = heading.getAttribute("last_modified");
if (lastModified && lastModified !== "0") {
folder.lastModified = Number(lastModified) * 1000;
}
items.push(folder);
continue;
}
}
return items;
}
// The top-level <DL> is the root bookmark list
const topDl = doc.querySelector("DL");
return parseItems(topDl);
}
importButton.addEventListener("click", () => {
closeMenu();
importFileInput.value = "";
importFileInput.click();
});
importFileInput.addEventListener("change", () => {
const file = importFileInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const items = parseNetscapeBookmarks(reader.result);
if (items.length > 0) {
ladybird.sendMessage("importBookmarks", items);
}
};
reader.readAsText(file);
});
// Export: generate Netscape Bookmark HTML format from current bookmarks
function escapeHtml(text) {
return text.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)}`);
}
function generateBookmarkHtml(items, indent) {
const pad = " ".repeat(indent);
let html = `${pad}<DL><p>\n`;
for (const item of items) {
if (item.type === "bookmark") {
const title = item.title ? escapeHtml(item.title) : escapeHtml(item.url);
const addDate = item.dateAdded ? Math.floor(item.dateAdded / 1000) : 0;
let attrs = `HREF="${escapeHtml(item.url)}" ADD_DATE="${addDate}"`;
if (item.favicon) {
attrs += ` ICON="data:image/png;base64,${item.favicon}"`;
}
html += `${pad} <DT><A ${attrs}>${title}</A>\n`;
} else if (item.type === "folder") {
const title = item.title ? escapeHtml(item.title) : "Untitled Folder";
const addDate = item.dateAdded ? Math.floor(item.dateAdded / 1000) : 0;
const lastModified = item.lastModified ? Math.floor(item.lastModified / 1000) : 0;
html += `${pad} <DT><H3 ADD_DATE="${addDate}" LAST_MODIFIED="${lastModified}">${title}</H3>\n`;
html += generateBookmarkHtml(item.children || [], indent + 1);
}
}
html += `${pad}</DL><p>\n`;
return html;
}
exportButton.addEventListener("click", () => {
closeMenu();
let html = "<!DOCTYPE NETSCAPE-Bookmark-file-1>\n";
html += '<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">\n';
html += "<TITLE>Bookmarks</TITLE>\n";
html += "<H1>Bookmarks</H1>\n";
html += generateBookmarkHtml(BOOKMARKS, 0);
// FIXME: Route bookmark export through a web platform save/download API
// once one is implemented in Ladybird.
ladybird.sendMessage("exportBookmarks", html);
});
</script>
</body>
</html>

View file

@ -77,7 +77,11 @@ Trustworthiness is_url_potentially_trustworthy(URL::URL const& url)
if (url == URL::about_blank() || url == URL::about_srcdoc())
return Trustworthiness::PotentiallyTrustworthy;
// 2. If urls scheme is "data", return "Potentially Trustworthy".
// AD-HOC: Browser-internal about: pages (WebUI) are served directly by the user agent.
if (URL::is_webui_url(url))
return Trustworthiness::PotentiallyTrustworthy;
// 2. If url's scheme is "data", return "Potentially Trustworthy".
if (url.scheme() == "data"sv)
return Trustworthiness::PotentiallyTrustworthy;

View file

@ -298,6 +298,28 @@ void BookmarkStore::add_folder(Optional<String> title, Optional<String const&> t
notify_observers();
}
void BookmarkStore::import_items(JsonArray const& items_array)
{
auto imported_items = parse_bookmark_items(items_array);
if (imported_items.is_empty())
return;
auto now = UnixDateTime::now();
m_items.append({
.id = generate_random_uuid(),
.date_added = now,
.last_modified = now,
.data = BookmarkItem::Folder {
.title = "Imported Bookmarks"_string,
.children = move(imported_items),
},
});
persist_bookmarks();
notify_observers();
}
void BookmarkStore::edit_bookmark(StringView id, URL::URL url, Optional<String> title)
{
auto item = find_mutable_item_by_id(id);

View file

@ -64,6 +64,8 @@ public:
Optional<BookmarkItem const&> find_bookmark_by_url(URL::URL const&) const;
Optional<BookmarkItem const&> find_item_by_id(StringView id) const;
void import_items(JsonArray const& items_array);
void add_bookmark(URL::URL url, Optional<String> title, Optional<String> favicon_base64, Optional<String const&> target_folder_id = {});
void add_folder(Optional<String> title, Optional<String const&> target_folder_id = {});

View file

@ -4,6 +4,7 @@
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibCore/File.h>
#include <LibWebView/Application.h>
#include <LibWebView/BookmarkStore.h>
#include <LibWebView/WebUI/BookmarksUI.h>
@ -21,6 +22,12 @@ void BookmarksUI::register_interfaces()
register_interface("showContextMenu"sv, [this](auto const& data) {
show_context_menu(data);
});
register_interface("importBookmarks"sv, [this](auto const& data) {
import_bookmarks(data);
});
register_interface("exportBookmarks"sv, [this](auto const& data) {
export_bookmarks(data);
});
}
void BookmarksUI::bookmarks_changed()
@ -48,6 +55,35 @@ void BookmarksUI::move_item(JsonValue const& data)
Application::bookmark_store().move_item(*id, target_folder_id, *index);
}
void BookmarksUI::import_bookmarks(JsonValue const& data)
{
if (!data.is_array())
return;
Application::bookmark_store().import_items(data.as_array());
}
void BookmarksUI::export_bookmarks(JsonValue const& data)
{
if (!data.is_string())
return;
auto destination = Application::the().path_for_downloaded_file("bookmarks.html"sv);
if (destination.is_error())
return;
auto file = Core::File::open(destination.value().string(), Core::File::OpenMode::Write);
if (file.is_error()) {
Application::the().display_error_dialog(MUST(String::formatted("Unable to export bookmarks: {}", file.error())));
return;
}
if (auto result = file.value()->write_until_depleted(data.as_string().bytes()); result.is_error()) {
Application::the().display_error_dialog(MUST(String::formatted("Unable to export bookmarks: {}", result.error())));
return;
}
}
void BookmarksUI::show_context_menu(JsonValue const& data)
{
if (!data.is_object())

View file

@ -22,6 +22,8 @@ private:
void load_bookmarks();
void move_item(JsonValue const&);
void import_bookmarks(JsonValue const&);
void export_bookmarks(JsonValue const&);
void show_context_menu(JsonValue const&);
};