From 9310bf5d60810c1f911a41cccfeef3c8563211ef Mon Sep 17 00:00:00 2001 From: Miquel Sabaté Solà Date: Fri, 25 Jul 2025 15:57:35 +0200 Subject: Implement creation of words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miquel Sabaté Solà --- Cargo.lock | 74 +++++++++- crates/cli/Cargo.toml | 4 +- crates/cli/src/words.rs | 352 +++++++++++++++++++++++++++++++++++++++++++++--- lib/mihi/src/lib.rs | 59 ++++++++ 4 files changed, 467 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8130300..3590d94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,6 +47,8 @@ version = "0.1.0" dependencies = [ "inquire", "mihi", + "serde", + "serde_json", ] [[package]] @@ -80,6 +82,16 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" +[[package]] +name = "errno" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -92,6 +104,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "foldhash" version = "0.1.5" @@ -116,6 +134,17 @@ dependencies = [ "byteorder", ] +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "hashbrown" version = "0.15.3" @@ -147,6 +176,7 @@ dependencies = [ "fxhash", "newline-converter", "once_cell", + "tempfile", "unicode-segmentation", "unicode-width", ] @@ -174,6 +204,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "lock_api" version = "0.4.13" @@ -214,7 +250,7 @@ dependencies = [ "libc", "log", "wasi", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -302,6 +338,19 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.9.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + [[package]] name = "ryu" version = "1.0.20" @@ -399,6 +448,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704" +dependencies = [ + "cfg-if", + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "thread_local" version = "1.1.8" @@ -470,6 +533,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-targets" version = "0.48.5" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index b35c369..ab5a406 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -10,4 +10,6 @@ path = "src/main.rs" [dependencies] mihi.workspace = true -inquire = "0.7.5" +inquire = { version = "0.7.5", features = ["editor"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/crates/cli/src/words.rs b/crates/cli/src/words.rs index bdf49b5..4e6e4b6 100644 --- a/crates/cli/src/words.rs +++ b/crates/cli/src/words.rs @@ -1,9 +1,65 @@ -use inquire::{Confirm, Select}; +use inquire::{Confirm, Select, Text, Editor}; use std::vec::IntoIter; -use mihi::{delete_word, select_enunciated}; +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!"; + +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"] +# } +# } +# } + +{ +} +"#; + +fn help(msg: Option<&str>) { + if msg.is_some() { + println!("{}.\n", msg.unwrap()); + } -fn help() { println!("mihi words: Manage words.\n"); println!("usage: mihi words [OPTIONS] \n"); @@ -17,21 +73,278 @@ fn help() { println!(" show\t\t\tShow information from a word."); } -fn create(args: IntoIter) -> i32 { - println!("TBD: {:#?}", args); +#[derive(Default)] +struct Guess { + particle: String, + category: Category, + inflection_id: usize, + gender: Gender, + kind: String, +} - return 0; +fn get_initial_guess(value: &str) -> Guess { + 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 Guess { + particle: first[0..first.len() - 1].to_string(), + category: Category::Noun, + inflection_id: 1, + gender: Gender::Feminine, + kind: "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(), + }; + } 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(), + }; + } 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(), + }; + } 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(), + }; + } 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(), + }; + } 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(), + }; + } 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(), + }; + } + } + + Guess::default() } -fn show(args: IntoIter) -> i32 { - println!("TBD: {:#?}", args); +// Remove comments from the "flags" text that was provided. +fn trim_flags(given: String) -> String { + let mut res = String::new(); - return 0; + for line in given.lines() { + let trimmed = line.trim(); + + if !line.trim().starts_with('#') { + res.push_str(trimmed); + } + } + + res +} + +fn do_create(enunciated: String) -> Result<(), String> { + let guess = get_initial_guess(enunciated.as_str()); + + let Ok(particle) = Text::new("Particle:") + .with_initial_value(&guess.particle) + .prompt() + else { + return Err("abort!".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(guess.category as usize) + .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(guess.gender as usize) + .prompt() + { + Ok(selection) => selection, + Err(_) => return Err("abort!".to_string()), + } + } + _ => Gender::None, + }; + + let Ok(inflection) = Text::new("Inflection:") + .with_initial_value(&guess.inflection_id.to_string()) + .prompt() + else { + return Err("abort!".to_string()); + }; + let Ok(inflection_id) = inflection.parse::() else { + 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 { + return Err("abort!".to_string()); + }; + let Ok(locative) = Confirm::new("Locative:").with_default(false).prompt() else { + return Err("abort!".to_string()); + }; + + let Ok(flags) = Editor::new("Flags:").with_predefined_text(FLAGS_TEXT).prompt() else { + return Err("abort!".to_string()); + }; + let trimmed_flags = trim_flags(flags); + + let Ok(translation_en) = Text::new("Translation (english):").prompt() else { + return Err("abort!".to_string()); + }; + let Ok(translation_ca) = Text::new("Translation (catalan):").prompt() else { + return Err("abort!".to_string()); + }; + + let word = Word{ + id: 0, + enunciated: enunciated.clone(), + particle, + language: Language::Latin, + declension_id: if matches!(category, Category::Verb) { None } else { Some(inflection_id) }, + conjugation_id: if matches!(category, Category::Verb) { Some(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, + }; + + match create_word(word) { + Ok(_) => { + println!("Word '{}' has been successfully created!", enunciated); + 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 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 { - println!("error: words: too many filters"); + help(Some("error: words: too many filters")); return 1; } @@ -43,6 +356,7 @@ fn ls(mut args: IntoIter) -> i32 { } }; + // TODO: not just the enunciated, but being able to edit for enunciated in words { println!("{}", enunciated); } @@ -52,7 +366,7 @@ fn ls(mut args: IntoIter) -> i32 { fn rm(mut args: IntoIter) -> i32 { if args.len() > 1 { - println!("error: words: too many filters"); + help(Some("error: words: too many filters")); return 1; } @@ -105,7 +419,9 @@ fn rm(mut args: IntoIter) -> i32 { pub fn run(args: Vec) { if args.is_empty() { - println!("error: words: you have to provide at least a subcommand"); + help(Some( + "error: words: you have to provide at least a subcommand", + )); std::process::exit(1); } @@ -114,7 +430,7 @@ pub fn run(args: Vec) { while let Some(first) = it.next() { match first.as_str() { "-h" | "--help" => { - help(); + help(None); std::process::exit(0); } "create" => { @@ -126,14 +442,10 @@ pub fn run(args: Vec) { "rm" => { std::process::exit(rm(it)); } - "show" => { - std::process::exit(show(it)); - } _ => { - println!( - "error: words: unknown flag or command: '{}'", - first.as_str() - ); + help(Some( + format!("error: words: unknown flag or command '{}'", first).as_str(), + )); std::process::exit(1); } } diff --git a/lib/mihi/src/lib.rs b/lib/mihi/src/lib.rs index 0a3c2f7..8325af8 100644 --- a/lib/mihi/src/lib.rs +++ b/lib/mihi/src/lib.rs @@ -221,10 +221,68 @@ pub struct Word { pub gender: Gender, pub suffix: Option, pub translation: Value, + pub flags: Value, pub succeeded: usize, pub steps: usize, } +const DECLENSIONS_WITH_KINDS: &[&[&str]] = &[ + &["a"], + &["us", "um", "ius", "er/ir"], + &["is", "istem", "pureistem", "one", "onenonistem", "two", "three", "visvis", "sussuis", "bosbovis", "iuppiteriovis"], + &["fus", "domusdomus"], + &["ies", "es"], + &["indeclinable"], +]; + +const ADJECTIVE_KINDS: &[&[&str]] = &[ + &["us", "er/ir"], + &[], + &["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)); + } + } + Some(val) => return Err(format!("the declension ID '{}' is not valid for nouns", val)), + 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)); + } + } + Some(val) => return Err(format!("the declension ID '{}' is not valid for adjectives", val)), + 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)), + } + + let conn = get_connection()?; + match conn.execute( + "INSERT INTO words (enunciated, particle, language_id, declension_id, conjugation_id, kind, category, regular, locative, gender, suffix, flags, translation, updated_at, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, datetime('now'), datetime('now'))", + params![word.enunciated, word.particle, word.language as usize, + 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 create '{}': {}", word.enunciated, e)), + } +} + pub fn select_enunciated(filter: Option) -> Result, String> { let conn = get_connection()?; @@ -286,6 +344,7 @@ pub fn select_random_words(category: Category, number: usize) -> Result(12).unwrap()).unwrap(), succeeded: row.get(13).unwrap(), steps: row.get(14).unwrap(), + flags: serde_json::from_str("").unwrap(), }); } Ok(res) -- cgit v1.2.3