502 lines
18 KiB
Rust
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("&"),
|
|
'<' => out.push_str("<"),
|
|
'>' => out.push_str(">"),
|
|
'"' => out.push_str("""),
|
|
_ => 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&b=2" ADD_DATE="1600000000">Example & 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("AB&unknown;"), "AB&unknown;");
|
|
}
|
|
}
|