refactor(cli): move cmd_get/list/history/status/sync into commands/
This commit is contained in:
107
crates/relicario-cli/src/commands/get.rs
Normal file
107
crates/relicario-cli/src/commands/get.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
//! `relicario get` — print a single item, masking secrets unless `--show`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
pub fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
|
||||
use relicario_core::ItemCore;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let item = vault.load_item(&entry.id)?;
|
||||
|
||||
println!("ID: {}", item.id.as_str());
|
||||
println!("Title: {}", item.title);
|
||||
println!("Type: {:?}", item.r#type);
|
||||
if let Some(g) = &item.group { println!("Group: {g}"); }
|
||||
if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); }
|
||||
println!("Created: {}", crate::helpers::iso8601(item.created));
|
||||
println!("Modified: {}", crate::helpers::iso8601(item.modified));
|
||||
if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); }
|
||||
println!();
|
||||
|
||||
let primary_secret: Option<Zeroizing<String>> = match &item.core {
|
||||
ItemCore::Login(l) => {
|
||||
if let Some(u) = &l.username { println!("Username: {u}"); }
|
||||
if let Some(u) = &l.url { println!("URL: {u}"); }
|
||||
if let Some(t) = &l.totp {
|
||||
if show {
|
||||
println!("TOTP: {}", data_encoding::BASE32.encode(&t.secret));
|
||||
} else {
|
||||
println!("TOTP: **** (use --show to reveal)");
|
||||
}
|
||||
}
|
||||
l.password.clone()
|
||||
}
|
||||
ItemCore::SecureNote(n) => {
|
||||
if show { println!("Body:\n{}", n.body.as_str()); }
|
||||
else { println!("Body: ********"); }
|
||||
None
|
||||
}
|
||||
ItemCore::Identity(i) => {
|
||||
if let Some(v) = &i.full_name { println!("Name: {v}"); }
|
||||
if let Some(v) = &i.email { println!("Email: {v}"); }
|
||||
if let Some(v) = &i.phone { println!("Phone: {v}"); }
|
||||
if let Some(v) = &i.date_of_birth { println!("DOB: {v}"); }
|
||||
None
|
||||
}
|
||||
ItemCore::Card(c) => {
|
||||
if let Some(h) = &c.holder { println!("Holder: {h}"); }
|
||||
if let Some(e) = &c.expiry { println!("Expiry: {:02}/{}", e.month, e.year); }
|
||||
println!("Kind: {:?}", c.kind);
|
||||
c.number.clone()
|
||||
}
|
||||
ItemCore::Key(k) => {
|
||||
if let Some(l) = &k.label { println!("Label: {l}"); }
|
||||
if let Some(a) = &k.algorithm { println!("Algo: {a}"); }
|
||||
if let Some(pk) = &k.public_key { println!("Pubkey: {pk}"); }
|
||||
Some(k.key_material.clone())
|
||||
}
|
||||
ItemCore::Document(d) => {
|
||||
println!("Filename: {}", d.filename);
|
||||
println!("MIME: {}", d.mime_type);
|
||||
None
|
||||
}
|
||||
ItemCore::Totp(t) => {
|
||||
if let Some(i) = &t.issuer { println!("Issuer: {i}"); }
|
||||
if let Some(l) = &t.label { println!("Label: {l}"); }
|
||||
println!("Period: {}s", t.config.period_seconds);
|
||||
println!("Digits: {}", t.config.digits);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(secret) = primary_secret {
|
||||
if show {
|
||||
println!("Secret: {}", secret.as_str());
|
||||
} else {
|
||||
println!("Secret: ******** (use --show to reveal, --copy to clipboard)");
|
||||
}
|
||||
if copy {
|
||||
copy_to_clipboard_then_clear(&secret)?;
|
||||
eprintln!("Copied to clipboard (auto-clears in 30s).");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_to_clipboard_then_clear(secret: &zeroize::Zeroizing<String>) -> Result<()> {
|
||||
use arboard::Clipboard;
|
||||
let mut cb = Clipboard::new().context("failed to access clipboard")?;
|
||||
cb.set_text(secret.as_str().to_string()).context("failed to write clipboard")?;
|
||||
let cleared_copy = zeroize::Zeroizing::new(secret.as_str().to_owned());
|
||||
// Unconditional clear (audit M6): spawn a detached thread that waits 30s
|
||||
// and then rewrites the clipboard with empty string. Even if the user
|
||||
// copies something else in the interim, we still overwrite once.
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||
if let Ok(mut cb) = Clipboard::new() {
|
||||
let _ = cb.set_text(String::new());
|
||||
drop(cleared_copy); // zeroize the detached copy
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
103
crates/relicario-cli/src/commands/list.rs
Normal file
103
crates/relicario-cli/src/commands/list.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
//! `relicario list` and `relicario history` — both read-only browse paths.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn cmd_list(
|
||||
type_filter: Option<String>,
|
||||
group_filter: Option<String>,
|
||||
tag_filter: Option<String>,
|
||||
trashed: bool,
|
||||
) -> Result<()> {
|
||||
use relicario_core::ItemType;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
||||
|
||||
let parsed_type: Option<ItemType> = match type_filter.as_deref() {
|
||||
None => None,
|
||||
Some("login") => Some(ItemType::Login),
|
||||
Some("secure_note") | Some("note") => Some(ItemType::SecureNote),
|
||||
Some("identity") => Some(ItemType::Identity),
|
||||
Some("card") => Some(ItemType::Card),
|
||||
Some("key") => Some(ItemType::Key),
|
||||
Some("document") => Some(ItemType::Document),
|
||||
Some("totp") => Some(ItemType::Totp),
|
||||
Some(other) => anyhow::bail!("unknown type filter: {other}"),
|
||||
};
|
||||
|
||||
let mut entries: Vec<_> = manifest.items.values()
|
||||
.filter(|e| {
|
||||
if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() }
|
||||
})
|
||||
.filter(|e| match parsed_type {
|
||||
Some(t) => e.r#type == t,
|
||||
None => true,
|
||||
})
|
||||
.filter(|e| group_filter.as_ref().is_none_or(|g| e.group.as_deref() == Some(g.as_str())))
|
||||
.filter(|e| tag_filter.as_ref().is_none_or(|t| e.tags.iter().any(|x| x == t)))
|
||||
.collect();
|
||||
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
|
||||
|
||||
if entries.is_empty() {
|
||||
eprintln!("(no items match)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("{:<16} {:<14} {:<6} TITLE", "ID", "TYPE", "FAV");
|
||||
for e in entries {
|
||||
let fav = if e.favorite { " *" } else { "" };
|
||||
println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_history(query: String, show: bool, field: Option<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)?;
|
||||
|
||||
println!("History for {} ({})", item.title, item.id.as_str());
|
||||
println!();
|
||||
|
||||
// Filter and sort the field-id keys so output is deterministic.
|
||||
let mut keys: Vec<&relicario_core::FieldId> = item.field_history.keys().collect();
|
||||
keys.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
let mut printed_any = false;
|
||||
for fid in keys {
|
||||
let display_name = fid.0.strip_prefix("core:").unwrap_or(&fid.0);
|
||||
if let Some(filter) = &field {
|
||||
if display_name != filter && fid.0 != *filter { continue; }
|
||||
}
|
||||
let entries = &item.field_history[fid];
|
||||
if entries.is_empty() { continue; }
|
||||
printed_any = true;
|
||||
|
||||
println!("{display_name} ({} {})",
|
||||
entries.len(),
|
||||
if entries.len() == 1 { "entry" } else { "entries" });
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
let ts = crate::helpers::iso8601(e.replaced_at);
|
||||
if show {
|
||||
println!(" [{i}] {ts} {}", e.value.as_str());
|
||||
} else {
|
||||
println!(" [{i}] {ts} ********");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
if !printed_any {
|
||||
if field.is_some() {
|
||||
println!("no history for the requested field");
|
||||
} else {
|
||||
println!("no history captured for this item");
|
||||
}
|
||||
} else if !show {
|
||||
println!("(use --show to reveal values)");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -7,8 +7,12 @@
|
||||
//! `use crate::commands::*`.
|
||||
|
||||
pub mod generate;
|
||||
pub mod get;
|
||||
pub mod init;
|
||||
pub mod list;
|
||||
pub mod rate;
|
||||
pub mod status;
|
||||
pub mod sync;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
|
||||
52
crates/relicario-cli/src/commands/status.rs
Normal file
52
crates/relicario-cli/src/commands/status.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
//! `relicario status` — vault-level summary (counts, last commit, last backup).
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn cmd_status() -> Result<()> {
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let root = vault.root().to_path_buf();
|
||||
let manifest = vault.load_manifest()?;
|
||||
|
||||
let total_items = manifest.items.len();
|
||||
let trashed_items = manifest.items.values().filter(|e| e.trashed_at.is_some()).count();
|
||||
let active_items = total_items - trashed_items;
|
||||
|
||||
let (attachment_count, attachment_bytes) = manifest.items.values()
|
||||
.flat_map(|e| e.attachment_summaries.iter())
|
||||
.fold((0u64, 0u64), |(c, b), s| (c + 1, b + s.size));
|
||||
|
||||
let last_commit = crate::helpers::git_command(&root, &[
|
||||
"log", "-1", "--pretty=format:%h %s",
|
||||
]).output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "(no commits)".into());
|
||||
|
||||
// Last backup age (read from marker written by cmd_backup_export).
|
||||
let last_backup_path = vault.root().join(".relicario").join("last_backup");
|
||||
let last_backup_str = if last_backup_path.exists() {
|
||||
let line = std::fs::read_to_string(&last_backup_path)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
// Parse the ISO-8601 we wrote in cmd_backup_export.
|
||||
match chrono::DateTime::parse_from_rfc3339(&line) {
|
||||
Ok(then) => {
|
||||
let now = relicario_core::now_unix();
|
||||
let age = now - then.timestamp();
|
||||
crate::helpers::humanize_age(age.max(0))
|
||||
}
|
||||
Err(_) => "unknown".to_string(),
|
||||
}
|
||||
} else {
|
||||
"never".to_string()
|
||||
};
|
||||
|
||||
println!("Vault: {}", root.display());
|
||||
println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)");
|
||||
println!("Attachments: {attachment_count} ({attachment_bytes} bytes)");
|
||||
println!("Last commit: {last_commit}");
|
||||
println!("Last export: {last_backup_str}");
|
||||
Ok(())
|
||||
}
|
||||
13
crates/relicario-cli/src/commands/sync.rs
Normal file
13
crates/relicario-cli/src/commands/sync.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
//! `relicario sync` — pull --rebase + push.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn cmd_sync() -> Result<()> {
|
||||
let root = crate::helpers::vault_dir()?;
|
||||
let pull = crate::helpers::git_command(&root, &["pull", "--rebase"]).status()?;
|
||||
if !pull.success() { anyhow::bail!("git pull --rebase failed"); }
|
||||
let push = crate::helpers::git_command(&root, &["push"]).status()?;
|
||||
if !push.success() { anyhow::bail!("git push failed"); }
|
||||
eprintln!("Sync complete.");
|
||||
Ok(())
|
||||
}
|
||||
@@ -429,10 +429,10 @@ fn main() -> Result<()> {
|
||||
match cli.command {
|
||||
Commands::Init { image, output } => commands::init::cmd_init(image, output),
|
||||
Commands::Add { kind } => cmd_add(kind),
|
||||
Commands::Get { query, show, copy } => cmd_get(query, show, copy),
|
||||
Commands::List { r#type, group, tag, trashed } => cmd_list(r#type, group, tag, trashed),
|
||||
Commands::Get { query, show, copy } => commands::get::cmd_get(query, show, copy),
|
||||
Commands::List { r#type, group, tag, trashed } => commands::list::cmd_list(r#type, group, tag, trashed),
|
||||
Commands::Edit { query, totp_qr } => cmd_edit(query, totp_qr),
|
||||
Commands::History { query, show, field } => cmd_history(query, show, field),
|
||||
Commands::History { query, show, field } => commands::list::cmd_history(query, show, field),
|
||||
Commands::Rm { query } => cmd_rm(query),
|
||||
Commands::Restore { query } => cmd_restore(query),
|
||||
Commands::Purge { query } => cmd_purge(query),
|
||||
@@ -447,8 +447,8 @@ fn main() -> Result<()> {
|
||||
commands::generate::cmd_generate(length, bip39, words, symbols, separator)
|
||||
}
|
||||
Commands::Settings { action } => cmd_settings(action),
|
||||
Commands::Sync => cmd_sync(),
|
||||
Commands::Status => cmd_status(),
|
||||
Commands::Sync => commands::sync::cmd_sync(),
|
||||
Commands::Status => commands::status::cmd_status(),
|
||||
Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) }
|
||||
Commands::Completions { shell } => {
|
||||
let mut cmd = Cli::command();
|
||||
@@ -467,7 +467,7 @@ fn main() -> Result<()> {
|
||||
///
|
||||
/// Failures are silently swallowed — a missing cache is merely a UX degradation,
|
||||
/// not a correctness problem.
|
||||
fn refresh_groups_cache(vault_dir: &std::path::Path, manifest: &relicario_core::Manifest) {
|
||||
pub(crate) fn refresh_groups_cache(vault_dir: &std::path::Path, manifest: &relicario_core::Manifest) {
|
||||
let mut set = std::collections::BTreeSet::<String>::new();
|
||||
for entry in manifest.items.values() {
|
||||
if let Some(g) = entry.group.as_ref() {
|
||||
@@ -815,158 +815,6 @@ fn build_totp_item(
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
|
||||
use relicario_core::ItemCore;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
refresh_groups_cache(vault.root(), &manifest);
|
||||
let entry = resolve_query(&manifest, &query)?;
|
||||
let item = vault.load_item(&entry.id)?;
|
||||
|
||||
println!("ID: {}", item.id.as_str());
|
||||
println!("Title: {}", item.title);
|
||||
println!("Type: {:?}", item.r#type);
|
||||
if let Some(g) = &item.group { println!("Group: {g}"); }
|
||||
if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); }
|
||||
println!("Created: {}", crate::helpers::iso8601(item.created));
|
||||
println!("Modified: {}", crate::helpers::iso8601(item.modified));
|
||||
if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); }
|
||||
println!();
|
||||
|
||||
let primary_secret: Option<Zeroizing<String>> = match &item.core {
|
||||
ItemCore::Login(l) => {
|
||||
if let Some(u) = &l.username { println!("Username: {u}"); }
|
||||
if let Some(u) = &l.url { println!("URL: {u}"); }
|
||||
if let Some(t) = &l.totp {
|
||||
if show {
|
||||
println!("TOTP: {}", data_encoding::BASE32.encode(&t.secret));
|
||||
} else {
|
||||
println!("TOTP: **** (use --show to reveal)");
|
||||
}
|
||||
}
|
||||
l.password.clone()
|
||||
}
|
||||
ItemCore::SecureNote(n) => {
|
||||
if show { println!("Body:\n{}", n.body.as_str()); }
|
||||
else { println!("Body: ********"); }
|
||||
None
|
||||
}
|
||||
ItemCore::Identity(i) => {
|
||||
if let Some(v) = &i.full_name { println!("Name: {v}"); }
|
||||
if let Some(v) = &i.email { println!("Email: {v}"); }
|
||||
if let Some(v) = &i.phone { println!("Phone: {v}"); }
|
||||
if let Some(v) = &i.date_of_birth { println!("DOB: {v}"); }
|
||||
None
|
||||
}
|
||||
ItemCore::Card(c) => {
|
||||
if let Some(h) = &c.holder { println!("Holder: {h}"); }
|
||||
if let Some(e) = &c.expiry { println!("Expiry: {:02}/{}", e.month, e.year); }
|
||||
println!("Kind: {:?}", c.kind);
|
||||
c.number.clone()
|
||||
}
|
||||
ItemCore::Key(k) => {
|
||||
if let Some(l) = &k.label { println!("Label: {l}"); }
|
||||
if let Some(a) = &k.algorithm { println!("Algo: {a}"); }
|
||||
if let Some(pk) = &k.public_key { println!("Pubkey: {pk}"); }
|
||||
Some(k.key_material.clone())
|
||||
}
|
||||
ItemCore::Document(d) => {
|
||||
println!("Filename: {}", d.filename);
|
||||
println!("MIME: {}", d.mime_type);
|
||||
None
|
||||
}
|
||||
ItemCore::Totp(t) => {
|
||||
if let Some(i) = &t.issuer { println!("Issuer: {i}"); }
|
||||
if let Some(l) = &t.label { println!("Label: {l}"); }
|
||||
println!("Period: {}s", t.config.period_seconds);
|
||||
println!("Digits: {}", t.config.digits);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(secret) = primary_secret {
|
||||
if show {
|
||||
println!("Secret: {}", secret.as_str());
|
||||
} else {
|
||||
println!("Secret: ******** (use --show to reveal, --copy to clipboard)");
|
||||
}
|
||||
if copy {
|
||||
copy_to_clipboard_then_clear(&secret)?;
|
||||
eprintln!("Copied to clipboard (auto-clears in 30s).");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_to_clipboard_then_clear(secret: &zeroize::Zeroizing<String>) -> Result<()> {
|
||||
use arboard::Clipboard;
|
||||
let mut cb = Clipboard::new().context("failed to access clipboard")?;
|
||||
cb.set_text(secret.as_str().to_string()).context("failed to write clipboard")?;
|
||||
let cleared_copy = zeroize::Zeroizing::new(secret.as_str().to_owned());
|
||||
// Unconditional clear (audit M6): spawn a detached thread that waits 30s
|
||||
// and then rewrites the clipboard with empty string. Even if the user
|
||||
// copies something else in the interim, we still overwrite once.
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||
if let Ok(mut cb) = Clipboard::new() {
|
||||
let _ = cb.set_text(String::new());
|
||||
drop(cleared_copy); // zeroize the detached copy
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
fn cmd_list(
|
||||
type_filter: Option<String>,
|
||||
group_filter: Option<String>,
|
||||
tag_filter: Option<String>,
|
||||
trashed: bool,
|
||||
) -> Result<()> {
|
||||
use relicario_core::ItemType;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
refresh_groups_cache(vault.root(), &manifest);
|
||||
|
||||
let parsed_type: Option<ItemType> = match type_filter.as_deref() {
|
||||
None => None,
|
||||
Some("login") => Some(ItemType::Login),
|
||||
Some("secure_note") | Some("note") => Some(ItemType::SecureNote),
|
||||
Some("identity") => Some(ItemType::Identity),
|
||||
Some("card") => Some(ItemType::Card),
|
||||
Some("key") => Some(ItemType::Key),
|
||||
Some("document") => Some(ItemType::Document),
|
||||
Some("totp") => Some(ItemType::Totp),
|
||||
Some(other) => anyhow::bail!("unknown type filter: {other}"),
|
||||
};
|
||||
|
||||
let mut entries: Vec<_> = manifest.items.values()
|
||||
.filter(|e| {
|
||||
if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() }
|
||||
})
|
||||
.filter(|e| match parsed_type {
|
||||
Some(t) => e.r#type == t,
|
||||
None => true,
|
||||
})
|
||||
.filter(|e| group_filter.as_ref().is_none_or(|g| e.group.as_deref() == Some(g.as_str())))
|
||||
.filter(|e| tag_filter.as_ref().is_none_or(|t| e.tags.iter().any(|x| x == t)))
|
||||
.collect();
|
||||
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
|
||||
|
||||
if entries.is_empty() {
|
||||
eprintln!("(no items match)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("{:<16} {:<14} {:<6} TITLE", "ID", "TYPE", "FAV");
|
||||
for e in entries {
|
||||
let fav = if e.favorite { " *" } else { "" };
|
||||
println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
||||
use relicario_core::time::now_unix;
|
||||
use relicario_core::ItemCore;
|
||||
@@ -1131,56 +979,6 @@ fn push_history(
|
||||
});
|
||||
}
|
||||
|
||||
fn cmd_history(query: String, show: bool, field: Option<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)?;
|
||||
|
||||
println!("History for {} ({})", item.title, item.id.as_str());
|
||||
println!();
|
||||
|
||||
// Filter and sort the field-id keys so output is deterministic.
|
||||
let mut keys: Vec<&relicario_core::FieldId> = item.field_history.keys().collect();
|
||||
keys.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
let mut printed_any = false;
|
||||
for fid in keys {
|
||||
let display_name = fid.0.strip_prefix("core:").unwrap_or(&fid.0);
|
||||
if let Some(filter) = &field {
|
||||
if display_name != filter && fid.0 != *filter { continue; }
|
||||
}
|
||||
let entries = &item.field_history[fid];
|
||||
if entries.is_empty() { continue; }
|
||||
printed_any = true;
|
||||
|
||||
println!("{display_name} ({} {})",
|
||||
entries.len(),
|
||||
if entries.len() == 1 { "entry" } else { "entries" });
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
let ts = crate::helpers::iso8601(e.replaced_at);
|
||||
if show {
|
||||
println!(" [{i}] {ts} {}", e.value.as_str());
|
||||
} else {
|
||||
println!(" [{i}] {ts} ********");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
if !printed_any {
|
||||
if field.is_some() {
|
||||
println!("no history for the requested field");
|
||||
} else {
|
||||
println!("no history captured for this item");
|
||||
}
|
||||
} else if !show {
|
||||
println!("(use --show to reveal values)");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_rm(query: String) -> Result<()> {
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
@@ -1264,7 +1062,7 @@ fn cmd_purge(query: String) -> Result<()> {
|
||||
|
||||
fn cmd_trash(action: TrashAction) -> Result<()> {
|
||||
match action {
|
||||
TrashAction::List => cmd_list(None, None, None, true),
|
||||
TrashAction::List => commands::list::cmd_list(None, None, None, true),
|
||||
TrashAction::Empty => cmd_trash_empty(),
|
||||
}
|
||||
}
|
||||
@@ -1942,65 +1740,6 @@ fn cmd_settings(action: SettingsAction) -> Result<()> {
|
||||
eprintln!("Settings updated.");
|
||||
Ok(())
|
||||
}
|
||||
fn cmd_sync() -> Result<()> {
|
||||
let root = crate::helpers::vault_dir()?;
|
||||
let pull = crate::helpers::git_command(&root, &["pull", "--rebase"]).status()?;
|
||||
if !pull.success() { anyhow::bail!("git pull --rebase failed"); }
|
||||
let push = crate::helpers::git_command(&root, &["push"]).status()?;
|
||||
if !push.success() { anyhow::bail!("git push failed"); }
|
||||
eprintln!("Sync complete.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_status() -> Result<()> {
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let root = vault.root().to_path_buf();
|
||||
let manifest = vault.load_manifest()?;
|
||||
|
||||
let total_items = manifest.items.len();
|
||||
let trashed_items = manifest.items.values().filter(|e| e.trashed_at.is_some()).count();
|
||||
let active_items = total_items - trashed_items;
|
||||
|
||||
let (attachment_count, attachment_bytes) = manifest.items.values()
|
||||
.flat_map(|e| e.attachment_summaries.iter())
|
||||
.fold((0u64, 0u64), |(c, b), s| (c + 1, b + s.size));
|
||||
|
||||
let last_commit = crate::helpers::git_command(&root, &[
|
||||
"log", "-1", "--pretty=format:%h %s",
|
||||
]).output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "(no commits)".into());
|
||||
|
||||
// Last backup age (read from marker written by cmd_backup_export).
|
||||
let last_backup_path = vault.root().join(".relicario").join("last_backup");
|
||||
let last_backup_str = if last_backup_path.exists() {
|
||||
let line = std::fs::read_to_string(&last_backup_path)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
// Parse the ISO-8601 we wrote in cmd_backup_export.
|
||||
match chrono::DateTime::parse_from_rfc3339(&line) {
|
||||
Ok(then) => {
|
||||
let now = relicario_core::now_unix();
|
||||
let age = now - then.timestamp();
|
||||
crate::helpers::humanize_age(age.max(0))
|
||||
}
|
||||
Err(_) => "unknown".to_string(),
|
||||
}
|
||||
} else {
|
||||
"never".to_string()
|
||||
};
|
||||
|
||||
println!("Vault: {}", root.display());
|
||||
println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)");
|
||||
println!("Attachments: {attachment_count} ({attachment_bytes} bytes)");
|
||||
println!("Last commit: {last_commit}");
|
||||
println!("Last export: {last_backup_str}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Device management ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a `GiteaClient` from flags or environment variables.
|
||||
|
||||
Reference in New Issue
Block a user