refactor(cli): move attach family (attach/attachments/extract/detach) into commands/
This commit is contained in:
175
crates/relicario-cli/src/commands/attach.rs
Normal file
175
crates/relicario-cli/src/commands/attach.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
//! `relicario attach` / `attachments` / `extract` / `detach` — per-attachment ops.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::parse::guess_mime;
|
||||
|
||||
pub fn cmd_attach(query: String, file: PathBuf) -> Result<()> {
|
||||
use std::fs;
|
||||
use relicario_core::{encrypt_attachment, AttachmentRef};
|
||||
use relicario_core::time::now_unix;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let id = entry.id.clone();
|
||||
let _ = entry;
|
||||
let mut item = vault.load_item(&id)?;
|
||||
let settings = vault.load_settings()?;
|
||||
let caps = settings.attachment_caps;
|
||||
|
||||
if item.attachments.len() as u32 >= caps.per_item_max_count {
|
||||
anyhow::bail!("item already has {} attachments (max {})",
|
||||
item.attachments.len(), caps.per_item_max_count);
|
||||
}
|
||||
|
||||
let bytes = fs::read(&file)
|
||||
.with_context(|| format!("failed to read {}", file.display()))?;
|
||||
|
||||
// Check per-vault total attachment bytes cap (audit I3).
|
||||
let current_total: u64 = manifest.items.values()
|
||||
.flat_map(|e| &e.attachment_summaries)
|
||||
.map(|s| s.size)
|
||||
.sum();
|
||||
let new_size = bytes.len() as u64;
|
||||
let hard_cap = caps.per_vault_hard_cap_bytes;
|
||||
let soft_cap = caps.per_vault_soft_cap_bytes;
|
||||
if current_total + new_size > hard_cap {
|
||||
anyhow::bail!(
|
||||
"attachment would exceed vault hard cap ({} + {} > {} bytes)",
|
||||
current_total, new_size, hard_cap
|
||||
);
|
||||
}
|
||||
if current_total + new_size > soft_cap {
|
||||
eprintln!(
|
||||
"warning: vault attachments will exceed soft cap ({} bytes)",
|
||||
soft_cap
|
||||
);
|
||||
}
|
||||
|
||||
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
|
||||
|
||||
let filename = file.file_name()
|
||||
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let mime_type = guess_mime(&filename);
|
||||
let aref = AttachmentRef {
|
||||
id: enc.id.clone(),
|
||||
filename,
|
||||
mime_type,
|
||||
size: bytes.len() as u64,
|
||||
created: now_unix(),
|
||||
};
|
||||
|
||||
let att_dir = vault.root().join("attachments").join(item.id.as_str());
|
||||
fs::create_dir_all(&att_dir)?;
|
||||
fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?;
|
||||
|
||||
item.attachments.push(aref);
|
||||
item.modified = now_unix();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.save_manifest(&manifest)?;
|
||||
|
||||
let paths = [
|
||||
format!("items/{}.enc", item.id.as_str()),
|
||||
"manifest.enc".into(),
|
||||
format!("attachments/{}/{}.enc", item.id.as_str(), enc.id.as_str()),
|
||||
];
|
||||
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
|
||||
super::commit_paths(&vault, &format!("attach: {} → {} ({})",
|
||||
crate::helpers::sanitize_for_commit(&file.display().to_string()),
|
||||
crate::helpers::sanitize_for_commit(&item.title),
|
||||
item.id.as_str()), &path_refs)?;
|
||||
eprintln!("Attached {} to {} (aid={})", file.display(), item.title, enc.id.as_str());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_attachments(query: String) -> Result<()> {
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let item = vault.load_item(&entry.id)?;
|
||||
if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); }
|
||||
println!("{:<17} {:>12} {:<22} FILENAME", "AID", "SIZE", "MIME");
|
||||
for a in &item.attachments {
|
||||
println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_extract(query: String, aid: String, out: Option<PathBuf>) -> Result<()> {
|
||||
use std::fs;
|
||||
use relicario_core::decrypt_attachment;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let item = vault.load_item(&entry.id)?;
|
||||
|
||||
let aref = item.attachments.iter().find(|a| a.id.as_str() == aid)
|
||||
.ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?;
|
||||
let path = vault.root().join("attachments").join(item.id.as_str())
|
||||
.join(format!("{}.enc", aid));
|
||||
let bytes = fs::read(&path)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
let plaintext = decrypt_attachment(&bytes, vault.key())?;
|
||||
let out_path = out.unwrap_or_else(|| PathBuf::from(&aref.filename));
|
||||
fs::write(&out_path, plaintext.as_slice())
|
||||
.with_context(|| format!("failed to write {}", out_path.display()))?;
|
||||
eprintln!("Wrote {} bytes to {}", plaintext.len(), out_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_detach(query: String, aid: String) -> Result<()> {
|
||||
use std::fs;
|
||||
use relicario_core::ItemCore;
|
||||
use relicario_core::time::now_unix;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let id = entry.id.clone();
|
||||
let _ = entry;
|
||||
let mut item = vault.load_item(&id)?;
|
||||
|
||||
let pos = item.attachments.iter().position(|a| a.id.as_str() == aid)
|
||||
.ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?;
|
||||
|
||||
// Document items keep their primary blob in the core; refuse to orphan it.
|
||||
if let ItemCore::Document(d) = &item.core {
|
||||
if d.primary_attachment.as_str() == aid {
|
||||
anyhow::bail!(
|
||||
"cannot detach the primary attachment of a Document item; \
|
||||
use `purge {}` to delete the whole item",
|
||||
item.title,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let removed = item.attachments.remove(pos);
|
||||
let blob_path = vault.root().join("attachments").join(item.id.as_str())
|
||||
.join(format!("{}.enc", removed.id.as_str()));
|
||||
if blob_path.exists() {
|
||||
fs::remove_file(&blob_path)
|
||||
.with_context(|| format!("failed to delete {}", blob_path.display()))?;
|
||||
}
|
||||
|
||||
item.modified = now_unix();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.save_manifest(&manifest)?;
|
||||
|
||||
let item_path = format!("items/{}.enc", item.id.as_str());
|
||||
let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str());
|
||||
super::commit_paths(
|
||||
&vault,
|
||||
&format!("detach: {} from {} ({})", crate::helpers::sanitize_for_commit(&removed.filename), crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||
&[&item_path, "manifest.enc", &blob_relpath],
|
||||
)?;
|
||||
eprintln!("Detached {} (aid={}) from {}", removed.filename, aid, item.title);
|
||||
Ok(())
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
//! this file as `pub(crate)` so siblings can pull them in via
|
||||
//! `use crate::commands::*`.
|
||||
|
||||
pub mod attach;
|
||||
pub mod backup;
|
||||
pub mod generate;
|
||||
pub mod get;
|
||||
|
||||
@@ -439,10 +439,10 @@ fn main() -> Result<()> {
|
||||
Commands::Trash { action } => commands::trash::cmd_trash(action),
|
||||
Commands::Backup { action } => commands::backup::cmd_backup(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),
|
||||
Commands::Detach { query, aid } => cmd_detach(query, aid),
|
||||
Commands::Attach { query, file } => commands::attach::cmd_attach(query, file),
|
||||
Commands::Attachments { query } => commands::attach::cmd_attachments(query),
|
||||
Commands::Extract { query, aid, out } => commands::attach::cmd_extract(query, aid, out),
|
||||
Commands::Detach { query, aid } => commands::attach::cmd_detach(query, aid),
|
||||
Commands::Generate { length, bip39, words, symbols, separator } => {
|
||||
commands::generate::cmd_generate(length, bip39, words, symbols, separator)
|
||||
}
|
||||
@@ -979,173 +979,6 @@ fn push_history(
|
||||
});
|
||||
}
|
||||
|
||||
fn cmd_attach(query: String, file: PathBuf) -> Result<()> {
|
||||
use std::fs;
|
||||
use relicario_core::{encrypt_attachment, AttachmentRef};
|
||||
use relicario_core::time::now_unix;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
let entry = resolve_query(&manifest, &query)?;
|
||||
let id = entry.id.clone();
|
||||
let _ = entry;
|
||||
let mut item = vault.load_item(&id)?;
|
||||
let settings = vault.load_settings()?;
|
||||
let caps = settings.attachment_caps;
|
||||
|
||||
if item.attachments.len() as u32 >= caps.per_item_max_count {
|
||||
anyhow::bail!("item already has {} attachments (max {})",
|
||||
item.attachments.len(), caps.per_item_max_count);
|
||||
}
|
||||
|
||||
let bytes = fs::read(&file)
|
||||
.with_context(|| format!("failed to read {}", file.display()))?;
|
||||
|
||||
// Check per-vault total attachment bytes cap (audit I3).
|
||||
let current_total: u64 = manifest.items.values()
|
||||
.flat_map(|e| &e.attachment_summaries)
|
||||
.map(|s| s.size)
|
||||
.sum();
|
||||
let new_size = bytes.len() as u64;
|
||||
let hard_cap = caps.per_vault_hard_cap_bytes;
|
||||
let soft_cap = caps.per_vault_soft_cap_bytes;
|
||||
if current_total + new_size > hard_cap {
|
||||
anyhow::bail!(
|
||||
"attachment would exceed vault hard cap ({} + {} > {} bytes)",
|
||||
current_total, new_size, hard_cap
|
||||
);
|
||||
}
|
||||
if current_total + new_size > soft_cap {
|
||||
eprintln!(
|
||||
"warning: vault attachments will exceed soft cap ({} bytes)",
|
||||
soft_cap
|
||||
);
|
||||
}
|
||||
|
||||
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
|
||||
|
||||
let filename = file.file_name()
|
||||
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let mime_type = guess_mime(&filename);
|
||||
let aref = AttachmentRef {
|
||||
id: enc.id.clone(),
|
||||
filename,
|
||||
mime_type,
|
||||
size: bytes.len() as u64,
|
||||
created: now_unix(),
|
||||
};
|
||||
|
||||
let att_dir = vault.root().join("attachments").join(item.id.as_str());
|
||||
fs::create_dir_all(&att_dir)?;
|
||||
fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?;
|
||||
|
||||
item.attachments.push(aref);
|
||||
item.modified = now_unix();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.save_manifest(&manifest)?;
|
||||
|
||||
let paths = [
|
||||
format!("items/{}.enc", item.id.as_str()),
|
||||
"manifest.enc".into(),
|
||||
format!("attachments/{}/{}.enc", item.id.as_str(), enc.id.as_str()),
|
||||
];
|
||||
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
|
||||
commit_paths(&vault, &format!("attach: {} → {} ({})",
|
||||
crate::helpers::sanitize_for_commit(&file.display().to_string()),
|
||||
crate::helpers::sanitize_for_commit(&item.title),
|
||||
item.id.as_str()), &path_refs)?;
|
||||
eprintln!("Attached {} to {} (aid={})", file.display(), item.title, enc.id.as_str());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_attachments(query: String) -> Result<()> {
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
let entry = resolve_query(&manifest, &query)?;
|
||||
let item = vault.load_item(&entry.id)?;
|
||||
if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); }
|
||||
println!("{:<17} {:>12} {:<22} FILENAME", "AID", "SIZE", "MIME");
|
||||
for a in &item.attachments {
|
||||
println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_extract(query: String, aid: String, out: Option<PathBuf>) -> Result<()> {
|
||||
use std::fs;
|
||||
use relicario_core::decrypt_attachment;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
let entry = resolve_query(&manifest, &query)?;
|
||||
let item = vault.load_item(&entry.id)?;
|
||||
|
||||
let aref = item.attachments.iter().find(|a| a.id.as_str() == aid)
|
||||
.ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?;
|
||||
let path = vault.root().join("attachments").join(item.id.as_str())
|
||||
.join(format!("{}.enc", aid));
|
||||
let bytes = fs::read(&path)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
let plaintext = decrypt_attachment(&bytes, vault.key())?;
|
||||
let out_path = out.unwrap_or_else(|| PathBuf::from(&aref.filename));
|
||||
fs::write(&out_path, plaintext.as_slice())
|
||||
.with_context(|| format!("failed to write {}", out_path.display()))?;
|
||||
eprintln!("Wrote {} bytes to {}", plaintext.len(), out_path.display());
|
||||
Ok(())
|
||||
}
|
||||
fn cmd_detach(query: String, aid: String) -> Result<()> {
|
||||
use std::fs;
|
||||
use relicario_core::ItemCore;
|
||||
use relicario_core::time::now_unix;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
let entry = resolve_query(&manifest, &query)?;
|
||||
let id = entry.id.clone();
|
||||
let _ = entry;
|
||||
let mut item = vault.load_item(&id)?;
|
||||
|
||||
let pos = item.attachments.iter().position(|a| a.id.as_str() == aid)
|
||||
.ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?;
|
||||
|
||||
// Document items keep their primary blob in the core; refuse to orphan it.
|
||||
if let ItemCore::Document(d) = &item.core {
|
||||
if d.primary_attachment.as_str() == aid {
|
||||
anyhow::bail!(
|
||||
"cannot detach the primary attachment of a Document item; \
|
||||
use `purge {}` to delete the whole item",
|
||||
item.title,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let removed = item.attachments.remove(pos);
|
||||
let blob_path = vault.root().join("attachments").join(item.id.as_str())
|
||||
.join(format!("{}.enc", removed.id.as_str()));
|
||||
if blob_path.exists() {
|
||||
fs::remove_file(&blob_path)
|
||||
.with_context(|| format!("failed to delete {}", blob_path.display()))?;
|
||||
}
|
||||
|
||||
item.modified = now_unix();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.save_manifest(&manifest)?;
|
||||
|
||||
let item_path = format!("items/{}.enc", item.id.as_str());
|
||||
let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str());
|
||||
commit_paths(
|
||||
&vault,
|
||||
&format!("detach: {} from {} ({})", crate::helpers::sanitize_for_commit(&removed.filename), crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||
&[&item_path, "manifest.enc", &blob_relpath],
|
||||
)?;
|
||||
eprintln!("Detached {} (aid={}) from {}", removed.filename, aid, item.title);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_settings(action: SettingsAction) -> Result<()> {
|
||||
use relicario_core::{
|
||||
Capitalization, CharClasses, GeneratorRequest, HistoryRetention,
|
||||
|
||||
Reference in New Issue
Block a user