diff options
| author | Miquel Sabaté Solà <mssola@mssola.com> | 2026-01-20 22:12:41 +0100 |
|---|---|---|
| committer | Miquel Sabaté Solà <mssola@mssola.com> | 2026-01-20 22:12:41 +0100 |
| commit | da7ecd3660ebb6cbf46749c7586a45cde19d9011 (patch) | |
| tree | f33a5e6cb410bf13f8ac02ea8df4b9ed7843d1f2 | |
| parent | 22f19cf9f50ae4ce96b175366602b9a2816332a6 (diff) | |
| download | mihi-da7ecd3660ebb6cbf46749c7586a45cde19d9011.tar.gz mihi-da7ecd3660ebb6cbf46749c7586a45cde19d9011.zip | |
Add the 'tags' command
This command includes the create, ls, and rm subcommands, which allow
for easy management of word tags.
Signed-off-by: Miquel Sabaté Solà <mssola@mssola.com>
| -rw-r--r-- | crates/cli/src/main.rs | 5 | ||||
| -rw-r--r-- | crates/cli/src/tags.rs | 178 | ||||
| -rw-r--r-- | lib/mihi/src/lib.rs | 50 | ||||
| -rw-r--r-- | lib/mihi/src/migrate.rs | 39 |
4 files changed, 266 insertions, 6 deletions
diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 871490a..9753cf9 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -4,6 +4,7 @@ mod init; mod locale; mod nuke; mod run; +mod tags; mod words; /// Version for this program. @@ -60,6 +61,10 @@ fn main() { let rest: Vec<String> = args.collect(); nuke::run(rest); } + "tags" => { + let rest: Vec<String> = args.collect(); + tags::run(rest); + } "words" => { let rest: Vec<String> = args.collect(); words::run(rest); diff --git a/crates/cli/src/tags.rs b/crates/cli/src/tags.rs new file mode 100644 index 0000000..82a14ce --- /dev/null +++ b/crates/cli/src/tags.rs @@ -0,0 +1,178 @@ +use inquire::{Confirm, Select}; +use mihi::{create_tag, delete_tag, select_tag_names}; +use std::vec::IntoIter; + +// Show the help message. +fn help(msg: Option<&str>) { + if let Some(msg) = msg { + println!("{}.\n", msg); + } + + println!("mihi tags: Manage tags.\n"); + println!("usage: mihi tags [OPTIONS] <subcommand>\n"); + + println!("Options:"); + println!(" -h, --help\t\tPrint this message."); + + println!("\nSubcommands:"); + println!(" create\t\tCreate a new tag."); + println!(" ls\t\t\tList tags from the database."); + println!(" rm\t\t\tRemove a tag from the database."); +} + +fn create(mut args: IntoIter<String>) -> i32 { + // We expect exactly one argument, which is the name of the tag. Note that + // this is wholly different to what's in for words/exercises, as the + // expected workflow on those is different as well. + if args.len() != 1 { + let mut msg = "error: tags: you have to pass exactly one argument, which is the name of the tag to be created".to_string(); + if args.len() > 1 { + msg.push_str(". You might want to wrap the given arguments in quotes"); + } + + help(Some(msg.as_str())); + return 1; + } + + // Fetch the name and guarantee it's unique. + let name = args.next().unwrap_or("".to_string()); + if let Ok(tags) = select_tag_names(&Some(name.clone())) { + for tag in tags { + if tag == name { + println!("errors: tags: '{}' already exists", name); + return 1; + } + } + } + + if create_tag(&name).is_ok() { + 0 + } else { + 1 + } +} + +fn ls(mut args: IntoIter<String>) -> i32 { + if args.len() > 1 { + help(Some("error: tags: too many filters")); + return 1; + } + + let tags = match select_tag_names(&args.next()) { + Ok(tags) => tags, + Err(e) => { + println!("error: tags: {e}."); + return 1; + } + }; + + for tag in tags { + println!("{tag}"); + } + + 0 +} + +fn select_single_tag(search: Option<String>) -> Result<String, String> { + let tags = select_tag_names(&search)?; + + match tags.len() { + 0 => Err("not found".to_string()), + 1 => Ok(tags.first().unwrap().to_owned()), + _ => match Select::new("Which tag?", tags).with_page_size(20).prompt() { + Ok(choice) => Ok(choice), + Err(_) => Err("abort!".to_string()), + }, + } +} + +fn rm(mut args: IntoIter<String>) -> i32 { + // We expect exactly one argument, which is the name of the tag. Note that + // this is wholly different to what's in for words/exercises, as the + // expected workflow on those is different as well. + if args.len() != 1 { + let mut msg = "error: tags: you have to pass exactly one argument, which is the name of the tag to be created".to_string(); + if args.len() > 1 { + msg.push_str(". You might want to wrap the given arguments in quotes"); + } + + help(Some(msg.as_str())); + return 1; + } + + // Select exactly one tag, with or without user feedback. If that's not + // possible, bail out. If one is selected, allow the user to think it + // through. + let selection = match select_single_tag(args.next()) { + Ok(tag) => tag, + Err(e) => { + println!("error: tags: {e}."); + return 1; + } + }; + let ans = Confirm::new( + format!("Do you really want to remove '{selection}' from the database?").as_str(), + ) + .with_default(false) + .prompt(); + + // We have a selected tag and the user confirmed its selection, go for it! + match ans { + Ok(true) => match delete_tag(&selection) { + Ok(_) => println!("Removed '{selection}' from the database!"), + Err(e) => { + println!("error: tags: {e}."); + return 1; + } + }, + Ok(false) => { + println!("Doing nothing..."); + } + Err(_) => return 1, + } + + 0 +} + +pub fn run(args: Vec<String>) { + if args.is_empty() { + help(Some( + "error: tags: you have to provide at least a subcommand", + )); + std::process::exit(1); + } + + let mut it = args.into_iter(); + + match it.next() { + Some(first) => match first.as_str() { + "-h" | "--help" => { + help(None); + std::process::exit(0); + } + "create" => { + std::process::exit(create(it)); + } + "ls" => { + std::process::exit(ls(it)); + } + "rm" => { + std::process::exit(rm(it)); + } + _ => { + help(Some( + format!("error: tags: unknown flag or command '{first}'").as_str(), + )); + std::process::exit(1); + } + }, + None => { + help(Some( + "error: tags: you need to provide a command" + .to_string() + .as_str(), + )); + std::process::exit(1); + } + } +} diff --git a/lib/mihi/src/lib.rs b/lib/mihi/src/lib.rs index 0c44335..c15e1ed 100644 --- a/lib/mihi/src/lib.rs +++ b/lib/mihi/src/lib.rs @@ -910,6 +910,56 @@ pub fn select_words_except( Ok(res) } +/// Returns a vector with the names for tags that match the given `filter`, or +/// all of them if None is passed as the filter. +pub fn select_tag_names(filter: &Option<String>) -> Result<Vec<String>, String> { + let conn = get_connection()?; + + let mut stmt; + let mut it = match filter { + Some(filter) => { + stmt = conn + .prepare("SELECT name FROM tags WHERE name LIKE ('%' || ?1 || '%') ORDER BY name") + .unwrap(); + stmt.query([filter.as_str()]).unwrap() + } + None => { + stmt = conn.prepare("SELECT name FROM tags ORDER BY name").unwrap(); + stmt.query([]).unwrap() + } + }; + + let mut res = vec![]; + while let Some(row) = it.next().unwrap() { + res.push(row.get::<usize, String>(0).unwrap()); + } + Ok(res) +} + +/// Insert into the database the tag identified by the given name. +pub fn create_tag(name: &str) -> Result<(), String> { + let conn = get_connection()?; + + match conn.execute( + "INSERT INTO tags (name, updated_at, created_at) \ + VALUES (?1, datetime('now'), datetime('now'))", + params![name.trim()], + ) { + Ok(_) => Ok(()), + Err(e) => Err(format!("could not create '{}': {}", name, e)), + } +} + +/// Delete the tag from the database. +pub fn delete_tag(name: &String) -> Result<(), String> { + let conn = get_connection()?; + + match conn.execute("DELETE FROM tags WHERE name = ?1", params![name.trim()]) { + Ok(_) => Ok(()), + Err(e) => Err(format!("could not remove '{name}': {e}")), + } +} + pub fn update_success(word: &Word, success: isize, steps: isize) -> Result<(), String> { let conn = get_connection()?; diff --git a/lib/mihi/src/migrate.rs b/lib/mihi/src/migrate.rs index a561b9c..b1ff0c3 100644 --- a/lib/mihi/src/migrate.rs +++ b/lib/mihi/src/migrate.rs @@ -1,11 +1,5 @@ use rusqlite::{Connection, Result}; -// TODO: not just the definition, but also the bootstrapping (into Git as there is nothing sensitive?). -// - conjugations -// - declensions -// - forms -// - word_relations - /// Use the given `connection` in order to initialize the database. pub fn init(connection: Connection) -> Result<usize> { connection.execute( @@ -71,5 +65,38 @@ CREATE UNIQUE INDEX IF NOT EXISTS "index_exercises_on_title" ON "exercises" ("ti (), )?; + connection.execute( + r#" +CREATE TABLE IF NOT EXISTS "tags" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "name" varchar NOT NULL, + "created_at" datetime(6) NOT NULL, + "updated_at" datetime(6) NOT NULL +); +"#, + (), + )?; + + connection.execute( + r#" +CREATE TABLE IF NOT EXISTS "tag_associations" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "word_id" integer NOT NULL, + "tag_id" integer NOT NULL, + "created_at" datetime(6) NOT NULL, + "updated_at" datetime(6) NOT NULL +); +"#, + (), + )?; + + connection.execute( + r#" +CREATE UNIQUE INDEX IF NOT EXISTS "index_tags_on_name" ON "tags" ("name"); +CREATE UNIQUE INDEX IF NOT EXISTS "word_tag_unique" ON tag_associations (word_id, tag_id); +"#, + (), + )?; + Ok(0) } |
