linkmarks_cli/cmd/
import.rs1use crate::Paths;
15use anyhow::{bail, Result};
16use clap::Args;
17use linkmarks_core::canonical::canonicalize_with;
18use linkmarks_core::config::load_from;
19use linkmarks_core::store;
20use linkmarks_core::traits::BookmarkSource;
21use std::path::PathBuf;
22
23#[derive(Args, Debug)]
24pub struct ImportArgs {
25 #[arg(long, default_value = "chrome")]
27 pub source: String,
28
29 #[arg(long)]
31 pub path: PathBuf,
32
33 #[arg(long)]
35 pub dry_run: bool,
36
37 #[arg(long)]
40 pub fresh: bool,
41}
42
43pub fn run(args: ImportArgs, _format: crate::Format, paths: Paths) -> Result<i32> {
44 let kind = linkmarks_core::SourceKind::from_cli_str(&args.source)
45 .ok_or_else(|| anyhow::anyhow!("unknown source '{}'", args.source))?;
46 if !matches!(kind, linkmarks_core::SourceKind::Chromium) {
47 bail!("v1 only supports --source=chrome");
48 }
49 if !args.path.exists() {
50 bail!("source file not found: {}", args.path.display());
51 }
52 if args.fresh && args.dry_run {
53 bail!("--fresh and --dry-run are mutually exclusive");
54 }
55
56 let src = linkmarks_bridge_chromium::ChromiumSource::open(&args.path)?;
57 let bookmarks = src.list()?;
58 let cfg = load_from(&paths.config)?;
59 let report = canonicalize_bookmarks(&bookmarks, &cfg);
60
61 if args.dry_run {
62 println!(
63 "imported (dry-run) {} bookmarks from {} ({})\n parsed_ok={} canonical_ok={}",
64 bookmarks.len(),
65 args.path.display(),
66 args.source,
67 report.parsed_ok,
68 report.canonical_ok
69 );
70 return Ok(crate::exit_codes::OK);
71 }
72
73 if args.fresh && paths.store.exists() {
74 std::fs::remove_file(&paths.store)
75 .map_err(|e| anyhow::anyhow!("remove {}: {e}", paths.store.display()))?;
76 }
77 let mut s = store::open(&paths.store)?;
78 let mut written = 0usize;
79 let mut failed = 0usize;
80 for bm in &report.canonical {
81 match s.upsert(bm) {
82 Ok(_) => written += 1,
83 Err(e) => {
84 failed += 1;
85 tracing::warn!(error = %e, url = %bm.original_url, "upsert failed");
86 }
87 }
88 }
89
90 println!(
91 "imported {} bookmarks from {} ({})\n parsed_ok={} canonical_ok={} written={} failed={}",
92 bookmarks.len(),
93 args.path.display(),
94 args.source,
95 report.parsed_ok,
96 report.canonical_ok,
97 written,
98 failed
99 );
100
101 if failed == 0 {
102 Ok(crate::exit_codes::OK)
103 } else {
104 Ok(crate::exit_codes::PARTIAL)
105 }
106}
107
108struct CanonicalReport {
111 canonical: Vec<linkmarks_core::Bookmark>,
113 parsed_ok: usize,
115 canonical_ok: usize,
117}
118
119fn canonicalize_bookmarks(
120 bookmarks: &[linkmarks_core::Bookmark],
121 cfg: &linkmarks_core::CanonicalConfig,
122) -> CanonicalReport {
123 let parsed_ok = bookmarks.len();
124 let mut canonical = Vec::with_capacity(bookmarks.len());
125 let mut canonical_ok = 0usize;
126 for bm in bookmarks {
127 match canonicalize_with(&bm.original_url, cfg) {
128 Ok(canonical_url) => {
129 let mut updated = bm.clone();
130 updated.canonical_url = canonical_url;
131 canonical.push(updated);
132 canonical_ok += 1;
133 }
134 Err(e) => {
135 tracing::warn!(
136 error = %e,
137 url = %bm.original_url,
138 "canonicalize failed; keeping as-is"
139 );
140 canonical.push(bm.clone());
141 }
142 }
143 }
144 CanonicalReport {
145 canonical,
146 parsed_ok,
147 canonical_ok,
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use chrono::{TimeZone, Utc};
155 use linkmarks_core::canonical_config::CanonicalConfig;
156 use linkmarks_core::model::{Bookmark, BookmarkId, SourceKind, SourceRef};
157
158 fn mk_bm(url: &str) -> Bookmark {
159 Bookmark {
160 id: BookmarkId::generate(),
161 original_url: url.into(),
162 canonical_url: url.into(),
163 title: "t".into(),
164 description: None,
165 tags: vec![],
166 collection: None,
167 created_at: Utc.timestamp_opt(0, 0).unwrap(),
168 updated_at: Utc.timestamp_opt(0, 0).unwrap(),
169 source: SourceRef {
170 kind: SourceKind::Manual,
171 external_id: None,
172 imported_at: Utc.timestamp_opt(0, 0).unwrap(),
173 raw: None,
174 },
175 content_type: None,
176 archived: false,
177 }
178 }
179
180 #[test]
181 fn canonicalize_lowercases_host_and_strips_tracking() {
182 let cfg = CanonicalConfig::default_rules();
183 let bms = vec![mk_bm("HTTPS://Example.com/p?utm_source=x&id=42")];
184 let report = canonicalize_bookmarks(&bms, &cfg);
185 assert_eq!(report.canonical.len(), 1);
186 assert!(report.canonical[0]
187 .canonical_url
188 .starts_with("https://example.com/"));
189 assert!(report.canonical[0].canonical_url.contains("id=42"));
190 assert!(!report.canonical[0].canonical_url.contains("utm_source"));
191 }
192}