linkmarks_cli/cmd/
export.rs1use 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 #[arg(long, default_value = "netscape")]
23 pub format: String,
24
25 #[arg(long, default_value = "store")]
28 pub source: String,
29
30 #[arg(long)]
32 pub path: Option<PathBuf>,
33
34 #[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('&', "&")
127 .replace('<', "<")
128 .replace('>', ">")
129 .replace('"', """)
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&b<c>d"e");
139 }
140}