diff options
| author | Miquel Sabaté Solà <mikisabate@gmail.com> | 2025-07-29 08:10:26 +0200 |
|---|---|---|
| committer | Miquel Sabaté Solà <mikisabate@gmail.com> | 2025-07-29 08:10:26 +0200 |
| commit | f28e0459b5918b1b21cba285af0e14c9d223a3b7 (patch) | |
| tree | 6715e7dca62f75318280d9be55a7111262978397 | |
| parent | 33e42deaf62e80864066b815654e1ee427f67353 (diff) | |
| download | mihi-f28e0459b5918b1b21cba285af0e14c9d223a3b7.tar.gz mihi-f28e0459b5918b1b21cba285af0e14c9d223a3b7.zip | |
Implement the edit subcommand for words
This implied replacing the Guess struct for a full Word, so the same
code for word creation could be re-used.
Signed-off-by: Miquel Sabaté Solà <mikisabate@gmail.com>
| -rw-r--r-- | crates/cli/src/words.rs | 283 | ||||
| -rw-r--r-- | lib/mihi/src/lib.rs | 214 |
2 files changed, 358 insertions, 139 deletions
diff --git a/crates/cli/src/words.rs b/crates/cli/src/words.rs index 832fa01..4743001 100644 --- a/crates/cli/src/words.rs +++ b/crates/cli/src/words.rs @@ -1,12 +1,12 @@ use inquire::{Confirm, Editor, Select, Text}; +use mihi::{Category, Gender, Language, Word}; use std::vec::IntoIter; -use mihi::{create_word, delete_word, select_enunciated, Category, Gender, Language, Word}; - static NEW_MESSAGE: &str = "New word"; static NEXT_MESSAGE: &str = "Skip this one!"; static QUIT_MESSAGE: &str = "Quit!"; +// Documentation text which is prepended to editing flags. static FLAGS_TEXT: &str = r#"# Write a JSON blob with the following allowed keys. # # => Boolean @@ -50,11 +50,9 @@ static FLAGS_TEXT: &str = r#"# Write a JSON blob with the following allowed keys # } # } # } - -{ -} "#; +// Show the help message. fn help(msg: Option<&str>) { if msg.is_some() { println!("{}.\n", msg.unwrap()); @@ -74,16 +72,9 @@ fn help(msg: Option<&str>) { println!(" show\t\t\tShow information from a word."); } -#[derive(Default)] -struct Guess { - particle: String, - category: Category, - inflection_id: usize, - gender: Gender, - kind: String, -} - -fn get_initial_guess(value: &str) -> Guess { +// Given an enunciated value, try to guess a word from it. If that's not +// possible then an empty word is given. +fn get_initial_guess(value: &str) -> Word { let parts = value.trim().split(',').collect::<Vec<_>>(); if parts.len() == 2 { @@ -91,73 +82,81 @@ fn get_initial_guess(value: &str) -> Guess { let second = parts.last().unwrap(); if first.ends_with('a') && second.ends_with("ae") { - return Guess { - particle: first[0..first.len() - 1].to_string(), - category: Category::Noun, - inflection_id: 1, - gender: Gender::Feminine, - kind: "a".to_string(), - }; + return Word::from( + first[0..first.len() - 1].to_string(), + Category::Noun, + Some(1), + None, + Gender::Feminine, + "a".to_string(), + ); } else if first.ends_with("us") && second.ends_with("ī") { - return Guess { - particle: first[0..first.len() - 2].to_string(), - category: Category::Noun, - inflection_id: 2, - gender: Gender::Masculine, - kind: "us".to_string(), - }; + return Word::from( + first[0..first.len() - 2].to_string(), + Category::Noun, + Some(2), + None, + Gender::Masculine, + "us".to_string(), + ); } else if first.ends_with("um") && second.ends_with("ī") { - return Guess { - particle: first[0..first.len() - 2].to_string(), - category: Category::Noun, - inflection_id: 2, - gender: Gender::Neuter, - kind: "um".to_string(), - }; + return Word::from( + first[0..first.len() - 2].to_string(), + Category::Noun, + Some(2), + None, + Gender::Neuter, + "um".to_string(), + ); } else if first.ends_with("us") && second.ends_with("ūs") { - return Guess { - particle: first[0..first.len() - 2].to_string(), - category: Category::Noun, - inflection_id: 4, - gender: Gender::Masculine, - kind: "fus".to_string(), - }; + return Word::from( + first[0..first.len() - 2].to_string(), + Category::Noun, + Some(4), + None, + Gender::Masculine, + "fus".to_string(), + ); } else if first.ends_with("ū") && second.ends_with("ūs") { - return Guess { - particle: first[0..first.len() - 1].to_string(), - category: Category::Noun, - inflection_id: 4, - gender: Gender::Masculine, - kind: "fus".to_string(), - }; + return Word::from( + first[0..first.len() - 1].to_string(), + Category::Noun, + Some(4), + None, + Gender::Masculine, + "fus".to_string(), + ); } else if first.ends_with("iēs") && second.ends_with("ēī") { - return Guess { - particle: first[0..first.len() - 3].to_string(), - category: Category::Noun, - inflection_id: 5, - gender: Gender::Masculine, - kind: "ies".to_string(), - }; + return Word::from( + first[0..first.len() - 3].to_string(), + Category::Noun, + Some(5), + None, + Gender::Masculine, + "ies".to_string(), + ); } else if first.ends_with("ēs") && second.ends_with("eī") { - return Guess { - particle: first[0..first.len() - 2].to_string(), - category: Category::Noun, - inflection_id: 5, - gender: Gender::Masculine, - kind: "es".to_string(), - }; + return Word::from( + first[0..first.len() - 2].to_string(), + Category::Noun, + Some(5), + None, + Gender::Masculine, + "es".to_string(), + ); } else if second.ends_with("is") { - return Guess { - particle: second[0..second.len() - 2].to_string(), - category: Category::Noun, - inflection_id: 5, - gender: Gender::Masculine, - kind: "es".to_string(), - }; + return Word::from( + second[0..second.len() - 2].to_string(), + Category::Noun, + Some(5), + None, + Gender::Masculine, + "es".to_string(), + ); } } - Guess::default() + Word::default() } // Remove comments from the "flags" text that was provided. @@ -175,11 +174,24 @@ fn trim_flags(given: String) -> String { res } -fn do_create(enunciated: String) -> Result<(), String> { - let guess = get_initial_guess(enunciated.as_str()); +// Get the translation from `word.translated` which matches the given language +// `key`. If that cannot be found, or for some reason is not a String, then an +// error is returned. +fn get_translated<'a>(word: &'a Word, key: &'a str) -> Result<&'a String, String> { + match word.translation.get(key) { + Some(value) => match value { + serde_json::Value::String(s) => Ok(s), + _ => Err("unexpected key type".to_string()), + }, + None => Err("key does not exist".to_string()), + } +} +// Interactively ask the user to provide information for a word by the given +// `enunciated`. The default values will be based on the given `word` parameter. +fn ask_for_word_based_on(enunciated: String, word: Word) -> Result<Word, String> { let Ok(particle) = Text::new("Particle:") - .with_initial_value(&guess.particle) + .with_initial_value(&word.particle) .prompt() else { return Err("abort!".to_string()); @@ -198,7 +210,7 @@ fn do_create(enunciated: String) -> Result<(), String> { Category::Determiner, ]; let Ok(category) = Select::new("Category:", categories) - .with_starting_cursor(guess.category as usize) + .with_starting_cursor(word.category as usize) .prompt() else { return Err("abort!".to_string()); @@ -214,7 +226,7 @@ fn do_create(enunciated: String) -> Result<(), String> { let gender = match category { Category::Noun => { match Select::new("Gender:", genders) - .with_starting_cursor(guess.gender as usize) + .with_starting_cursor(word.gender as usize) .prompt() { Ok(selection) => selection, @@ -224,8 +236,12 @@ fn do_create(enunciated: String) -> Result<(), String> { _ => Gender::None, }; + let Ok(kind) = Text::new("Kind:").with_initial_value(&word.kind).prompt() else { + return Err("abort!".to_string()); + }; + let Ok(inflection) = Text::new("Inflection:") - .with_initial_value(&guess.inflection_id.to_string()) + .with_initial_value(word.inflection_id().to_string().as_str()) .prompt() else { return Err("abort!".to_string()); @@ -234,35 +250,42 @@ fn do_create(enunciated: String) -> Result<(), String> { return Err(format!("bad value for inflection ID '{inflection}'")); }; - let Ok(kind) = Text::new("Kind:").with_initial_value(&guess.kind).prompt() else { - return Err("abort!".to_string()); - }; - - let Ok(regular) = Confirm::new("Regular:").with_default(true).prompt() else { + let Ok(regular) = Confirm::new("Regular:").with_default(word.regular).prompt() else { return Err("abort!".to_string()); }; - let Ok(locative) = Confirm::new("Locative:").with_default(false).prompt() else { + let Ok(locative) = Confirm::new("Locative:") + .with_default(word.locative) + .prompt() + else { return Err("abort!".to_string()); }; + let raw_flags = serde_json::to_string(&word.flags).unwrap(); + let Ok(flags) = Editor::new("Flags:") - .with_predefined_text(FLAGS_TEXT) + .with_predefined_text(format!("{FLAGS_TEXT}\n{raw_flags}").as_str()) .prompt() else { return Err("abort!".to_string()); }; let trimmed_flags = trim_flags(flags); - let Ok(translation_en) = Text::new("Translation (english):").prompt() else { + let Ok(translation_en) = Text::new("Translation (english):") + .with_initial_value(get_translated(&word, "en").unwrap_or(&String::from(""))) + .prompt() + else { return Err("abort!".to_string()); }; - let Ok(translation_ca) = Text::new("Translation (catalan):").prompt() else { + let Ok(translation_ca) = Text::new("Translation (catalan):") + .with_initial_value(get_translated(&word, "ca").unwrap_or(&String::from(""))) + .prompt() + else { return Err("abort!".to_string()); }; - let word = Word { - id: 0, - enunciated: enunciated.clone(), + Ok(Word { + id: word.id, + enunciated, particle, language: Language::Latin, declension_id: if matches!(category, Category::Verb) { @@ -293,9 +316,17 @@ fn do_create(enunciated: String) -> Result<(), String> { flags: serde_json::from_str(&trimmed_flags).unwrap(), succeeded: 0, steps: 0, - }; + }) +} + +// Interactively ask the user for the given `enunciated`, build up a Word object +// from it, and insert it into the database. +fn do_create(enunciated: String) -> Result<(), String> { + let mut guess = get_initial_guess(enunciated.as_str()); + guess.enunciated = enunciated.clone(); - match create_word(word) { + let word = ask_for_word_based_on(enunciated.clone(), guess)?; + match mihi::create_word(word) { Ok(_) => { println!("Word '{enunciated}' has been successfully created!"); Ok(()) @@ -323,7 +354,7 @@ fn create(args: IntoIter<String>) -> i32 { // Now we try to fetch whether the word already existed, by doing a // general search on the database. - let mut words = match select_enunciated(Some(enunciated.clone())) { + let mut words = match mihi::select_enunciated(Some(enunciated.clone())) { Ok(words) => words, Err(e) => { println!("error: words: {e}"); @@ -362,13 +393,14 @@ fn create(args: IntoIter<String>) -> i32 { } } +// TODO: accept a --raw flag, which is implied on pipe fn ls(mut args: IntoIter<String>) -> i32 { if args.len() > 1 { help(Some("error: words: too many filters")); return 1; } - let words = match select_enunciated(args.next()) { + let words = match mihi::select_enunciated(args.next()) { Ok(words) => words, Err(e) => { println!("error: words: {e}"); @@ -387,7 +419,7 @@ fn ls(mut args: IntoIter<String>) -> i32 { // multiple words match the same search parameter, then the user is asked to // select one from a list of candidates. fn select_single_word(search: Option<String>) -> Result<String, String> { - let words = select_enunciated(search)?; + let words = mihi::select_enunciated(search)?; match words.len() { 0 => Err("not found".to_string()), @@ -402,8 +434,63 @@ fn select_single_word(search: Option<String>) -> Result<String, String> { } } -fn edit(mut _args: IntoIter<String>) -> i32 { - 0 +fn edit(mut args: IntoIter<String>) -> i32 { + if args.len() > 1 { + help(Some( + "error: words: only one argument. If it's an enunciate, wrap it in double quotes", + )); + return 1; + } + + // Only one word can be modified at a time. + let enunciated = match select_single_word(args.next()) { + Ok(word) => word, + Err(e) => { + println!("error: words: {e}"); + return 1; + } + }; + + // Fetch the word object for it which will serve as the initial values. + let word = match mihi::find_by(enunciated.as_str()) { + Ok(word) => word, + Err(e) => { + println!("error: words: {e}"); + return 1; + } + }; + + // 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:") + .with_initial_value(&word.enunciated) + .prompt() + else { + return 1; + }; + if enunciated.trim().is_empty() { + return 0; + } + + // And ask again column by column to check for changes. + let updated = match ask_for_word_based_on(enunciated.clone(), word) { + Ok(word) => word, + Err(e) => { + println!("error: words: {e}"); + return 1; + } + }; + + match mihi::update_word(updated) { + Ok(_) => { + println!("Word '{enunciated}' has been updated!"); + 0 + } + Err(e) => { + println!("error: words: {e}"); + 1 + } + } } fn show(mut _args: IntoIter<String>) -> i32 { @@ -431,7 +518,7 @@ fn rm(mut args: IntoIter<String>) -> i32 { .prompt(); match ans { - Ok(true) => match delete_word(&selection) { + Ok(true) => match mihi::delete_word(&selection) { Ok(_) => println!("Removed '{selection}' from the database!"), Err(e) => { println!("error: words: {e}"); diff --git a/lib/mihi/src/lib.rs b/lib/mihi/src/lib.rs index 54efcf9..95cb6e6 100644 --- a/lib/mihi/src/lib.rs +++ b/lib/mihi/src/lib.rs @@ -8,8 +8,7 @@ use rusqlite::{params, Connection}; mod migrate; -#[derive(Debug)] -#[derive(Default)] +#[derive(Clone, Copy, Debug, Default)] pub enum Category { #[default] Unknown = 0, @@ -41,7 +40,6 @@ impl std::fmt::Display for Category { } } - impl TryFrom<usize> for Category { type Error = &'static str; @@ -62,8 +60,7 @@ impl TryFrom<usize> for Category { } } -#[derive(Debug)] -#[derive(Default)] +#[derive(Clone, Copy, Debug, Default)] pub enum Gender { Masculine = 0, Feminine, @@ -100,9 +97,7 @@ impl std::fmt::Display for Gender { } } - -#[derive(Debug)] -#[derive(Default)] +#[derive(Clone, Debug, Default)] pub enum Language { #[default] Unknown = 0, @@ -130,7 +125,6 @@ impl std::fmt::Display for Language { } } - /// Returns the configuration path for the application, and it even creates it /// if it doesn't exist already. pub fn get_config_path() -> Result<PathBuf, String> { @@ -183,11 +177,7 @@ pub fn init_database() -> Result<(), String> { let path = get_config_path()?.join("database.sqlite3"); let conn = match Connection::open(path) { Ok(handle) => handle, - Err(e) => { - return Err(format!( - "could not initialize the database: {e}" - )) - } + Err(e) => return Err(format!("could not initialize the database: {e}")), }; match migrate::init(conn) { @@ -196,7 +186,7 @@ pub fn init_database() -> Result<(), String> { } } -#[derive(Debug)] +#[derive(Clone, Debug, Default)] pub struct Word { pub id: i32, pub enunciated: String, @@ -216,10 +206,59 @@ pub struct Word { pub steps: usize, } +impl Word { + pub fn from( + particle: String, + category: Category, + declension_id: Option<usize>, + conjugation_id: Option<usize>, + gender: Gender, + kind: String, + ) -> Word { + Word { + id: 0, + enunciated: "".to_string(), + particle, + category, + declension_id, + conjugation_id, + kind, + regular: true, + locative: false, + gender, + suffix: None, + language: Language::Latin, + translation: serde_json::from_str("{}").unwrap(), + flags: serde_json::from_str("{}").unwrap(), + succeeded: 0, + steps: 0, + } + } + + pub fn inflection_id(&self) -> usize { + if matches!(self.category, Category::Verb) { + return self.conjugation_id.unwrap(); + } + self.declension_id.unwrap() + } +} + const DECLENSIONS_WITH_KINDS: &[&[&str]] = &[ &["a"], &["us", "um", "ius", "er/ir"], - &["is", "istem", "pureistem", "one", "onenonistem", "two", "three", "visvis", "sussuis", "bosbovis", "iuppiteriovis"], + &[ + "is", + "istem", + "pureistem", + "one", + "onenonistem", + "two", + "three", + "visvis", + "sussuis", + "bosbovis", + "iuppiteriovis", + ], &["fus", "domusdomus"], &["ies", "es"], &["indeclinable"], @@ -228,36 +267,59 @@ const DECLENSIONS_WITH_KINDS: &[&[&str]] = &[ const ADJECTIVE_KINDS: &[&[&str]] = &[ &["us", "er/ir"], &[], - &["one", "onenonistem", "two", "three", "unusnauta", "unusnautaer/ir", "duo", "tres", "mille"], + &[ + "one", + "onenonistem", + "two", + "three", + "unusnauta", + "unusnautaer/ir", + "duo", + "tres", + "mille", + ], ]; /// Creates the given word into the database. pub fn create_word(word: Word) -> Result<(), String> { match word.category { - Category::Noun => { - match word.declension_id { - Some(id @ 1..7) => { - if !DECLENSIONS_WITH_KINDS[id - 1].contains(&word.kind.as_str()) { - return Err(format!("bad kind for declension '{id}'")); - } + Category::Noun => match word.declension_id { + Some(id @ 1..7) => { + if !DECLENSIONS_WITH_KINDS[id - 1].contains(&word.kind.as_str()) { + return Err(format!("bad kind for declension '{id}'")); } - Some(val) => return Err(format!("the declension ID '{val}' is not valid for nouns")), - None => return Err(String::from("you have to provide the declension ID for this noun")), + } + Some(val) => return Err(format!("the declension ID '{val}' is not valid for nouns")), + None => { + return Err(String::from( + "you have to provide the declension ID for this noun", + )) } }, - Category::Adjective => { - match word.declension_id { - Some(id @ (1 | 3)) => { - if !ADJECTIVE_KINDS[id - 1].contains(&word.kind.as_str()) { - return Err(format!("bad kind for declension '{id}'")); - } + Category::Adjective => match word.declension_id { + Some(id @ (1 | 3)) => { + if !ADJECTIVE_KINDS[id - 1].contains(&word.kind.as_str()) { + return Err(format!("bad kind for declension '{id}'")); } - Some(val) => return Err(format!("the declension ID '{val}' is not valid for adjectives")), - None => return Err(String::from("you have to provide the declension ID for this adjective")), + } + Some(val) => { + return Err(format!( + "the declension ID '{val}' is not valid for adjectives" + )) + } + None => { + return Err(String::from( + "you have to provide the declension ID for this adjective", + )) } }, // TODO - _ => return Err(format!("you cannot create a word from the '{}' category", word.category)), + _ => { + return Err(format!( + "you cannot create a word from the '{}' category", + word.category + )) + } } let conn = get_connection()?; @@ -273,6 +335,40 @@ pub fn create_word(word: Word) -> Result<(), String> { } } +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()); + } + + let conn = get_connection()?; + + match conn.execute( + "UPDATE words \ + SET enunciated = ?2, particle = ?3, declension_id = ?4, conjugation_id = ?5, \ + kind = ?6, category = ?7, regular = ?8, locative = ?9, gender = ?10, \ + suffix = ?11, flags = ?12, translation = ?13, updated_at = datetime('now') \ + WHERE id = ?1", + params![ + word.id, + word.enunciated, + word.particle, + word.declension_id, + word.conjugation_id, + word.kind, + word.category as usize, + word.regular, + word.locative, + word.gender as usize, + word.suffix, + serde_json::to_string(&word.flags).unwrap(), + serde_json::to_string(&word.translation).unwrap() + ], + ) { + Ok(_) => Ok(()), + Err(e) => Err(format!("could not update '{}': {}", word.enunciated, e)), + } +} + pub fn select_enunciated(filter: Option<String>) -> Result<Vec<String>, String> { let conn = get_connection()?; @@ -301,6 +397,45 @@ pub fn select_enunciated(filter: Option<String>) -> Result<Vec<String>, String> Ok(res) } +pub fn find_by(enunciated: &str) -> Result<Word, String> { + let conn = get_connection()?; + let mut stmt = conn + .prepare( + "SELECT id, enunciated, particle, language_id, declension_id, conjugation_id, \ + kind, category, regular, locative, gender, suffix, translation, \ + succeeded, steps, flags \ + FROM words \ + WHERE enunciated = ?1", + ) + .unwrap(); + let mut it = stmt.query([enunciated]).unwrap(); + + match it.next() { + Err(_) => Err("no words were found with this enunciate".to_string()), + Ok(rows) => match rows { + Some(row) => Ok(Word { + id: row.get(0).unwrap(), + enunciated: row.get(1).unwrap(), + particle: row.get(2).unwrap(), + language: row.get::<usize, usize>(3).unwrap().try_into()?, + declension_id: row.get(4).unwrap(), + conjugation_id: row.get(5).unwrap(), + kind: row.get(6).unwrap(), + category: row.get::<usize, usize>(7).unwrap().try_into()?, + regular: row.get(8).unwrap(), + locative: row.get(9).unwrap(), + gender: row.get::<usize, usize>(10).unwrap().try_into()?, + suffix: row.get(11).unwrap(), + translation: serde_json::from_str(&row.get::<usize, String>(12).unwrap()).unwrap(), + succeeded: row.get(13).unwrap(), + steps: row.get(14).unwrap(), + flags: serde_json::from_str(&row.get::<usize, String>(15).unwrap()).unwrap(), + }), + None => Err("no words were found with this enunciate".to_string()), + }, + } +} + pub fn select_random_words(category: Category, number: usize) -> Result<Vec<Word>, String> { let conn = get_connection()?; let mut stmt = conn @@ -334,7 +469,7 @@ pub fn select_random_words(category: Category, number: usize) -> Result<Vec<Word translation: serde_json::from_str(&row.get::<usize, String>(12).unwrap()).unwrap(), succeeded: row.get(13).unwrap(), steps: row.get(14).unwrap(), - flags: serde_json::from_str("").unwrap(), + flags: serde_json::from_str("{}").unwrap(), }); } Ok(res) @@ -370,11 +505,8 @@ fn get_connection() -> Result<rusqlite::Connection, String> { let path = get_config_path()?.join("database.sqlite3"); match Connection::open(path) { Ok(handle) => Ok(handle), - Err(_) => { - Err( - "could not fetch the database. Ensure that you have called 'init' first" - .to_string(), - ) - } + Err(_) => Err( + "could not fetch the database. Ensure that you have called 'init' first".to_string(), + ), } } |
