Skip to main content

linkmarks_cli/cmd/
import.rs

1//! `linkmarks import` — import bookmarks from a source file into the store.
2//!
3//! Pipeline:
4//! 1. Open the source (Chromium JSON in v1).
5//! 2. Read bookmarks via `BookmarkSource::list()`.
6//! 3. Canonicalize each URL through the loader-supplied
7//!    [`CanonicalConfig`] (see `linkmarks_core::config`).
8//! 4. Upsert into the local store.
9//!
10//! `--dry-run` parses and canonicalizes but does not write the store.
11//! `--source=store` is rejected — `import` always writes, never reads,
12//! from the store.
13
14use 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    /// Source to import from. `chrome` parses a Chromium JSON file.
26    #[arg(long, default_value = "chrome")]
27    pub source: String,
28
29    /// Path to the source file.
30    #[arg(long)]
31    pub path: PathBuf,
32
33    /// Parse and canonicalize but do not write the store.
34    #[arg(long)]
35    pub dry_run: bool,
36
37    /// Overwrite the destination store with a fresh DB. Off by
38    /// default; the operator decides when to discard history.
39    #[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
108/// Aggregated outcome of canonicalization: original bookmarks plus the
109/// successfully canonicalized subset and counters for diagnostics.
110struct CanonicalReport {
111    /// Bookmarks with a freshly-computed `canonical_url`.
112    canonical: Vec<linkmarks_core::Bookmark>,
113    /// Number of records the source parser emitted.
114    parsed_ok: usize,
115    /// Number of records that survived canonicalization.
116    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}