diff --git a/crates/relicario-cli/src/commands/import.rs b/crates/relicario-cli/src/commands/import.rs new file mode 100644 index 0000000..f4b5713 --- /dev/null +++ b/crates/relicario-cli/src/commands/import.rs @@ -0,0 +1,88 @@ +//! `relicario import` — currently only LastPass CSV is supported. + +use std::path::PathBuf; + +use anyhow::{bail, Context, Result}; + +use crate::ImportAction; + +pub fn cmd_import(action: ImportAction) -> Result<()> { + match action { + ImportAction::Lastpass { csv } => cmd_import_lastpass(csv), + } +} + +fn cmd_import_lastpass(csv_path: PathBuf) -> Result<()> { + use std::fs; + use relicario_core::import_lastpass::parse_lastpass_csv; + + let csv_bytes = fs::read(&csv_path) + .with_context(|| format!("failed to read CSV {}", csv_path.display()))?; + + let (items, warnings) = parse_lastpass_csv(&csv_bytes)?; + + if items.is_empty() { + // Print all warnings so the user sees why nothing imported. + for w in &warnings { + print_warning(w); + } + bail!( + "imported 0 items from {} — see warnings above", + csv_path.display() + ); + } + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + + let total = items.len(); + let mut written_paths: Vec = Vec::with_capacity(items.len() + 1); + + for (idx, item) in items.iter().enumerate() { + vault.save_item(item)?; + manifest.upsert(item); + written_paths.push(format!("items/{}.enc", item.id.as_str())); + + let n = idx + 1; + if n % 50 == 0 || n == total { + eprintln!("[{n}/{total}] importing..."); + } + } + + vault.save_manifest(&manifest)?; + written_paths.push("manifest.enc".into()); + + let path_refs: Vec<&str> = written_paths.iter().map(String::as_str).collect(); + let csv_filename = csv_path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("lastpass.csv"); + super::commit_paths( + &vault, + &format!("import: {} items from LastPass ({})", total, csv_filename), + &path_refs, + )?; + + for w in &warnings { + print_warning(w); + } + // Counts only true skips, not partial imports. Coupled by convention to + // the parser's warning message strings: skip messages end in "— skipped", + // partial-import messages say "imported without TOTP" / "imported without URL". + // If a future warning uses the word "skipped" in any other sense, this filter + // will need to switch to an enum tag (see ImportWarning::message). + eprintln!( + "Imported {}, skipped {} (see warnings above)", + total, + warnings.iter().filter(|w| w.message.contains("skipped")).count() + ); + Ok(()) +} + +fn print_warning(w: &relicario_core::import_lastpass::ImportWarning) { + let prefix = match &w.title { + Some(t) => format!("row {} ({}):", w.row, t), + None => format!("row {}:", w.row), + }; + eprintln!("warning: {prefix} {}", w.message); +} diff --git a/crates/relicario-cli/src/commands/mod.rs b/crates/relicario-cli/src/commands/mod.rs index ba40acb..d040bff 100644 --- a/crates/relicario-cli/src/commands/mod.rs +++ b/crates/relicario-cli/src/commands/mod.rs @@ -9,6 +9,7 @@ pub mod backup; pub mod generate; pub mod get; +pub mod import; pub mod init; pub mod list; pub mod rate; diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 03c97c6..8edb235 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -12,7 +12,7 @@ mod session; use std::path::PathBuf; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result}; use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::{generate, Shell}; @@ -438,7 +438,7 @@ fn main() -> Result<()> { Commands::Purge { query } => commands::trash::cmd_purge(query), Commands::Trash { action } => commands::trash::cmd_trash(action), Commands::Backup { action } => commands::backup::cmd_backup(action), - Commands::Import { action } => cmd_import(action), + Commands::Import { action } => commands::import::cmd_import(action), Commands::Attach { query, file } => cmd_attach(query, file), Commands::Attachments { query } => cmd_attachments(query), Commands::Extract { query, aid, out } => cmd_extract(query, aid, out), @@ -979,87 +979,6 @@ fn push_history( }); } -fn cmd_import(action: ImportAction) -> Result<()> { - match action { - ImportAction::Lastpass { csv } => cmd_import_lastpass(csv), - } -} - -fn cmd_import_lastpass(csv_path: PathBuf) -> Result<()> { - use std::fs; - use relicario_core::import_lastpass::parse_lastpass_csv; - - let csv_bytes = fs::read(&csv_path) - .with_context(|| format!("failed to read CSV {}", csv_path.display()))?; - - let (items, warnings) = parse_lastpass_csv(&csv_bytes)?; - - if items.is_empty() { - // Print all warnings so the user sees why nothing imported. - for w in &warnings { - print_warning(w); - } - bail!( - "imported 0 items from {} — see warnings above", - csv_path.display() - ); - } - - let vault = crate::session::UnlockedVault::unlock_interactive()?; - let mut manifest = vault.load_manifest()?; - - let total = items.len(); - let mut written_paths: Vec = Vec::with_capacity(items.len() + 1); - - for (idx, item) in items.iter().enumerate() { - vault.save_item(item)?; - manifest.upsert(item); - written_paths.push(format!("items/{}.enc", item.id.as_str())); - - let n = idx + 1; - if n % 50 == 0 || n == total { - eprintln!("[{n}/{total}] importing..."); - } - } - - vault.save_manifest(&manifest)?; - written_paths.push("manifest.enc".into()); - - let path_refs: Vec<&str> = written_paths.iter().map(String::as_str).collect(); - let csv_filename = csv_path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("lastpass.csv"); - commit_paths( - &vault, - &format!("import: {} items from LastPass ({})", total, csv_filename), - &path_refs, - )?; - - for w in &warnings { - print_warning(w); - } - // Counts only true skips, not partial imports. Coupled by convention to - // the parser's warning message strings: skip messages end in "— skipped", - // partial-import messages say "imported without TOTP" / "imported without URL". - // If a future warning uses the word "skipped" in any other sense, this filter - // will need to switch to an enum tag (see ImportWarning::message). - eprintln!( - "Imported {}, skipped {} (see warnings above)", - total, - warnings.iter().filter(|w| w.message.contains("skipped")).count() - ); - Ok(()) -} - -fn print_warning(w: &relicario_core::import_lastpass::ImportWarning) { - let prefix = match &w.title { - Some(t) => format!("row {} ({}):", w.row, t), - None => format!("row {}:", w.row), - }; - eprintln!("warning: {prefix} {}", w.message); -} - fn cmd_attach(query: String, file: PathBuf) -> Result<()> { use std::fs; use relicario_core::{encrypt_attachment, AttachmentRef};