diff options
| author | Miquel Sabaté Solà <mssola@mssola.com> | 2026-01-21 22:10:32 +0100 |
|---|---|---|
| committer | Miquel Sabaté Solà <mssola@mssola.com> | 2026-01-21 22:32:44 +0100 |
| commit | 950a64645f038a5a64076a8724b881e94fda504e (patch) | |
| tree | 9ed9334254c3612f3d3bef3ebc5fdb48e237e04d | |
| parent | cab0faea15c270dcefd2d88fc1f91477eca00c58 (diff) | |
| download | mihi-950a64645f038a5a64076a8724b881e94fda504e.tar.gz mihi-950a64645f038a5a64076a8724b881e94fda504e.zip | |
Allow to pair tags with words on words create/edit
Signed-off-by: Miquel Sabaté Solà <mssola@mssola.com>
| -rw-r--r-- | crates/cli/src/words.rs | 90 | ||||
| -rw-r--r-- | lib/mihi/src/lib.rs | 98 | ||||
| -rw-r--r-- | testdata/test.sqlite3 | bin | 2916352 -> 2916352 bytes |
3 files changed, 181 insertions, 7 deletions
diff --git a/crates/cli/src/words.rs b/crates/cli/src/words.rs index 9210dd8..b97a57f 100644 --- a/crates/cli/src/words.rs +++ b/crates/cli/src/words.rs @@ -1,10 +1,10 @@ use crate::inflection::print_full_inflection_for; use crate::locale::current_locale; -use inquire::{Confirm, Editor, Select, Text}; +use inquire::{Confirm, Editor, MultiSelect, Select, Text}; use mihi::{ - adverb, comparative, joint_related_words, select_related_words, superlative, Category, Gender, - Language, RelationKind, Word, + adverb, comparative, joint_related_words, select_related_words, select_tags_for, superlative, + Category, Gender, Language, RelationKind, Word, }; use std::vec::IntoIter; @@ -388,9 +388,22 @@ fn do_create(enunciated: String) -> Result<(), String> { let mut guess = get_initial_guess(enunciated.as_str()); guess.enunciated = enunciated.trim().to_string(); + let tags = select_tags_for(None)?; let word = ask_for_word_based_on(enunciated.clone(), guess)?; + let Ok(selected_tags) = MultiSelect::new("Tags:", tags) + .with_starting_cursor(0) + .prompt() + else { + return Err("abort!".to_string()); + }; + match mihi::create_word(word) { - Ok(_) => { + Ok(word_id) => { + for tag in selected_tags { + if let Err(e) = mihi::attach_tag_to_word(tag.id as i64, word_id) { + println!("warning: words: {e}"); + } + } println!("Word '{enunciated}' has been successfully created!"); Ok(()) } @@ -522,6 +535,9 @@ fn edit(mut args: IntoIter<String>) -> i32 { } }; + // Preserve this value as it will be used at the end of this function. + let word_id = word.id as i64; + // The enunciate might change, let's ask for it again. This way we get the // same experience as with the 'create' command. let Ok(enunciated) = Text::new("Enunciated:") @@ -534,6 +550,22 @@ fn edit(mut args: IntoIter<String>) -> i32 { return 0; } + // Select the tags for the current word. + let tags = match mihi::select_tags_for(Some(word.id)) { + Ok(tags) => tags, + Err(e) => { + println!("error: words: {e}"); + return 1; + } + }; + let all_tags = match mihi::select_tags_for(None) { + Ok(tags) => tags, + Err(e) => { + println!("error: words: {e}"); + return 1; + } + }; + // And ask again column by column to check for changes. let updated = match ask_for_word_based_on(enunciated.clone(), word) { Ok(word) => word, @@ -543,8 +575,58 @@ fn edit(mut args: IntoIter<String>) -> i32 { } }; + // Ask for tags. The indeces on the UI do not match the ones on the + // DB. Hence, we need to match the IDs from the DB to the ones displayed on + // the DB. It's a bit cumbersome but there shouldn't be many tags for this + // to become painfully slow. + let mut default_indices = vec![]; + for t in &tags { + for (idx, ta) in all_tags.iter().enumerate() { + if t.id == ta.id { + default_indices.push(idx); + } + } + } + let Ok(selected_tags) = MultiSelect::new("Tags:", all_tags) + .with_starting_cursor(0) + .with_default(&default_indices) + .prompt() + else { + return 1; + }; + + // Compute which tags to add and which to remove. This is, again, not the + // most fun thing to do, but I think it's better/cleaner on the long run + // than simply removing all tag associations and then bringing them + // back. And as I said before, there shouldn't be too many tags for this to + // become too slow. + let mut tags_to_add = vec![]; + let mut tags_to_remove = vec![]; + for st in &selected_tags { + if !tags.iter().any(|et| st.id == et.id) { + tags_to_add.push(st.id); + } + } + for et in &tags { + if !selected_tags.iter().any(|st| st.id == et.id) { + tags_to_remove.push(et.id); + } + } + match mihi::update_word(updated) { Ok(_) => { + // Add missing tags. + for tag in tags_to_add { + if let Err(e) = mihi::attach_tag_to_word(tag as i64, word_id) { + println!("warning: words: {e}"); + } + } + + // Drop tags which are no longer needed. + if let Err(e) = mihi::dettach_tags_from_word(&tags_to_remove, word_id) { + println!("warning: words: {e}"); + } + println!("Word '{enunciated}' has been updated!"); 0 } diff --git a/lib/mihi/src/lib.rs b/lib/mihi/src/lib.rs index 6ce450e..9d65b1c 100644 --- a/lib/mihi/src/lib.rs +++ b/lib/mihi/src/lib.rs @@ -360,6 +360,19 @@ pub fn adverb(word: &Word, related: &[Word]) -> String { } #[derive(Clone, Debug)] +pub struct Tag { + pub id: i32, + pub name: String, +} + +// Needed for inquire's (Multi)Select. +impl std::fmt::Display for Tag { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} + +#[derive(Clone, Debug)] pub struct Word { pub id: i32, pub enunciated: String, @@ -516,8 +529,8 @@ pub fn is_valid_word_flag(flag: &str) -> bool { BOOLEAN_FLAGS.contains(&flag) } -/// Creates the given word into the database. -pub fn create_word(word: Word) -> Result<(), String> { +/// Creates the given word into the database and returns its ID on success. +pub fn create_word(word: Word) -> Result<i64, String> { match word.category { Category::Noun => match word.declension_id { Some(id @ 1..7) => { @@ -605,11 +618,13 @@ pub fn create_word(word: Word) -> Result<(), String> { 0 ], ) { - Ok(_) => Ok(()), + Ok(_) => Ok(conn.last_insert_rowid()), Err(e) => Err(format!("could not create '{}': {}", word.enunciated, e)), } } +/// Update the word that matches the ID on `word` and set it to the new values +/// contained in the `word` object. pub fn update_word(word: Word) -> Result<(), String> { if word.id == 0 { return Err("invalid word to update; seems it has not been created before".to_string()); @@ -1022,6 +1037,44 @@ pub fn select_tag_names(filter: &Option<String>) -> Result<Vec<String>, String> Ok(res) } +/// Select all tags for the given `word`. If None is provided, then all tags +/// from the database are returned. +pub fn select_tags_for(word: Option<i32>) -> Result<Vec<Tag>, String> { + let conn = get_connection()?; + + let mut stmt; + let mut it = match word { + Some(id) => { + stmt = conn + .prepare( + "SELECT t.id, t.name \ + FROM tags t \ + JOIN tag_associations ta ON t.id = ta.tag_id \ + JOIN words w ON w.id = ta.word_id \ + WHERE w.id = ?1 \ + ORDER BY t.name", + ) + .unwrap(); + stmt.query([id]).unwrap() + } + None => { + stmt = conn + .prepare("SELECT id, name FROM tags ORDER BY name") + .unwrap(); + stmt.query([]).unwrap() + } + }; + + let mut res = vec![]; + while let Some(row) = it.next().unwrap() { + res.push(Tag { + id: row.get::<usize, i32>(0).unwrap(), + name: row.get::<usize, String>(1).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()?; @@ -1036,6 +1089,45 @@ pub fn create_tag(name: &str) -> Result<(), String> { } } +/// Inserts the pair of IDs into the tag_associations table. +pub fn attach_tag_to_word(tag_id: i64, word_id: i64) -> Result<(), String> { + let conn = get_connection()?; + + match conn.execute( + "INSERT INTO tag_associations (tag_id, word_id, updated_at, created_at) \ + VALUES (?1, ?2, datetime('now'), datetime('now'))", + params![tag_id, word_id], + ) { + Ok(_) => Ok(()), + Err(e) => Err(format!("could not attach tag: {e}")), + } +} + +/// Inserts the pair of IDs into the tag_associations table. +pub fn dettach_tags_from_word(tags: &[i32], word_id: i64) -> Result<(), String> { + if tags.is_empty() { + return Ok(()); + } + + let conn = get_connection()?; + + match conn.execute( + format!( + "DELETE FROM tag_associations \ + WHERE tag_id in ({}) AND word_id = ?1", + tags.iter() + .map(|t| format!("{}", t)) + .collect::<Vec<_>>() + .join(", ") + ) + .as_str(), + params![word_id], + ) { + Ok(_) => Ok(()), + Err(e) => Err(format!("could not attach tag: {e}")), + } +} + /// Delete the tag from the database. pub fn delete_tag(name: &String) -> Result<(), String> { let conn = get_connection()?; diff --git a/testdata/test.sqlite3 b/testdata/test.sqlite3 Binary files differindex 43d7ca0..f6e5939 100644 --- a/testdata/test.sqlite3 +++ b/testdata/test.sqlite3 |
