Files
sovereign_browser_rust/src/bookmarks.rs
T

502 lines
18 KiB
Rust

//! Bookmark management for sovereign_browser
//!
//! Port of `bookmarks.c` / `bookmarks.h` from the C project.
//!
//! Also implements import/export of the Netscape bookmark HTML format
//! (`<!DOCTYPE NETSCAPE-Bookmark-file-1>`) used by Chromium, Chrome, Firefox
//! and most other browsers.
use crate::db;
/// Name of the bookmark folder shown in the per-tab bookmark bar.
pub const BOOKMARKS_BAR_FOLDER: &str = "Bookmarks Bar";
/// Folder that receives imported bookmarks that sit at the top level of the
/// import file (our tree view only shows bookmarks that live in a folder).
const DEFAULT_IMPORT_FOLDER: &str = "General";
/// Add a bookmark.
pub fn bookmarks_add(title: &str, url: &str, folder: &str) -> Result<i64, Box<dyn std::error::Error>> {
let id = db::db_add_bookmark(title, url, folder)?;
// Trigger bookmark sync (Kind 30003) — stub for now, actual relay
// publishing requires NIP-44 encryption + relay connection management.
bookmarks_publish();
Ok(id)
}
/// Get all bookmarks.
pub fn bookmarks_get_all() -> Result<Vec<serde_json::Value>, Box<dyn std::error::Error>> {
db::db_get_bookmarks()
}
/// Delete a bookmark.
pub fn bookmarks_delete(id: i64) -> Result<(), Box<dyn std::error::Error>> {
db::db_delete_bookmark(id)?;
bookmarks_publish();
Ok(())
}
/// Publish bookmarks to Nostr relays as kind 30003 bookmark sets.
/// The actual relay publishing requires NIP-44 encryption and relay
/// connection management. For now, we serialize and store the bookmark
/// set locally. The infrastructure is in place for future relay publishing.
pub fn bookmarks_publish() {
if let Ok(bookmarks) = db::db_get_bookmarks() {
if let Ok(json) = serde_json::to_string(&bookmarks) {
let _ = db::db_kv_set("bookmarks_sync.last_payload", &json);
}
}
// TODO: Build and sign a kind 30003 event, publish to bootstrap relays.
}
// ═══════════════════════════════════════════════════════════════════════
// HTML export
// ═══════════════════════════════════════════════════════════════════════
#[derive(Default)]
struct ExportNode {
marks: Vec<(String, String, u64)>, // (title, url, added)
kids: Vec<(String, ExportNode)>,
}
impl ExportNode {
fn child(&mut self, name: &str) -> &mut ExportNode {
let idx = match self.kids.iter().position(|(n, _)| n == name) {
Some(i) => i,
None => {
self.kids.push((name.to_string(), ExportNode::default()));
self.kids.len() - 1
}
};
&mut self.kids[idx].1
}
}
fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
_ => out.push(c),
}
}
out
}
fn write_export_node(out: &mut String, node: &ExportNode, depth: usize, now: u64) {
let pad = " ".repeat(depth);
for (title, url, added) in &node.marks {
let label = if title.is_empty() { url } else { title };
out.push_str(&format!(
"{}<DT><A HREF=\"{}\" ADD_DATE=\"{}\">{}</A>\n",
pad,
html_escape(url),
added,
html_escape(label)
));
}
for (name, kid) in &node.kids {
let is_bar = depth == 1 && name == BOOKMARKS_BAR_FOLDER;
if is_bar {
// Chromium recognises the bookmarks bar via this attribute.
out.push_str(&format!(
"{}<DT><H3 ADD_DATE=\"{}\" LAST_MODIFIED=\"{}\" PERSONAL_TOOLBAR_FOLDER=\"true\">Bookmarks bar</H3>\n",
pad, now, now
));
} else {
out.push_str(&format!(
"{}<DT><H3 ADD_DATE=\"{}\" LAST_MODIFIED=\"{}\">{}</H3>\n",
pad,
now,
now,
html_escape(name)
));
}
out.push_str(&format!("{}<DL><p>\n", pad));
write_export_node(out, kid, depth + 1, now);
out.push_str(&format!("{}</DL><p>\n", pad));
}
}
/// Render all bookmarks as a Netscape/Chromium-compatible HTML document.
pub fn bookmarks_export_html() -> Result<String, Box<dyn std::error::Error>> {
let all = db::db_get_bookmarks()?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut root = ExportNode::default();
for bm in &all {
let title = bm["title"].as_str().unwrap_or("").to_string();
let url = bm["url"].as_str().unwrap_or("").to_string();
if url.is_empty() {
continue;
}
let added = bm["created_at"].as_u64().unwrap_or(now);
let folder = bm["folder"].as_str().unwrap_or("");
let mut node = &mut root;
for part in folder.split('/').filter(|p| !p.is_empty()) {
node = node.child(part);
}
node.marks.push((title, url, added));
}
// Bookmarks bar first, like Chromium.
root.kids.sort_by_key(|(n, _)| n != BOOKMARKS_BAR_FOLDER);
let mut out = String::new();
out.push_str("<!DOCTYPE NETSCAPE-Bookmark-file-1>\n");
out.push_str("<!-- This is an automatically generated file.\n");
out.push_str(" It will be read and overwritten.\n");
out.push_str(" DO NOT EDIT! -->\n");
out.push_str("<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">\n");
out.push_str("<TITLE>Bookmarks</TITLE>\n");
out.push_str("<H1>Bookmarks</H1>\n");
out.push_str("<DL><p>\n");
write_export_node(&mut out, &root, 1, now);
out.push_str("</DL><p>\n");
Ok(out)
}
/// Export all bookmarks to an HTML file. Returns the number exported.
pub fn bookmarks_export_to_file(path: &std::path::Path) -> Result<usize, Box<dyn std::error::Error>> {
let html = bookmarks_export_html()?;
let count = html.matches("<DT><A ").count();
std::fs::write(path, html)?;
Ok(count)
}
// ═══════════════════════════════════════════════════════════════════════
// HTML import
// ═══════════════════════════════════════════════════════════════════════
/// A bookmark parsed from an HTML file.
#[derive(Debug, PartialEq)]
pub struct ParsedBookmark {
pub title: String,
pub url: String,
pub folder: String,
pub added: Option<u64>,
}
/// Result of an import.
#[derive(Debug, Default, Clone, Copy)]
pub struct ImportSummary {
pub added: usize,
pub skipped: usize,
}
fn html_unescape(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(pos) = rest.find('&') {
out.push_str(&rest[..pos]);
rest = &rest[pos..];
let semi = rest.find(';').filter(|&i| i <= 10);
let decoded = semi.and_then(|end| {
let ent = &rest[1..end];
let ch = match ent {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
"nbsp" => Some(' '),
_ => {
if let Some(hex) = ent.strip_prefix("#x").or_else(|| ent.strip_prefix("#X")) {
u32::from_str_radix(hex, 16).ok().and_then(char::from_u32)
} else if let Some(dec) = ent.strip_prefix('#') {
dec.parse::<u32>().ok().and_then(char::from_u32)
} else {
None
}
}
};
ch.map(|c| (c, end))
});
match decoded {
Some((c, end)) => {
out.push(c);
rest = &rest[end + 1..];
}
None => {
out.push('&');
rest = &rest[1..];
}
}
}
out.push_str(rest);
out
}
/// Find the `>` that closes a tag starting after `from`, honouring quotes.
fn find_tag_end(s: &str, from: usize) -> Option<usize> {
let bytes = s.as_bytes();
let mut quote: Option<u8> = None;
let mut i = from;
while i < bytes.len() {
let b = bytes[i];
match quote {
Some(q) => {
if b == q {
quote = None;
}
}
None => {
if b == b'"' || b == b'\'' {
quote = Some(b);
} else if b == b'>' {
return Some(i);
}
}
}
i += 1;
}
None
}
/// Parse `name="value"` attributes (names lower-cased, values unescaped).
fn parse_attrs(s: &str) -> Vec<(String, String)> {
let mut attrs = Vec::new();
let b = s.as_bytes();
let mut i = 0;
while i < b.len() {
while i < b.len() && (b[i].is_ascii_whitespace() || b[i] == b'/') {
i += 1;
}
let name_start = i;
while i < b.len() && !b[i].is_ascii_whitespace() && b[i] != b'=' && b[i] != b'/' {
i += 1;
}
if name_start == i {
break;
}
let name = s[name_start..i].to_ascii_lowercase();
while i < b.len() && b[i].is_ascii_whitespace() {
i += 1;
}
let mut value = String::new();
if i < b.len() && b[i] == b'=' {
i += 1;
while i < b.len() && b[i].is_ascii_whitespace() {
i += 1;
}
if i < b.len() && (b[i] == b'"' || b[i] == b'\'') {
let q = b[i];
i += 1;
let vs = i;
while i < b.len() && b[i] != q {
i += 1;
}
value = html_unescape(&s[vs..i]);
i += 1; // closing quote
} else {
let vs = i;
while i < b.len() && !b[i].is_ascii_whitespace() {
i += 1;
}
value = html_unescape(&s[vs..i]);
}
}
attrs.push((name, value));
}
attrs
}
fn attr<'a>(attrs: &'a [(String, String)], name: &str) -> Option<&'a str> {
attrs.iter().find(|(n, _)| n == name).map(|(_, v)| v.as_str())
}
/// Folder names cannot contain the path separator.
fn sanitize_folder_name(name: &str) -> String {
let n = name.replace('/', "-");
let n = n.trim();
if n.is_empty() { "Untitled".to_string() } else { n.to_string() }
}
/// Parse a Netscape-format bookmark HTML document.
pub fn bookmarks_parse_html(input: &str) -> Vec<ParsedBookmark> {
// ASCII lower-casing preserves byte offsets, so we can search `lower`
// and slice `input` with the same indices.
let lower = input.to_ascii_lowercase();
let mut out = Vec::new();
// One entry per open <DL>; Some(name) when it belongs to a folder.
let mut stack: Vec<Option<String>> = Vec::new();
let mut pending_folder: Option<String> = None;
let mut pos = 0;
while let Some(rel) = input[pos..].find('<') {
let lt = pos + rel;
// Comments / doctype.
if input[lt..].starts_with("<!--") {
pos = input[lt + 4..].find("-->").map(|e| lt + 4 + e + 3).unwrap_or(input.len());
continue;
}
let Some(gt) = find_tag_end(input, lt + 1) else { break };
let inner = &input[lt + 1..gt];
let name_end = inner
.find(|c: char| c.is_ascii_whitespace() || c == '/')
.unwrap_or(inner.len());
let closing = inner.starts_with('/');
let tag = if closing {
let t = &inner[1..];
let e = t.find(|c: char| c.is_ascii_whitespace()).unwrap_or(t.len());
t[..e].to_ascii_lowercase()
} else {
inner[..name_end].to_ascii_lowercase()
};
pos = gt + 1;
match (tag.as_str(), closing) {
("dl", false) => stack.push(pending_folder.take()),
("dl", true) => {
stack.pop();
}
("h3", false) => {
let attrs = parse_attrs(&inner[name_end..]);
let close = lower[pos..].find("</h3").map(|e| pos + e).unwrap_or(input.len());
let raw = html_unescape(input[pos..close].trim());
let at_top = stack.iter().all(|s| s.is_none());
let toolbar_attr = attr(&attrs, "personal_toolbar_folder")
.map(|v| v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let lname = raw.to_lowercase();
let is_bar = at_top
&& (toolbar_attr
|| lname == "bookmarks bar"
|| lname == "bookmarks toolbar"
|| lname == "favorites bar");
pending_folder = Some(if is_bar {
BOOKMARKS_BAR_FOLDER.to_string()
} else {
sanitize_folder_name(&raw)
});
pos = close;
}
("a", false) => {
let attrs = parse_attrs(&inner[name_end..]);
let close = lower[pos..].find("</a").map(|e| pos + e).unwrap_or(input.len());
let title = html_unescape(input[pos..close].trim());
pos = close;
let url = attr(&attrs, "href").unwrap_or("").trim().to_string();
if url.is_empty() || url.starts_with("place:") {
continue;
}
let folder: Vec<&str> = stack.iter().filter_map(|s| s.as_deref()).collect();
let folder = if folder.is_empty() {
DEFAULT_IMPORT_FOLDER.to_string()
} else {
folder.join("/")
};
out.push(ParsedBookmark {
title,
url,
folder,
added: attr(&attrs, "add_date").and_then(|v| v.parse::<u64>().ok()),
});
}
_ => {}
}
}
out
}
/// Import bookmarks from HTML text. Bookmarks already present (same URL in
/// the same folder) are skipped.
pub fn bookmarks_import_html(html: &str) -> Result<ImportSummary, Box<dyn std::error::Error>> {
let parsed = bookmarks_parse_html(html);
let mut existing: std::collections::HashSet<(String, String)> = db::db_get_bookmarks()?
.iter()
.map(|b| {
(
b["folder"].as_str().unwrap_or("").to_string(),
b["url"].as_str().unwrap_or("").to_string(),
)
})
.collect();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut summary = ImportSummary::default();
for bm in parsed {
let key = (bm.folder.clone(), bm.url.clone());
if existing.contains(&key) {
summary.skipped += 1;
continue;
}
// Some exporters write add dates in microseconds or 0.
let added = match bm.added {
Some(t) if t > 0 && t < 100_000_000_000 => t,
_ => now,
};
db::db_add_bookmark_at(&bm.title, &bm.url, &bm.folder, added)?;
existing.insert(key);
summary.added += 1;
}
if summary.added > 0 {
bookmarks_publish();
}
Ok(summary)
}
/// Import bookmarks from an HTML file.
pub fn bookmarks_import_from_file(path: &std::path::Path) -> Result<ImportSummary, Box<dyn std::error::Error>> {
let bytes = std::fs::read(path)?;
let html = String::from_utf8_lossy(&bytes);
bookmarks_import_html(&html)
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"<!DOCTYPE NETSCAPE-Bookmark-file-1>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
<DL><p>
<DT><H3 ADD_DATE="1600000000" LAST_MODIFIED="1600000001" PERSONAL_TOOLBAR_FOLDER="true">Bookmarks bar</H3>
<DL><p>
<DT><A HREF="https://example.com/?a=1&amp;b=2" ADD_DATE="1600000000">Example &amp; Co</A>
<DT><H3 ADD_DATE="1600000000">Work/Stuff</H3>
<DL><p>
<DT><A HREF="https://rust-lang.org/">Rust</A>
</DL><p>
</DL><p>
<DT><H3>Other bookmarks</H3>
<DL><p>
<DT><A HREF="https://o.example/">Other</A>
</DL><p>
<DT><A HREF="https://top.example/">Top</A>
</DL><p>
"#;
#[test]
fn parses_chromium_export() {
let v = bookmarks_parse_html(SAMPLE);
assert_eq!(v.len(), 4);
assert_eq!(v[0].folder, "Bookmarks Bar");
assert_eq!(v[0].url, "https://example.com/?a=1&b=2");
assert_eq!(v[0].title, "Example & Co");
assert_eq!(v[0].added, Some(1600000000));
assert_eq!(v[1].folder, "Bookmarks Bar/Work-Stuff");
assert_eq!(v[2].folder, "Other bookmarks");
assert_eq!(v[3].folder, "General");
}
#[test]
fn escape_roundtrip() {
assert_eq!(html_unescape(&html_escape("a&b<c>\"d\"")), "a&b<c>\"d\"");
assert_eq!(html_unescape("&#65;&#x42;&unknown;"), "AB&unknown;");
}
}