use crate::inflection::print_full_inflection_for; use crate::locale::current_locale; use inquire::{Confirm, Editor, Select, Text}; use mihi::{ adverb, comparative, joint_related_words, select_related_words, superlative, Category, Gender, Language, RelationKind, Word, }; use std::vec::IntoIter; 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 # # deponent: This is a Latin deponent verb. # onlysingular: It only has singular forms. # onlyplural: It only has plural forms. # contracted_root: The root contracts for certain forms (e.g. '_liber_' vs '_libr_ī'). # nonpositive: This is a non-positive word. # compsup_prefix: Comparative and superlative forms require a prefix. # indeclinable: It cannot be declined :-) # irregularsup: The superlative is irregular. # nopassive: Verb has no passive form. # nosupine: Verb has no supine form. # noperfect: Verb has no perfect forms. # nogerundive: Verb has no gerundive. # impersonal: Verb is impersonal (only third person available). # impersonalpassive: Verb is impersonal only on its passive forms. # noimperative: Verb has no imperative forms. # noinfinitive: Verb has no infinitive forms. # shortimperative: The imperative form is a short version. # onlythirdpassive: Verb has only forms on the third person of the passive voice. # enclitic: This is simply an enclitic. # notcomparable: There cannot be a comparable version for this word # onlyperfect: Only perfect forms are available. # semideponent: This is a Latin semi-deponent verb. # contracted_vocative The vocative contracts the root by one character. # # => More complex flags # # adds: There are some cases that are to be added to existing ones. # sets: There are some cases which need to replace the existing ones. # # For example: # # { # "onlysingular": true, # "sets": { # "accusative": { # "singular": ["im"] # } # } # } "#; // Show the help message. fn help(msg: Option<&str>) { if let Some(msg) = msg { println!("{}.\n", msg); } println!("mihi words: Manage words.\n"); println!("usage: mihi words [OPTIONS] \n"); println!("Options:"); println!(" -h, --help\t\tPrint this message."); println!("\nSubcommands:"); println!(" create\t\tCreate a new word."); println!(" edit\t\t\tEdit information from a word."); println!(" ls\t\t\tList the words from the database."); println!(" poke\t\t\tUpdate the timestamp for a word."); println!(" rm\t\t\tRemove a word from the database."); println!(" show\t\t\tShow information from a word."); } // 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::>(); if parts.len() == 2 { let first = parts.first().unwrap(); let second = parts.last().unwrap(); if first.ends_with('a') && second.ends_with("ae") { 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 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 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 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 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 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 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 Word::from( second[0..second.len() - 2].to_string(), Category::Noun, Some(3), None, Gender::Masculine, "is".to_string(), ); } } Word::from( value.to_string(), Category::Unknown, None, None, Gender::None, String::from("-"), ) } // Remove comments from the "flags" text that was provided. fn trim_flags(given: String) -> String { let mut res = String::new(); for line in given.lines() { let trimmed = line.trim(); if !trimmed.starts_with('#') { res.push_str(trimmed); } } res } // 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 { let Ok(particle) = Text::new("Particle:") .with_initial_value(&word.particle) .prompt() else { return Err("abort!".to_string()); }; let particle = particle.trim().to_string(); let categories = vec![ Category::Unknown, Category::Noun, Category::Adjective, Category::Verb, Category::Pronoun, Category::Adverb, Category::Preposition, Category::Conjunction, Category::Interjection, Category::Determiner, ]; let Ok(category) = Select::new("Category:", categories) .with_starting_cursor((word.category as isize).try_into().unwrap()) .prompt() else { return Err("abort!".to_string()); }; let genders = vec![ Gender::Masculine, Gender::Feminine, Gender::MasculineOrFeminine, Gender::Neuter, Gender::None, ]; let gender = match category { Category::Noun => { match Select::new("Gender:", genders) .with_starting_cursor((word.gender as isize).try_into().unwrap()) .prompt() { Ok(selection) => selection, Err(_) => return Err("abort!".to_string()), } } _ => Gender::None, }; let inflection_id = match category { Category::Noun | Category::Adjective | Category::Verb => { let Ok(inflection) = Text::new("Inflection:") .with_initial_value(word.inflection_id().unwrap_or(0).to_string().as_str()) .prompt() else { return Err("abort!".to_string()); }; let Ok(inflection_id) = inflection.parse::() else { return Err(format!("bad value for inflection ID '{inflection}'")); }; Some(inflection_id) } _ => None, }; // TODO: refine guess once the inflection is known: select from possible values. let kind = match category { Category::Noun | Category::Adjective => { let Ok(kind) = Text::new("Kind:").with_initial_value(&word.kind).prompt() else { return Err("abort!".to_string()); }; kind.trim().to_string() } Category::Verb => String::from("verb"), _ => String::from("-"), }; let regular = if matches!( category, Category::Noun | Category::Adjective | Category::Verb ) { let Ok(regular) = Confirm::new("Regular:").with_default(word.regular).prompt() else { return Err("abort!".to_string()); }; regular } else { true }; let locative = if matches!(category, Category::Noun) { let Ok(locative) = Confirm::new("Locative:") .with_default(word.locative) .prompt() else { return Err("abort!".to_string()); }; locative } else { false }; let Ok(raw_weight) = Text::new("Weight:") .with_initial_value(word.weight.to_string().as_str()) .prompt() else { return Err("abort!".to_string()); }; let Ok(weight) = raw_weight.parse::() else { return Err(format!( "bad value for inflection ID '{}'", inflection_id.unwrap_or(0) )); }; if weight > 10 { return Err(format!( "weight has to be an integer between 0 and 10, but {weight} was given" )); } let raw_flags = serde_json::to_string(&word.flags).unwrap(); let Ok(flags) = Editor::new("Flags:") .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):") .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):") .with_initial_value(get_translated(&word, "ca").unwrap_or(&String::from(""))) .prompt() else { return Err("abort!".to_string()); }; Ok(Word { id: word.id, enunciated, particle, language: Language::Latin, declension_id: if matches!(category, Category::Verb) { None } else { inflection_id }, conjugation_id: if matches!(category, Category::Verb) { inflection_id } else { None }, kind, category, regular, locative, gender, suffix: None, translation: serde_json::from_str( format!( "{{\"en\":\"{}\", \"ca\":\"{}\"}}", translation_en.trim(), translation_ca.trim() ) .as_str(), ) .unwrap(), flags: serde_json::from_str(&trimmed_flags).unwrap(), succeeded: 0, steps: 0, weight, }) } // 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.trim().to_string(); let word = ask_for_word_based_on(enunciated.clone(), guess)?; match mihi::create_word(word) { Ok(_) => { println!("Word '{enunciated}' has been successfully created!"); Ok(()) } Err(e) => Err(e), } } fn create(args: IntoIter) -> i32 { if args.len() > 0 { help(Some( "error: words: no arguments were expected for this command", )); return 1; } loop { // Grab the enunciate from the word that we want to create. let Ok(enunciated) = Text::new("Enunciated:").prompt() else { return 1; }; if enunciated.trim().is_empty() { return 0; } // Now we try to fetch whether the word already existed, by doing a // general search on the database. let mut words = match mihi::select_enunciated(Some(enunciated.clone())) { Ok(words) => words, Err(e) => { println!("error: words: {e}"); return 1; } }; words.push(NEW_MESSAGE.to_string()); words.push(NEXT_MESSAGE.to_string()); words.push(QUIT_MESSAGE.to_string()); match words.len() { // Seems confusing, but we fill the "words" list with three default // "messages" which are part of the interface. Hence, if only three // "words" exist, then it's just the interface and we can go right // into creating the word. 3 => { if let Err(e) = do_create(enunciated) { println!("error: words: {e}"); return 1; } } _ => match Select::new("Is your word on this list?", words).prompt() { Ok(choice) => { if choice == QUIT_MESSAGE { return 0; } else if choice == NEW_MESSAGE { if let Err(e) = do_create(enunciated) { println!("error: words: {e}"); return 1; } } } Err(_) => return 1, }, }; } } fn ls(mut args: IntoIter) -> i32 { if args.len() > 1 { help(Some("error: words: too many filters")); return 1; } let words = match mihi::select_enunciated(args.next()) { Ok(words) => words, Err(e) => { println!("error: words: {e}"); return 1; } }; for enunciated in words { println!("{enunciated}"); } 0 } // Given a search parameter, returns the word that match the enunciate. If // 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) -> Result { let words = mihi::select_enunciated(search)?; match words.len() { 0 => Err("not found".to_string()), 1 => Ok(words.first().unwrap().to_owned()), _ => match Select::new("Which word?", words) .with_page_size(20) .prompt() { Ok(choice) => Ok(choice), Err(_) => Err("abort!".to_string()), }, } } fn edit(mut args: IntoIter) -> 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 } } } // Returns a string with a more human-readable declension kind. fn humanize_kind(kind: &str) -> &str { match kind { // Noun "a" => "-a", "us" => "-us", "er/ir" => "-er/-ir", "um" => "-um", "ius" => "-ius; like 'fīlius'", "is" => "-is", "istem" => "i-stem; '-i-' also in the genitive plural", "pureistem" => "pure i-stem; '-i-' also in the ablative singular", "visvis" => "irregular 'vīs, vīs'", "sussuis" => "irregular 'sūs, suis'", "bosbovis" => "irregular 'bōs, bovis'", "iuppiteriovis" => "irregular 'Iuppiter, Iovis'", "fus" => "-u-", "domusdomus" => "irregular 'domus, domūs/domī'", "ies" => "-iēs; like 'diēs, diēī'", "es" => "-ēs; like 'rēs, reī'", "indeclinable" => "indeclinable", // Adjective "one" => "one termination adjective", "onenonistem" => "one termination adjective; non i-stem like 'melior, melius'", "two" => "two termination adjective", "three" => "three termination adjective", "unusnauta" => "'ūnus nauta' like 'ūnus, ūna, ūnum'", "unusnautaer/ir" => "'ūnus nauta' like 'neuter, neutra, neutrum'", "duo" => "number 'duo, duae, duo'", "tres" => "number 'trēs, trēs, tria'", "mille" => "number 'mīlle, mīlle'", // Others "egonos" => "'ego, nōs'", "demonstrative-weak" => "weak demonstrative", "demonstrative-proximal" => "proximal demonstrative", "demonstrative-distal" => "distal demonstrative", "demonstrative-medial" => "medial demonstrative", "demonstrative-idem" => "'īdem, eadem, idem' demonstrative", "tuvos" => "'tū, vōs'", "sesui" => "'sē, suī'", _ => kind, } } fn show_info(word: Word) -> Result<(), String> { // Title. match word.gender { Gender::None => println!("Word: {} ({})", word.enunciated, word.category), _ => println!( "Word: {} ({} {})", word.enunciated, word.gender.abbrev(), word.category ), } // Conjugation, declension + kind. // TODO: to_human match word.conjugation_id { Some(id) => println!("Conjugation: {}", id), None => { if let Some(did) = word.declension_id { if did > 5 { println!("Declension: {}", humanize_kind(&word.kind)); } else { println!( "Declension: {} ({})", word.declension_id.unwrap(), humanize_kind(&word.kind) ); } } } }; // Show relationships with other words. let related = select_related_words(&word)?; if matches!(word.category, Category::Adjective) { print!( "Comparative: {} || ", comparative(&word, &related[RelationKind::Comparative as usize - 1]) ); print!( "Superlative: {} || ", superlative(&word, &related[RelationKind::Superlative as usize - 1]) ); println!( "Adverb: {}", adverb(&word, &related[RelationKind::Adverb as usize - 1]) ); } let alternatives = &related[RelationKind::Alternative as usize - 1]; match alternatives.len() { 0 => {} 1 => println!("Alternative: {}", joint_related_words(alternatives)), _ => println!("Alternatives: {}", joint_related_words(alternatives)), } let gendered = &related[RelationKind::Gendered as usize - 1]; let g = if matches!(word.gender, Gender::Masculine) { "Feminine" } else { "Masculine" }; match gendered.len() { 0 => {} 1 => println!("{g} alternative: {}", joint_related_words(gendered)), _ => println!("{g} alternatives: {}", joint_related_words(gendered)), } // Show translation if available. let locale = current_locale(); if let Some(translation) = word.translation.get(locale.to_code()) { let s = translation.as_str().unwrap_or(""); if !s.is_empty() { println!("Translation ({}): {}.", locale.to_code(), s); } } print_full_inflection_for(word)?; Ok(()) } fn poke(mut args: IntoIter) -> i32 { if args.len() > 1 { help(Some( "error: words: only one argument. If it's an enunciate, wrap it in double quotes", )); return 1; } let enunciated = match select_single_word(args.next()) { Ok(word) => word, Err(e) => { println!("error: words: {e}."); return 1; } }; if mihi::update_timestamp(enunciated.as_str()).is_ok() { 0 } else { 1 } } fn show(mut args: IntoIter) -> i32 { if args.len() > 1 { help(Some( "error: words: only one argument. If it's an enunciate, wrap it in double quotes", )); return 1; } let enunciated = match select_single_word(args.next()) { Ok(word) => word, Err(e) => { println!("error: words: {e}."); return 1; } }; let word = match mihi::find_by(enunciated.as_str()) { Ok(word) => word, Err(e) => { println!("error: words: {e}."); return 1; } }; if let Err(e) = show_info(word) { println!("error: words: {e}."); return 1; } 0 } fn rm(mut args: IntoIter) -> i32 { if args.len() > 1 { help(Some("error: words: too many filters")); return 1; } let selection = match select_single_word(args.next()) { Ok(word) => word, Err(e) => { println!("error: words: {e}"); return 1; } }; let ans = Confirm::new( format!("Do you really want to remove '{selection}' from the database?").as_str(), ) .with_default(false) .prompt(); match ans { Ok(true) => match mihi::delete_word(&selection) { Ok(_) => println!("Removed '{selection}' from the database!"), Err(e) => { println!("error: words: {e}"); return 1; } }, Ok(false) => { println!("Doing nothing..."); } Err(_) => return 1, } 0 } pub fn run(args: Vec) { if args.is_empty() { help(Some( "error: words: 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)); } "edit" => { std::process::exit(edit(it)); } "ls" => { std::process::exit(ls(it)); } "poke" => { std::process::exit(poke(it)); } "rm" => { std::process::exit(rm(it)); } "show" => { std::process::exit(show(it)); } _ => { help(Some( format!("error: words: unknown flag or command '{first}'").as_str(), )); std::process::exit(1); } }, None => { help(Some( "error: words: you need to provide a command" .to_string() .as_str(), )); std::process::exit(1); } } } #[cfg(test)] mod tests { use super::*; // Returns a string with the format "{comparative form}-{superlative // form}-{adverbial form}-{alternatives}-{gendered alternatives}". fn related_for(enunciated: &str) -> String { let word = mihi::find_by(enunciated).unwrap(); let related = select_related_words(&word).unwrap(); let alternatives = &related[RelationKind::Alternative as usize - 1]; let gendered = &related[RelationKind::Gendered as usize - 1]; let first = if matches!(word.category, Category::Adjective) { format!( "{}-{}-{}", comparative(&word, &related[RelationKind::Comparative as usize - 1]), superlative(&word, &related[RelationKind::Superlative as usize - 1]), adverb(&word, &related[RelationKind::Adverb as usize - 1]) ) } else { "--".to_string() }; format!( "{}-{}-{}", first, mihi::joint_related_words(alternatives), mihi::joint_related_words(gendered) ) } #[test] fn related() { assert_eq!( related_for("parvus, parva, parvum"), "minor, minus-minimus, minima, minimum-parvē--" ); assert_eq!( related_for("versō, versāre, versāvī, versātum"), "---vorsō, vorsāre, vorsāvī, vorsātum-" ); assert_eq!(related_for("victor, victōris"), "----victrīx, victrīcis"); } }