lingxia-browser-shell 0.11.1

Browser shell product module and host registrations for LingXia
Documentation
//! Chrome-compatible Netscape Bookmark HTML import and export.

use html5ever::tendril::StrTendril;
use html5ever::tokenizer::{
    BufferQueue, TagKind, Token, TokenSink, TokenSinkResult, Tokenizer, TokenizerOpts,
};
use std::cell::RefCell;

use crate::bookmarks::BookmarksSnapshot;

const MAX_IMPORT_BOOKMARKS: usize = 50_000;

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ImportedBookmark {
    pub url: String,
    pub title: String,
    pub group_name: Option<String>,
    pub created_at_ms: Option<u64>,
}

#[derive(Default)]
struct ParserState {
    folders: Vec<Option<String>>,
    pending_folder: Option<String>,
    capture: Option<Capture>,
    bookmarks: Vec<ImportedBookmark>,
}

enum Capture {
    Folder(String),
    Bookmark {
        url: String,
        title: String,
        created_at_ms: Option<u64>,
    },
}

#[derive(Default)]
struct BookmarkSink(RefCell<ParserState>);

impl BookmarkSink {
    fn finish(self) -> Vec<ImportedBookmark> {
        self.0.into_inner().bookmarks
    }
}

impl TokenSink for BookmarkSink {
    type Handle = ();

    fn process_token(&self, token: Token, _line_number: u64) -> TokenSinkResult<Self::Handle> {
        let mut state = self.0.borrow_mut();
        match token {
            Token::TagToken(tag) if tag.kind == TagKind::StartTag => {
                let name = tag.name.as_ref();
                if name.eq_ignore_ascii_case("h3") {
                    state.capture = Some(Capture::Folder(String::new()));
                } else if name.eq_ignore_ascii_case("a") {
                    let url = tag
                        .attrs
                        .iter()
                        .find(|attr| attr.name.local.as_ref().eq_ignore_ascii_case("href"))
                        .map(|attr| attr.value.to_string())
                        .unwrap_or_default();
                    let created_at_ms = tag
                        .attrs
                        .iter()
                        .find(|attr| attr.name.local.as_ref().eq_ignore_ascii_case("add_date"))
                        .and_then(|attr| attr.value.parse::<u64>().ok())
                        .and_then(|seconds| seconds.checked_mul(1_000));
                    state.capture = Some(Capture::Bookmark {
                        url,
                        title: String::new(),
                        created_at_ms,
                    });
                } else if name.eq_ignore_ascii_case("dl") {
                    let folder = state.pending_folder.take();
                    state.folders.push(folder);
                }
            }
            Token::TagToken(tag) if tag.kind == TagKind::EndTag => {
                let name = tag.name.as_ref();
                if name.eq_ignore_ascii_case("h3") {
                    if let Some(Capture::Folder(name)) = state.capture.take() {
                        let name = clean_text(&name);
                        state.pending_folder = (!name.is_empty()).then_some(name);
                    }
                } else if name.eq_ignore_ascii_case("a") {
                    // Always consume the capture so it cannot accumulate stale
                    // characters once the import cap is reached.
                    let capture = state.capture.take();
                    if state.bookmarks.len() >= MAX_IMPORT_BOOKMARKS {
                        return TokenSinkResult::Continue;
                    }
                    if let Some(Capture::Bookmark {
                        url,
                        title,
                        created_at_ms,
                    }) = capture
                    {
                        let group_name = flattened_folder_name(&state.folders);
                        state.bookmarks.push(ImportedBookmark {
                            url: url.trim().to_string(),
                            title: clean_text(&title),
                            group_name,
                            created_at_ms,
                        });
                    }
                } else if name.eq_ignore_ascii_case("dl") {
                    state.folders.pop();
                }
            }
            Token::CharacterTokens(text) => {
                if let Some(capture) = state.capture.as_mut() {
                    match capture {
                        Capture::Folder(value) => value.push_str(&text),
                        Capture::Bookmark { title, .. } => title.push_str(&text),
                    }
                }
            }
            _ => {}
        }
        TokenSinkResult::Continue
    }
}

fn clean_text(value: &str) -> String {
    value.split_whitespace().collect::<Vec<_>>().join(" ")
}

fn flattened_folder_name(folders: &[Option<String>]) -> Option<String> {
    let path = folders
        .iter()
        .filter_map(Option::as_deref)
        .map(clean_text)
        .filter(|name| !name.is_empty())
        .collect::<Vec<_>>()
        .join(" / ");
    (!path.is_empty()).then_some(path)
}

pub(crate) fn parse_netscape_html(input: &str) -> Vec<ImportedBookmark> {
    let sink = BookmarkSink::default();
    let queue = BufferQueue::default();
    queue.push_back(StrTendril::from(input));
    let tokenizer = Tokenizer::new(sink, TokenizerOpts::default());
    let _ = tokenizer.feed(&queue);
    tokenizer.end();
    tokenizer.sink.finish()
}

fn escape_html(value: &str) -> String {
    value
        .replace('&', "&amp;")
        .replace('"', "&quot;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

fn bookmark_line(url: &str, title: &str, created_at_ms: u64, indent: &str) -> String {
    format!(
        "{indent}<DT><A HREF=\"{}\" ADD_DATE=\"{}\">{}</A>\n",
        escape_html(url),
        created_at_ms / 1_000,
        escape_html(title)
    )
}

/// Returns the HTML and the number of bookmarks actually emitted. Entries
/// whose group no longer exists are preserved in the ungrouped section.
pub(crate) fn export_netscape_html(
    snapshot: &BookmarksSnapshot,
    exported_at_ms: u64,
) -> (String, usize) {
    let exported_at = exported_at_ms / 1_000;
    let mut count = 0usize;
    let mut output = String::from(
        "<!DOCTYPE NETSCAPE-Bookmark-file-1>\n\
<!-- This is an automatically generated file.\n\
     It will be read and overwritten.\n\
     DO NOT EDIT! -->\n\
<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">\n\
<TITLE>Bookmarks</TITLE>\n\
<H1>Bookmarks</H1>\n\
<DL><p>\n",
    );

    let known_group_ids = snapshot
        .groups
        .iter()
        .map(|group| group.id.as_str())
        .collect::<std::collections::HashSet<_>>();
    for entry in snapshot.entries.iter().filter(|entry| {
        entry
            .group_id
            .as_deref()
            .is_none_or(|group_id| !known_group_ids.contains(group_id))
    }) {
        output.push_str(&bookmark_line(
            &entry.url,
            &entry.title,
            entry.created_at_ms,
            "    ",
        ));
        count += 1;
    }

    for group in &snapshot.groups {
        output.push_str(&format!(
            "    <DT><H3 ADD_DATE=\"{exported_at}\" LAST_MODIFIED=\"{exported_at}\">{}</H3>\n",
            escape_html(&group.name)
        ));
        output.push_str("    <DL><p>\n");
        for entry in snapshot
            .entries
            .iter()
            .filter(|entry| entry.group_id.as_deref() == Some(group.id.as_str()))
        {
            output.push_str(&bookmark_line(
                &entry.url,
                &entry.title,
                entry.created_at_ms,
                "        ",
            ));
            count += 1;
        }
        output.push_str("    </DL><p>\n");
    }
    output.push_str("</DL><p>\n");
    (output, count)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::bookmarks::{BookmarkEntry, BookmarkGroup};

    #[test]
    fn parses_netscape_folders_entities_and_dates() {
        let html = r#"<!DOCTYPE NETSCAPE-Bookmark-file-1>
<DL><p>
  <DT><H3>Bookmarks bar</H3>
  <DL><p>
    <DT><A HREF="https://example.com/?a=1&amp;b=2" ADD_DATE="123">A &amp; B</A>
    <DT><H3>Work</H3>
    <DL><p><DT><A HREF="https://docs.example.com">Docs</A></DL><p>
  </DL><p>
  <DT><A HREF="https://root.example.com">Root</A>
</DL><p>"#;
        let parsed = parse_netscape_html(html);
        assert_eq!(parsed.len(), 3);
        assert_eq!(parsed[0].url, "https://example.com/?a=1&b=2");
        assert_eq!(parsed[0].title, "A & B");
        assert_eq!(parsed[0].group_name.as_deref(), Some("Bookmarks bar"));
        assert_eq!(parsed[0].created_at_ms, Some(123_000));
        assert_eq!(
            parsed[1].group_name.as_deref(),
            Some("Bookmarks bar / Work")
        );
        assert_eq!(parsed[2].group_name, None);
    }

    #[test]
    fn exports_netscape_html_that_round_trips() {
        let mut snapshot = BookmarksSnapshot::default();
        snapshot.groups = vec![BookmarkGroup {
            id: "g1".into(),
            name: "R&D <Docs>".into(),
        }];
        snapshot.entries = vec![BookmarkEntry {
            id: "b1".into(),
            url: "https://example.com/?a=1&b=2".into(),
            title: "A \"useful\" page".into(),
            group_id: Some("g1".into()),
            created_at_ms: 123_000,
        }];
        let (exported, count) = export_netscape_html(&snapshot, 456_000);
        assert_eq!(count, 1);
        assert!(exported.starts_with("<!DOCTYPE NETSCAPE-Bookmark-file-1>"));
        assert!(exported.contains("R&amp;D &lt;Docs&gt;"));
        assert!(exported.contains("HREF=\"https://example.com/?a=1&amp;b=2\""));

        let parsed = parse_netscape_html(&exported);
        assert_eq!(parsed.len(), 1);
        assert_eq!(parsed[0].url, snapshot.entries[0].url);
        assert_eq!(parsed[0].title, snapshot.entries[0].title);
        assert_eq!(parsed[0].group_name.as_deref(), Some("R&D <Docs>"));
        assert_eq!(parsed[0].created_at_ms, Some(123_000));
    }

    #[test]
    fn export_preserves_entries_with_orphaned_groups_as_ungrouped() {
        let mut snapshot = BookmarksSnapshot::default();
        snapshot.entries = vec![
            BookmarkEntry {
                id: "b1".into(),
                url: "https://kept.test".into(),
                title: "Kept".into(),
                group_id: None,
                created_at_ms: 0,
            },
            BookmarkEntry {
                id: "b2".into(),
                url: "https://orphan.test".into(),
                title: "Orphan".into(),
                group_id: Some("missing-group".into()),
                created_at_ms: 0,
            },
        ];
        let (exported, count) = export_netscape_html(&snapshot, 0);
        assert_eq!(count, 2);
        assert_eq!(parse_netscape_html(&exported).len(), 2);
    }
}