feat(cli/org): org document edit via --file + purge removes attachments
This commit is contained in:
@@ -1001,7 +1001,7 @@ fn resolve_org_query<'a>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>) -> Result<()> {
|
pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>, file: Option<std::path::PathBuf>) -> Result<()> {
|
||||||
use relicario_core::time::now_unix;
|
use relicario_core::time::now_unix;
|
||||||
use relicario_core::ItemCore;
|
use relicario_core::ItemCore;
|
||||||
|
|
||||||
@@ -1025,15 +1025,47 @@ pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
let history = &mut item.field_history;
|
let history = &mut item.field_history;
|
||||||
|
let mut doc_attachment_rel: Option<String> = None;
|
||||||
|
let mut new_doc_attachments: Option<Vec<relicario_core::AttachmentRef>> = None;
|
||||||
match &mut item.core {
|
match &mut item.core {
|
||||||
ItemCore::Login(l) => ib::edit_login(l, history, totp_qr)?,
|
ItemCore::Login(l) => ib::edit_login(l, history, totp_qr)?,
|
||||||
ItemCore::SecureNote(n) => ib::edit_secure_note(n, history)?,
|
ItemCore::SecureNote(n) => ib::edit_secure_note(n, history)?,
|
||||||
ItemCore::Identity(i) => ib::edit_identity(i)?,
|
ItemCore::Identity(i) => ib::edit_identity(i)?,
|
||||||
ItemCore::Card(c) => ib::edit_card(c, history)?,
|
ItemCore::Card(c) => ib::edit_card(c, history)?,
|
||||||
ItemCore::Key(k) => ib::edit_key(k, 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)?,
|
ItemCore::Totp(t) => ib::edit_totp(t, history)?,
|
||||||
}
|
}
|
||||||
|
if let Some(atts) = new_doc_attachments {
|
||||||
|
item.attachments = atts;
|
||||||
|
}
|
||||||
|
|
||||||
item.modified = now_unix();
|
item.modified = now_unix();
|
||||||
let item_rel = vault.save_item(&collection, &item)?;
|
let item_rel = vault.save_item(&collection, &item)?;
|
||||||
@@ -1053,7 +1085,13 @@ pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>) ->
|
|||||||
collection,
|
collection,
|
||||||
item.id.as_str()
|
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")?;
|
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);
|
println!("Updated {} ({}) in `{}`", item.title, item.id.as_str(), collection);
|
||||||
Ok(())
|
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.
|
// Remove the blob from disk, drop the manifest entry, stage with git rm.
|
||||||
vault.remove_item(&collection, &id)?;
|
vault.remove_item(&collection, &id)?;
|
||||||
|
vault.remove_item_attachments(&collection, &id)?;
|
||||||
let mut manifest = vault.load_manifest()?;
|
let mut manifest = vault.load_manifest()?;
|
||||||
manifest.entries.retain(|e| e.id != id);
|
manifest.entries.retain(|e| e.id != id);
|
||||||
vault.save_manifest(&manifest)?;
|
vault.save_manifest(&manifest)?;
|
||||||
|
|
||||||
let item_rel = format!("items/{}/{}.enc", collection, id.as_str());
|
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")?;
|
crate::org_session::org_git_run(&vault.root, &["add", "manifest.enc"], "org purge: git add manifest")?;
|
||||||
|
|
||||||
let commit_msg = format!(
|
let commit_msg = format!(
|
||||||
|
|||||||
@@ -541,6 +541,8 @@ pub(crate) enum OrgCommands {
|
|||||||
query: String,
|
query: String,
|
||||||
/// Replace the login TOTP secret from a QR image.
|
/// Replace the login TOTP secret from a QR image.
|
||||||
#[arg(long)] totp_qr: Option<std::path::PathBuf>,
|
#[arg(long)] totp_qr: Option<std::path::PathBuf>,
|
||||||
|
/// Replace a Document item's attachment file.
|
||||||
|
#[arg(long)] file: Option<std::path::PathBuf>,
|
||||||
},
|
},
|
||||||
/// Soft-delete an org item (reversible via `org restore`).
|
/// Soft-delete an org item (reversible via `org restore`).
|
||||||
Rm { query: String },
|
Rm { query: String },
|
||||||
@@ -760,9 +762,9 @@ fn main() -> Result<()> {
|
|||||||
let d = crate::org_session::org_dir(dir_path)?;
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
commands::org::run_list(&d, trashed)?;
|
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)?;
|
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 } => {
|
OrgCommands::Rm { query } => {
|
||||||
let d = crate::org_session::org_dir(dir_path)?;
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
|
|||||||
@@ -17,10 +17,6 @@ use relicario_core::{
|
|||||||
/// so this mirrors the personal-vault default
|
/// so this mirrors the personal-vault default
|
||||||
/// `AttachmentCaps::per_attachment_max_bytes` at
|
/// `AttachmentCaps::per_attachment_max_bytes` at
|
||||||
/// crates/relicario-core/src/settings.rs:116.
|
/// 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 const DEFAULT_ORG_ATTACHMENT_MAX_BYTES: u64 = 10 * 1024 * 1024;
|
||||||
|
|
||||||
pub struct UnlockedOrgVault {
|
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 {
|
pub fn attachment_path(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> PathBuf {
|
||||||
self.root.join("attachments").join(collection_slug)
|
self.root.join("attachments").join(collection_slug)
|
||||||
.join(item_id.as_str()).join(format!("{}.enc", att_id.as_str()))
|
.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.
|
/// 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<String> {
|
pub fn save_attachment(&self, collection_slug: &str, item_id: &ItemId, enc: &EncryptedAttachment) -> Result<String> {
|
||||||
let path = self.attachment_path(collection_slug, item_id, &enc.id);
|
let path = self.attachment_path(collection_slug, item_id, &enc.id);
|
||||||
if let Some(parent) = path.parent() {
|
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()))
|
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)]
|
#[allow(dead_code)]
|
||||||
pub fn load_attachment(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> Result<Zeroizing<Vec<u8>>> {
|
pub fn load_attachment(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> Result<Zeroizing<Vec<u8>>> {
|
||||||
let path = self.attachment_path(collection_slug, item_id, att_id);
|
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
|
/// Remove an item's whole attachment directory. Missing dir is NOT an error
|
||||||
/// (mirrors `remove_item`'s NotFound-tolerant behavior, for partial-write recovery).
|
/// (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<()> {
|
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());
|
let dir = self.root.join("attachments").join(collection_slug).join(item_id.as_str());
|
||||||
match fs::remove_dir_all(&dir) {
|
match fs::remove_dir_all(&dir) {
|
||||||
|
|||||||
Reference in New Issue
Block a user