diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs index 7b85aa7..93fad2e 100644 --- a/crates/relicario-cli/src/commands/org.rs +++ b/crates/relicario-cli/src/commands/org.rs @@ -1001,7 +1001,7 @@ fn resolve_org_query<'a>( } } -pub fn run_edit(dir: &Path, query: &str, totp_qr: Option) -> Result<()> { +pub fn run_edit(dir: &Path, query: &str, totp_qr: Option, file: Option) -> Result<()> { use relicario_core::time::now_unix; use relicario_core::ItemCore; @@ -1025,15 +1025,47 @@ pub fn run_edit(dir: &Path, query: &str, totp_qr: Option) -> } let history = &mut item.field_history; + let mut doc_attachment_rel: Option = None; + let mut new_doc_attachments: Option> = None; match &mut item.core { ItemCore::Login(l) => ib::edit_login(l, history, totp_qr)?, ItemCore::SecureNote(n) => ib::edit_secure_note(n, history)?, ItemCore::Identity(i) => ib::edit_identity(i)?, ItemCore::Card(c) => ib::edit_card(c, history)?, ItemCore::Key(k) => ib::edit_key(k, history)?, - ItemCore::Document(_) => ib::edit_document_message(), + ItemCore::Document(d) => { + if let Some(path) = &file { + let bytes = std::fs::read(path) + .with_context(|| format!("read {}", path.display()))?; + let enc = relicario_core::encrypt_attachment( + &bytes, vault.key(), crate::org_session::DEFAULT_ORG_ATTACHMENT_MAX_BYTES)?; + vault.remove_item_attachments(&collection, &id)?; + let rel = vault.save_attachment(&collection, &id, &enc)?; + let filename = path + .file_name() + .ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", path.display()))? + .to_string_lossy() + .into_owned(); + d.mime_type = crate::parse::guess_mime(&filename); + d.primary_attachment = enc.id.clone(); + d.filename = filename.clone(); + new_doc_attachments = Some(vec![relicario_core::AttachmentRef { + id: enc.id, + filename, + mime_type: d.mime_type.clone(), + size: bytes.len() as u64, + created: now_unix(), + }]); + doc_attachment_rel = Some(rel); + } else { + ib::edit_document_message(); + } + } ItemCore::Totp(t) => ib::edit_totp(t, history)?, } + if let Some(atts) = new_doc_attachments { + item.attachments = atts; + } item.modified = now_unix(); let item_rel = vault.save_item(&collection, &item)?; @@ -1053,7 +1085,13 @@ pub fn run_edit(dir: &Path, query: &str, totp_qr: Option) -> collection, item.id.as_str() ); - crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org edit: git add")?; + let mut add_args: Vec<&str> = vec!["add", &item_rel, "manifest.enc"]; + let att_dir_rel; + if doc_attachment_rel.is_some() { + att_dir_rel = format!("attachments/{}/{}", collection, id.as_str()); + add_args.push(&att_dir_rel); + } + crate::org_session::org_git_run(&vault.root, &add_args, "org edit: git add")?; crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?; println!("Updated {} ({}) in `{}`", item.title, item.id.as_str(), collection); Ok(()) @@ -1129,12 +1167,14 @@ pub fn run_purge(dir: &Path, query: &str) -> Result<()> { // Remove the blob from disk, drop the manifest entry, stage with git rm. vault.remove_item(&collection, &id)?; + vault.remove_item_attachments(&collection, &id)?; let mut manifest = vault.load_manifest()?; manifest.entries.retain(|e| e.id != id); vault.save_manifest(&manifest)?; let item_rel = format!("items/{}/{}.enc", collection, id.as_str()); - crate::helpers::git_rm(&vault.root, &[item_rel], "org purge: git rm")?; + let att_dir_rel = format!("attachments/{}/{}", collection, id.as_str()); + crate::helpers::git_rm(&vault.root, &[item_rel, att_dir_rel], "org purge: git rm")?; crate::org_session::org_git_run(&vault.root, &["add", "manifest.enc"], "org purge: git add manifest")?; let commit_msg = format!( diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index e7069ee..3e38e43 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -541,6 +541,8 @@ pub(crate) enum OrgCommands { query: String, /// Replace the login TOTP secret from a QR image. #[arg(long)] totp_qr: Option, + /// Replace a Document item's attachment file. + #[arg(long)] file: Option, }, /// Soft-delete an org item (reversible via `org restore`). Rm { query: String }, @@ -760,9 +762,9 @@ fn main() -> Result<()> { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_list(&d, trashed)?; } - OrgCommands::Edit { query, totp_qr } => { + OrgCommands::Edit { query, totp_qr, file } => { let d = crate::org_session::org_dir(dir_path)?; - commands::org::run_edit(&d, &query, totp_qr)?; + commands::org::run_edit(&d, &query, totp_qr, file)?; } OrgCommands::Rm { query } => { let d = crate::org_session::org_dir(dir_path)?; diff --git a/crates/relicario-cli/src/org_session.rs b/crates/relicario-cli/src/org_session.rs index d0a5b33..f36db6b 100644 --- a/crates/relicario-cli/src/org_session.rs +++ b/crates/relicario-cli/src/org_session.rs @@ -17,10 +17,6 @@ use relicario_core::{ /// so this mirrors the personal-vault default /// `AttachmentCaps::per_attachment_max_bytes` at /// crates/relicario-core/src/settings.rs:116. -// Attachment API — consumed by `org add document`, Document edit, and purge -// landing in Tasks C2/C3; `load_attachment` additionally backs a future -// org document read/extract. Allow dead_code until those consumers land. -#[allow(dead_code)] pub const DEFAULT_ORG_ATTACHMENT_MAX_BYTES: u64 = 10 * 1024 * 1024; pub struct UnlockedOrgVault { @@ -126,14 +122,12 @@ impl UnlockedOrgVault { } } - #[allow(dead_code)] pub fn attachment_path(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> PathBuf { self.root.join("attachments").join(collection_slug) .join(item_id.as_str()).join(format!("{}.enc", att_id.as_str())) } /// Encrypt-already-done blob: persist it and return the repo-relative path for git staging. - #[allow(dead_code)] pub fn save_attachment(&self, collection_slug: &str, item_id: &ItemId, enc: &EncryptedAttachment) -> Result { let path = self.attachment_path(collection_slug, item_id, &enc.id); if let Some(parent) = path.parent() { @@ -143,6 +137,7 @@ impl UnlockedOrgVault { Ok(format!("attachments/{}/{}/{}.enc", collection_slug, item_id.as_str(), enc.id.as_str())) } + // Retained for a future `org document read/extract` command (mirrors `org_meta_path` convention). #[allow(dead_code)] pub fn load_attachment(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> Result>> { let path = self.attachment_path(collection_slug, item_id, att_id); @@ -152,7 +147,6 @@ impl UnlockedOrgVault { /// Remove an item's whole attachment directory. Missing dir is NOT an error /// (mirrors `remove_item`'s NotFound-tolerant behavior, for partial-write recovery). - #[allow(dead_code)] pub fn remove_item_attachments(&self, collection_slug: &str, item_id: &ItemId) -> Result<()> { let dir = self.root.join("attachments").join(collection_slug).join(item_id.as_str()); match fs::remove_dir_all(&dir) {