Skip to main content

linkmarks_cli/cmd/
export.rs

1//! `linkmarks export` — export bookmarks to a sink format.
2//!
3//! Default source order:
4//! 1. `--source=store`: read from the local SQLite store (default).
5//! 2. `--source=chrome`: parse a Chromium JSON file.
6//!
7//! Sink formats: `netscape` (HTML) and `json` (NDJSON, one
8//! `Bookmark` per line). `--output=-` writes to stdout; any other
9//! value is treated as a file path.
10
11use crate::Paths;
12use anyhow::{bail, Result};
13use clap::Args;
14use linkmarks_core::store;
15use linkmarks_core::traits::BookmarkSource;
16use std::path::PathBuf;
17
18#[derive(Args, Debug)]
19pub struct ExportArgs {
20    /// Output format. `netscape` emits an HTML interchange file;
21    /// `json` emits NDJSON.
22    #[arg(long, default_value = "netscape")]
23    pub format: String,
24
25    /// Source to export from. `store` reads the SQLite store
26    /// (default); `chrome` parses a Chromium JSON file.
27    #[arg(long, default_value = "store")]
28    pub source: String,
29
30    /// Path to a source file (required for `--source=chrome`).
31    #[arg(long)]
32    pub path: Option<PathBuf>,
33
34    /// Output path. `-` writes to stdout.
35    #[arg(long, short = 'o', default_value = "-")]
36    pub output: PathBuf,
37}
38
39pub fn run(args: ExportArgs, _format: crate::Format, paths: Paths) -> Result<i32> {
40    let bookmarks = match args.source.as_str() {
41        "store" => {
42            if !paths.store.exists() {
43                bail!(
44                    "store not found at {}; run `linkmarks init` first",
45                    paths.store.display()
46                );
47            }
48            let s = store::open(&paths.store)?;
49            let mut all = Vec::new();
50            let mut offset = 0usize;
51            loop {
52                let page = s.list(500, offset)?;
53                if page.is_empty() {
54                    break;
55                }
56                let page_len = page.len();
57                offset += page_len;
58                all.extend(page);
59                if page_len < 500 {
60                    break;
61                }
62            }
63            all
64        }
65        "chrome" => {
66            let path = args
67                .path
68                .clone()
69                .ok_or_else(|| anyhow::anyhow!("--path is required for --source=chrome"))?;
70            let src = linkmarks_bridge_chromium::ChromiumSource::open(&path)?;
71            src.list()?
72        }
73        other => bail!("unsupported --source '{other}' (try `store` or `chrome`)"),
74    };
75
76    let rendered = match args.format.as_str() {
77        "json" => {
78            let mut out = String::new();
79            for b in &bookmarks {
80                out.push_str(&serde_json::to_string(b)?);
81                out.push('\n');
82            }
83            out
84        }
85        "netscape" => render_netscape(&bookmarks),
86        other => bail!("unsupported export format '{other}' (v1: netscape, json)"),
87    };
88
89    if args.output.as_os_str() == "-" {
90        print!("{rendered}");
91    } else {
92        std::fs::write(&args.output, rendered)
93            .map_err(|e| anyhow::anyhow!("write {}: {e}", args.output.display()))?;
94    }
95    Ok(crate::exit_codes::OK)
96}
97
98fn render_netscape(bookmarks: &[linkmarks_core::Bookmark]) -> String {
99    let mut out = String::new();
100    out.push_str("<!DOCTYPE NETSCAPE-Bookmark-file-1>\n");
101    out.push_str("<!-- This is an automatically generated file.\n");
102    out.push_str("     It will be read and overwritten.\n");
103    out.push_str("     DO NOT EDIT! -->\n");
104    out.push_str("<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">\n");
105    out.push_str("<TITLE>Bookmarks</TITLE>\n");
106    out.push_str("<H1>Bookmarks</H1>\n");
107    out.push_str("<DL><p>\n");
108    for b in bookmarks {
109        let add_date = b.updated_at.timestamp();
110        let href = &b.original_url;
111        out.push_str(&format!(
112            "    <DT><A HREF=\"{href}\" ADD_DATE=\"{add_date}\">{title}</A>\n",
113            href = html_escape(href),
114            add_date = add_date,
115            title = html_escape(&b.title),
116        ));
117        if let Some(desc) = &b.description {
118            out.push_str(&format!("    <DD>{}\n", html_escape(desc)));
119        }
120    }
121    out.push_str("</DL><p>\n");
122    out
123}
124
125fn html_escape(s: &str) -> String {
126    s.replace('&', "&amp;")
127        .replace('<', "&lt;")
128        .replace('>', "&gt;")
129        .replace('"', "&quot;")
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn html_escape_basic() {
138        assert_eq!(html_escape("a&b<c>d\"e"), "a&amp;b&lt;c&gt;d&quot;e");
139    }
140}