diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a7b297..99c2e1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,24 @@ All notable changes to sinbo will be documented here. --- +## 1.7.0 - 2026-05-24 + +### Changed + +- Improved sinbo list formatting +- Missing variable placeholders now warn instead of halting +- Removed modified_at from metadata +- Added bulk `remove`, `export`, `import` +- Improved `sinbo list` output formatting + +### Fixed + +- Editing a snippet without a description no longer wipes it + ## 1.6.0 - 2026-04-19 ### Added + - `sinbo-lsp` — LSP server for inline snippet completions in any editor - VS Code extension with `sinbo:` trigger - Variable fallback values with `SINBO:name:fallback:` syntax @@ -16,6 +31,7 @@ All notable changes to sinbo will be documented here. ## 1.5.0 - 2026-04-13 ### Added + - `rename ` command to rename snippets - `--peek` / `-p` flag on `list` to preview first 30 characters of snippet content - `-a` short flag for `--args` on `get` @@ -25,6 +41,7 @@ All notable changes to sinbo will be documented here. ## 1.4.0 - 2026-04-12 ### Added + - Shell completions for bash, zsh, fish, and powershell (`sinbo completions `) - Dynamic snippet name completion on TAB for `get`, `remove`, `edit`, `encrypt`, `decrypt`, `export` - Hidden `list-names` command for shell completion scripts @@ -33,6 +50,7 @@ All notable changes to sinbo will be documented here. ## 1.3.0 - 2026-04-12 ### Added + - Variable substitution system with `SINBO:name:` placeholder syntax - `--args key=value` flag on `get` for placeholder substitution - `export` command — export snippets to `.sinbo.json` files @@ -42,12 +60,15 @@ All notable changes to sinbo will be documented here. ## 1.2.1 - 2026-04-11 ### Added + - Description field to snippet metadata ### Fixed + - Suppressed false-positive `RUSTSEC-2026-0097` advisory for `rand 0.8.5` (unsoundness does not affect sinbo's usage) ### CI + - Added `cargo audit` check to the pipeline ## 1.2.0 - 2026-04-10 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3b906a3..975330b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,13 @@ cargo build cargo test ``` +### Roadmap + +- [ ] Search should hit tags and descriptions, not just content +- [*] Bulk operations by tag — `remove -t`, `export -t`, `encrypt -t` +- [ ] Git sync — `sinbo sync init / push / pull` using snippets folder as a git repo +- [ ] Chrome extension — select text on any page, save as a snippet + --- ## Making Changes diff --git a/readme.md b/readme.md index f4f7dee..d0ca253 100644 --- a/readme.md +++ b/readme.md @@ -169,7 +169,7 @@ List all saved snippets. Encrypted snippets are shown with a `Locked` indicator. sinbo list # list all sinbo list -t docker # filter by tag sinbo list -s # show full content -sinbo list -p # preview first 25 characters +sinbo list -p # preview first 30 characters ``` | Flag | Short | Description | @@ -366,17 +366,17 @@ Add-Content $PROFILE "`nsinbo completions powershell | Invoke-Expression" --- - + ## How It Works @@ -389,7 +389,7 @@ Snippets are stored as plain files in your system config directory: | ------------------ | ------------------------------ | | `{name}.code` | Plaintext snippet content | | `{name}.enc` | Encrypted snippet content | -| `{name}.meta.json` | Tags, extension, and timestamp | +| `{name}.meta.json` | Tags, extension and description| Plain `.code` files are grep-able, copyable, and easy to back up directly. diff --git a/src/main.rs b/src/main.rs index dec5d8c..51ee8b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,8 @@ use std::{ + collections::HashSet, env, fs, io::{self, IsTerminal, Read}, - path::PathBuf, + path::{Path, PathBuf}, process::Command, }; @@ -76,11 +77,17 @@ enum Action { tags: Option>, #[arg(short, long, help = "Show snippet content")] show: bool, - #[arg(short, long, help = "Preview first 25 characters of snippet content")] + #[arg(short, long, help = "Preview first line of snippet content")] peek: bool, }, #[command(about = "Remove a snippet", alias = "r")] - Remove { name: String }, + Remove { + #[arg(num_args = 1..)] + names: Vec, + + #[arg(long, short)] + tags: Vec, + }, #[command(about = "Edit an existing snippet", alias = "e")] Edit { name: String, @@ -101,14 +108,19 @@ enum Action { Decrypt { name: String }, #[command(about = "Export a snippet to a .sinbo.json file")] Export { - name: String, + names: Vec, #[arg( short, long, num_args = 1, - help = "Directory to export into (default: current dir)" + help = "Directory to export into (default: current dir)", + default_value = "." )] - path: Option, + path: PathBuf, + #[arg(long, short)] + tags: Vec, + #[arg(long, short)] + all: bool, }, #[command(about = "Import a snippet from a .sinbo.json file")] Import { path: std::path::PathBuf }, @@ -204,7 +216,7 @@ fn main() -> Result<()> { let mut content = get_content(&storage, &name)?; let map: std::collections::HashMap = args.into_iter().collect(); - content = var::substitute(&content, &map)?; + content = var::substitute(&content, &map); if copy { let mut clipboard = arboard::Clipboard::new()?; @@ -223,7 +235,7 @@ fn main() -> Result<()> { if !args.is_empty() { let map: std::collections::HashMap = args.into_iter().collect(); - content = var::substitute(&content, &map)?; + content = var::substitute(&content, &map); } let mut clipboard = arboard::Clipboard::new()?; @@ -264,7 +276,6 @@ fn main() -> Result<()> { let meta = SnippetMeta { description, - modified_at: now_secs(), tags: tags.unwrap_or_default(), ext, }; @@ -302,86 +313,124 @@ fn main() -> Result<()> { return Ok(()); } - eprintln!("{} {} snippets\n", "sinbo".cyan().bold(), snippets.len()); - for s in &snippets { - let ext_raw = s - .meta - .ext - .as_deref() - .map(|e| format!(" .{e}")) - .unwrap_or_default(); + let total = snippets.len(); + let encrypted = snippets.iter().filter(|x| x.encrypted).count(); - let ext_str = s + fn folder_size(path: &Path) -> Result { + Ok(std::fs::read_dir(path) + .context("Failed to read snippets directory")? + .filter_map(|e| e.ok()) + .map(|e| e.metadata().unwrap().len()) + .sum()) + } + + let size_kb = folder_size(&storage.base_path)? / 1024; + + println!( + "\n{}{} {}", + "snippets".cyan().bold(), + format!("({})", total).bright_black(), + format!( + "{} encrypted · {} KB · Path: {}", + encrypted, + size_kb, + storage.base_path.display() + ) + .bright_black(), + ); + + for (i, s) in snippets.iter().enumerate() { + let is_last = i == snippets.len() - 1; + let branch = if is_last { "└──" } else { "├──" }; + let indent = if is_last { " " } else { "│ " }; + + let ext_part = s .meta .ext .as_deref() - .map(|e| format!(" .{}", e.bright_black())) + .map(|e| format!(" .{}", e).bright_black().to_string()) .unwrap_or_default(); - - let desc_str = s.meta.description.clone().unwrap_or_default(); - - let tags_raw = if s.meta.tags.is_empty() { - String::new() + let enc_part = if s.encrypted { + format!(" {}", "[enc]".yellow().dimmed()) } else { - format!("[{}]", s.meta.tags.join(", ")) + String::new() }; - - let tags_str = if s.meta.tags.is_empty() { + let tags_part = if s.meta.tags.is_empty() { String::new() } else { - format!("[{}]", s.meta.tags.join(", ").dimmed()) - }; - - let name_ext_raw = format!("{}{}", s.name, ext_raw); - let pad = 20usize.saturating_sub(name_ext_raw.len()); - - let tags_pad = 30usize.saturating_sub(tags_raw.len()); - - let end = if s.encrypted { format!( - "{} --- {}", - "Locked".yellow().dimmed(), - desc_str.bright_black().italic() + " {}", + s.meta + .tags + .iter() + .map(|t| format!("[{}]", t)) + .collect::>() + .join(" ") + .dimmed() ) - } else { - desc_str.bright_black().italic().to_string() }; + let desc_part = s + .meta + .description + .as_deref() + .map(|d| format!(" — {}", d.bright_black().italic())) + .unwrap_or_default(); + println!( - "{}{}{}{}{}{}", + "{} {}{}{}{}{}", + branch.bright_black(), s.name.cyan().bold(), - ext_str, - " ".repeat(pad), - tags_str, - " ".repeat(tags_pad), - end + ext_part, + enc_part, + tags_part, + desc_part, ); if show || peek { - if s.encrypted { - println!("> {}", "[encrypted]".dimmed()); + if show { + for line in s.content.lines() { + println!("{} {}", indent.bright_black(), line.dimmed()); + } } else { - let content = if show { - s.content.clone() - } else if peek { - if s.content.chars().count() > 25 { - format!("{}...", s.content.chars().take(25).collect::()) - } else { - s.content.chars().take(25).collect::() - } + let first = s.content.lines().next().unwrap_or(""); + let chars: String = first.chars().take(40).collect(); + let preview = if first.chars().count() > 40 { + format!("{}...", chars) } else { - s.content.clone() + chars }; - println!("> {}\n", content.dimmed()); + println!("{} {}", indent.bright_black(), preview.dimmed()); } } } + + println!(); } - Action::Remove { name } => { - eprintln!("This command will remove '{}'", name); + Action::Remove { names, tags } => { + let things = storage.list(Some(&tags))?; // dont forget to change the name + let names: Vec = names + .iter() + .filter(|x| storage.exists(x)) + .cloned() + .collect(); + let mut all_names: HashSet = names.into_iter().collect(); + for x in &things { + all_names.insert(x.name.clone()); + } + if all_names.is_empty() { + eprintln!("No available snippets in this scope"); + std::process::exit(1) + } + eprintln!("the following snippet(s) will be permanently deleted:"); + for n in &all_names { + eprintln!("- {n}") + } confirm(); - storage.remove(&name)?; - eprintln!("{} removed '{}'", "sinbo".cyan().bold(), name.yellow()); + for n in all_names { + storage.remove(&n)?; + } + println!("{} done", "sinbo".cyan().bold()); } Action::Edit { name, @@ -409,13 +458,18 @@ fn main() -> Result<()> { buf }; + let description = if description.is_some() { + description + } else { + snippet.meta.description + }; + if content.trim().is_empty() { return Err(anyhow!("snippet content is empty")); } let meta = SnippetMeta { description, - modified_at: now_secs(), tags: tags.unwrap_or(snippet.meta.tags), ext: snippet.meta.ext, }; @@ -525,12 +579,60 @@ fn main() -> Result<()> { eprintln!("{} decrypted '{}'", "sinbo".cyan().bold(), name.yellow()); } - Action::Export { name, path } => { - let snippet = storage.get(&name)?; - transfer::export(&snippet, path)?; + Action::Export { + path, + names, + tags, + all, + } => { + if all && (!names.is_empty() || !tags.is_empty()) { + return Err(anyhow!("--all cannot be combined with names or --tag")); + } + + let all_names: HashSet = if all { + storage.list(None)?.into_iter().map(|s| s.name).collect() + } else { + let mut set: HashSet = names + .iter() + .filter(|x| storage.exists(x)) + .cloned() + .collect(); + + if !tags.is_empty() { + for s in storage.list(Some(&tags))? { + set.insert(s.name); + } + } + + set + }; + + if all_names.is_empty() { + return Err(anyhow!("no snippets found in this scope")); + } + + for n in all_names { + let snippet = storage.get(&n)?; + transfer::export(&snippet, path.clone())?; + } + + eprintln!("{} exported successfully", "sinbo".cyan().bold()); } Action::Import { path } => { - transfer::import(path, storage)?; + if !path.exists() { + return Err(anyhow!("file not found: {}", path.display())); + } + if path.is_dir() { + for entry in fs::read_dir(&path)? { + let file = entry?.path(); + if !file.is_file() { + continue; + } + transfer::import(file, &storage)?; + } + } else { + transfer::import(path, &storage)?; + } } Action::ListNames => { let snippets = storage.list(None)?; @@ -565,13 +667,6 @@ fn main() -> Result<()> { Ok(()) } -fn now_secs() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() -} - fn parse_key_val(s: &str) -> Result<(String, String), String> { s.split_once('=') .map(|(k, v)| (k.to_string(), v.to_string())) diff --git a/src/storage.rs b/src/storage.rs index 688dd84..9a75309 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -9,7 +9,6 @@ pub struct SnippetMeta { pub description: Option, pub tags: Vec, pub ext: Option, - pub modified_at: u64, } pub struct Snippet { @@ -20,7 +19,7 @@ pub struct Snippet { } pub struct Storage { - base_path: PathBuf, + pub base_path: PathBuf, } impl Storage { @@ -191,7 +190,6 @@ mod tests { description: None, tags: vec![], ext: None, - modified_at: 0, } } diff --git a/src/transfer.rs b/src/transfer.rs index f4b5b80..e0dfef7 100644 --- a/src/transfer.rs +++ b/src/transfer.rs @@ -1,12 +1,9 @@ use anyhow::{Context, Result, anyhow}; use dialoguer::{Input, Select}; use serde::{Deserialize, Serialize}; -use std::{fs, path::PathBuf}; +use std::{fs, path::{Path, PathBuf}}; -use crate::{ - now_secs, - storage::{Snippet, SnippetMeta}, -}; +use crate::storage::{Snippet, SnippetMeta}; #[derive(Serialize, Deserialize)] struct ExportedSnippet { @@ -17,7 +14,11 @@ struct ExportedSnippet { extension: Option, } -pub fn export(snippet: &Snippet, path_to: Option) -> Result<()> { +pub fn export(snippet: &Snippet, path_to: PathBuf) -> Result<()> { + if !path_to.exists() { + return Err(anyhow!("path '{}' does not exist", path_to.display())); + } + if snippet.encrypted { return Err(anyhow!( "cannot export an encrypted snippet, decrypt it first" @@ -32,26 +33,25 @@ pub fn export(snippet: &Snippet, path_to: Option) -> Result<()> { extension: snippet.meta.ext.clone(), }; - let base = path_to.clone().unwrap_or_default(); - let mut f_path = base.join(format!("{}.sinbo.json", exported.name)); + let mut file_path = path_to.join(format!("{}.sinbo.json", exported.name)); - if f_path.exists() + if file_path.exists() && let Some(new_name) = - prompt_options(&format!("{}.sinbo.json", exported.name), None, Some(&base)) + prompt_options(&format!("{}.sinbo.json", exported.name), None, &path_to) { exported.name = new_name; - f_path = base.join(format!("{}.sinbo.json", exported.name)); + file_path = path_to.join(format!("{}.sinbo.json", exported.name)); } let json = serde_json::to_string_pretty(&exported) .with_context(|| format!("failed to serialize '{}'", exported.name))?; - fs::write(&f_path, json).with_context(|| format!("failed to write '{}'", exported.name))?; + fs::write(&file_path, json).with_context(|| format!("failed to write '{}'", exported.name))?; Ok(()) } -pub fn import(path: PathBuf, storage: crate::Storage) -> Result<()> { +pub fn import(path: PathBuf, storage: &crate::Storage) -> Result<()> { if !path.exists() { return Err(anyhow!("file not found: {}", path.display())); } @@ -67,7 +67,11 @@ pub fn import(path: PathBuf, storage: crate::Storage) -> Result<()> { .with_context(|| format!("failed to parse '{}'", path.display()))?; if storage.exists(&exported.name) - && let Some(new_name) = prompt_options(&exported.name, Some(&storage), None) + && let Some(new_name) = prompt_options( + &exported.name, + Some(storage), + &path.parent().unwrap(), + ) { exported.name = new_name; } @@ -76,7 +80,6 @@ pub fn import(path: PathBuf, storage: crate::Storage) -> Result<()> { description: exported.description, ext: exported.extension, tags: exported.tags.unwrap_or_default(), - modified_at: now_secs(), }; storage.save(&exported.name, &exported.content, meta)?; @@ -84,11 +87,7 @@ pub fn import(path: PathBuf, storage: crate::Storage) -> Result<()> { Ok(()) } -fn prompt_options( - name: &str, - storage: Option<&crate::Storage>, - base: Option<&PathBuf>, -) -> Option { +fn prompt_options(name: &str, storage: Option<&crate::Storage>, base: &Path) -> Option { let selection = Select::new() .with_prompt(format!("'{}' already exists", name)) .items(["Overwrite", "Rename", "Cancel"]) @@ -115,9 +114,7 @@ fn prompt_options( continue; } - if let Some(b) = base - && b.join(format!("{}.sinbo.json", new_name)).exists() - { + if base.join(format!("{}.sinbo.json", new_name)).exists() { eprintln!( "error: '{}.sinbo.json' already exists, choose another name", new_name @@ -127,7 +124,7 @@ fn prompt_options( return Some(new_name); }, - 2 => std::process::exit(0), + 2 => None, _ => unreachable!(), } } diff --git a/src/var.rs b/src/var.rs index cac760b..c8c12a6 100644 --- a/src/var.rs +++ b/src/var.rs @@ -1,4 +1,4 @@ -use anyhow::{Result, anyhow}; +use colored::Colorize; use std::collections::HashMap; const PREFIX: &str = "SINBO:"; @@ -42,7 +42,7 @@ pub fn extract_vars(content: &str) -> Vec<(String, Option)> { vars } -pub fn substitute(content: &str, args: &HashMap) -> Result { +pub fn substitute(content: &str, args: &HashMap) -> String { let vars = extract_vars(content); let mut result = content.to_string(); @@ -56,14 +56,21 @@ pub fn substitute(content: &str, args: &HashMap) -> Result val.clone(), None => match fallback { Some(f) => f.clone(), - None => return Err(anyhow!("missing value for variable '{}'", name)), + None => { + eprintln!( + "{} '{}'", + "missing value for variable".bold().yellow().dimmed(), + name.dimmed() + ); + format!("SINBO:{}:", name) + } }, }; result = result.replace(&placeholder, &value); } - Ok(result) + result } #[cfg(test)] @@ -108,7 +115,7 @@ mod tests { fn test_substitute_single_var() { let mut args = HashMap::new(); args.insert("port".to_string(), "8080".to_string()); - let result = substitute("docker run -p SINBO:port:", &args).unwrap(); + let result = substitute("docker run -p SINBO:port:", &args); assert_eq!(result, "docker run -p 8080"); } @@ -117,14 +124,14 @@ mod tests { let mut args = HashMap::new(); args.insert("port".to_string(), "8080".to_string()); args.insert("name".to_string(), "myapp".to_string()); - let result = substitute("docker run -p SINBO:port: -it SINBO:name:", &args).unwrap(); + let result = substitute("docker run -p SINBO:port: -it SINBO:name:", &args); assert_eq!(result, "docker run -p 8080 -it myapp"); } #[test] fn test_substitute_uses_fallback_when_missing() { let args = HashMap::new(); - let result = substitute("docker run -p SINBO:port:8080:", &args).unwrap(); + let result = substitute("docker run -p SINBO:port:8080:", &args); assert_eq!(result, "docker run -p 8080"); } @@ -132,22 +139,14 @@ mod tests { fn test_substitute_arg_overrides_fallback() { let mut args = HashMap::new(); args.insert("port".to_string(), "9090".to_string()); - let result = substitute("docker run -p SINBO:port:8080:", &args).unwrap(); + let result = substitute("docker run -p SINBO:port:8080:", &args); assert_eq!(result, "docker run -p 9090"); } - #[test] - fn test_substitute_missing_no_fallback_errors() { - let args = HashMap::new(); - let result = substitute("docker run -p SINBO:port:", &args); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("port")); - } - #[test] fn test_substitute_no_vars() { let args = HashMap::new(); - let result = substitute("docker run -p 8080", &args).unwrap(); + let result = substitute("docker run -p 8080", &args); assert_eq!(result, "docker run -p 8080"); } @@ -155,7 +154,7 @@ mod tests { fn test_substitute_preserves_non_placeholder_text() { let mut args = HashMap::new(); args.insert("name".to_string(), "myapp".to_string()); - let result = substitute("prefix SINBO:name: suffix", &args).unwrap(); + let result = substitute("prefix SINBO:name: suffix", &args); assert_eq!(result, "prefix myapp suffix"); } #[test]