diff options
| author | Miquel Sabaté Solà <mssola@mssola.com> | 2026-01-13 15:27:05 +0100 |
|---|---|---|
| committer | Miquel Sabaté Solà <mssola@mssola.com> | 2026-01-13 15:27:05 +0100 |
| commit | 43632164dcacb3424ccb9ceba76ddfd7586cb59c (patch) | |
| tree | 5c8d42f793326fecef757a7e60eb94ad45c13511 | |
| parent | bb3bbcfd018816a80077a6c1f96f842d2bf5f3a4 (diff) | |
| parent | 472d7fb9d37ec838fc69692cf24c8cb4a68af47a (diff) | |
| download | mihi-43632164dcacb3424ccb9ceba76ddfd7586cb59c.tar.gz mihi-43632164dcacb3424ccb9ceba76ddfd7586cb59c.zip | |
Merge branch 'inflection'
| -rw-r--r-- | .github/workflows/ci.yml | 7 | ||||
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | crates/cli/src/inflection.rs | 336 | ||||
| -rw-r--r-- | crates/cli/src/locale.rs | 38 | ||||
| -rw-r--r-- | crates/cli/src/main.rs | 2 | ||||
| -rw-r--r-- | crates/cli/src/run.rs | 39 | ||||
| -rw-r--r-- | crates/cli/src/words.rs | 216 | ||||
| -rw-r--r-- | lib/mihi/src/lib.rs | 388 | ||||
| -rw-r--r-- | lib/mihi/src/migrate.rs | 12 | ||||
| -rw-r--r-- | testdata/test.sqlite3 | bin | 0 -> 2916352 bytes |
10 files changed, 961 insertions, 79 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2ec6aa..3e4d8b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,12 @@ jobs: rustup default stable - name: Run tests - run: cargo test --verbose + env: + MIHI_DATABASE: test.sqlite3 + run: | + mkdir -p $HOME/.config/mihi + cp testdata/test.sqlite3 $HOME/.config/mihi/ + cargo test --verbose - name: Run Clippy run: cargo clippy --all-targets --all-features @@ -2,6 +2,8 @@ A self assessment tool for learning languages. **UNDER CONSTRUCTION** +Set the `MIHI_DATABASE` environment variable to `test.sqlite3`. + ## License This repository holds two licenses, as you can also note on the `Cargo.toml` diff --git a/crates/cli/src/inflection.rs b/crates/cli/src/inflection.rs new file mode 100644 index 0000000..f42d1e8 --- /dev/null +++ b/crates/cli/src/inflection.rs @@ -0,0 +1,336 @@ +use mihi::{group_declension_inflections, Category, DeclensionInfo, DeclensionTable, Gender, Word}; + +fn get_inflected_from(word: &Word, row: &[DeclensionInfo; 2]) -> String { + if word.is_flag_set("onlysingular") { + row[0].inflected.join("/") + } else if word.is_flag_set("onlyplural") { + row[1].inflected.join("/") + } else { + format!( + "{}, {}", + row[0].inflected.join("/"), + row[1].inflected.join("/") + ) + } +} + +fn get_noun_table(word: &Word) -> Result<DeclensionTable, String> { + let gender = match word.gender { + Gender::MasculineOrFeminine => Gender::Masculine as usize, + _ => word.gender as usize, + }; + group_declension_inflections(word, &word.kind, gender) +} + +fn print_noun_inflection(word: &Word) -> Result<(), String> { + let table = get_noun_table(word)?; + + println!("\n== Inflection ==\n"); + + println!( + "Nominative:\t{}", + get_inflected_from(&word, &table.nominative) + ); + println!("Vocative:\t{}", get_inflected_from(&word, &table.vocative)); + println!( + "Accusative:\t{}", + get_inflected_from(&word, &table.accusative) + ); + println!("Genitive:\t{}", get_inflected_from(&word, &table.genitive)); + println!("Dative:\t\t{}", get_inflected_from(&word, &table.dative)); + println!("Ablative:\t{}", get_inflected_from(&word, &table.ablative)); + if word.locative { + println!("Locative:\t{}", get_inflected_from(&word, &table.locative)); + } + + Ok(()) +} + +fn get_adjective_table(word: &Word) -> Result<[DeclensionTable; 3], String> { + // Unless the word is a special "unus nauta" variant, force 1/2 declension + // adjectives in the feminine to grab the "a" kind. + let kind_f = if word.kind.as_str() == "unusnauta" { + &word.kind + } else { + match word.declension_id { + Some(1 | 2) => &"a".to_string(), + _ => &word.kind, + } + }; + + let kind_n = if word.kind == "us" { + &"um".to_owned() + } else { + &word.kind + }; + + Ok([ + group_declension_inflections(word, &word.kind, Gender::Masculine as usize)?, + group_declension_inflections(word, kind_f, Gender::Feminine as usize)?, + group_declension_inflections(word, kind_n, Gender::Neuter as usize)?, + ]) +} + +fn print_adjective_inflection(word: &Word) -> Result<(), String> { + let tables = get_adjective_table(word)?; + + println!("\n== Inflection ==\n"); + + println!( + "Nominative:\t{} | {} | {}", + get_inflected_from(&word, &tables[0].nominative), + get_inflected_from(&word, &tables[1].nominative), + get_inflected_from(&word, &tables[2].nominative) + ); + println!( + "Vocative:\t{} | {} | {}", + get_inflected_from(&word, &tables[0].vocative), + get_inflected_from(&word, &tables[1].vocative), + get_inflected_from(&word, &tables[2].vocative) + ); + println!( + "Accusative:\t{} | {} | {}", + get_inflected_from(&word, &tables[0].accusative), + get_inflected_from(&word, &tables[1].accusative), + get_inflected_from(&word, &tables[2].accusative) + ); + println!( + "Genitive:\t{} | {} | {}", + get_inflected_from(&word, &tables[0].genitive), + get_inflected_from(&word, &tables[1].genitive), + get_inflected_from(&word, &tables[2].genitive) + ); + println!( + "Dative:\t\t{} | {} | {}", + get_inflected_from(&word, &tables[0].dative), + get_inflected_from(&word, &tables[1].dative), + get_inflected_from(&word, &tables[2].dative) + ); + println!( + "Ablative:\t{} | {} | {}", + get_inflected_from(&word, &tables[0].ablative), + get_inflected_from(&word, &tables[1].ablative), + get_inflected_from(&word, &tables[2].ablative) + ); + if word.locative { + println!( + "Locative:\t{} | {} | {}", + get_inflected_from(&word, &tables[0].locative), + get_inflected_from(&word, &tables[1].locative), + get_inflected_from(&word, &tables[2].locative) + ); + } + + Ok(()) +} + +pub fn print_full_inflection_for(word: Word) -> Result<(), String> { + if word.is_flag_set("indeclinable") { + return Ok(()); + } + + match word.category { + Category::Noun => print_noun_inflection(&word)?, + Category::Adjective => print_adjective_inflection(&word)?, + Category::Verb => {} // TODO + Category::Pronoun => {} // TODO + Category::Adverb + | Category::Preposition + | Category::Conjunction + | Category::Interjection + | Category::Determiner + | Category::Unknown => { + // Nothing to do. + } + } + // TODO: on the 'extra' info. + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn get_word(enunciated: &str) -> Word { + let words = mihi::select_enunciated(Some(enunciated.to_string())).unwrap(); + + assert_eq!(words.len(), 1); + + mihi::find_by(words.first().unwrap().as_str()).unwrap() + } + + fn stringify_with(word: &Word, table: &DeclensionTable) -> String { + let mut res = get_inflected_from(&word, &table.nominative); + res.push_str(" | "); + res.push_str(get_inflected_from(&word, &table.vocative).as_str()); + res.push_str(" | "); + res.push_str(get_inflected_from(&word, &table.accusative).as_str()); + res.push_str(" | "); + res.push_str(get_inflected_from(&word, &table.genitive).as_str()); + res.push_str(" | "); + res.push_str(get_inflected_from(&word, &table.dative).as_str()); + res.push_str(" | "); + res.push_str(get_inflected_from(&word, &table.ablative).as_str()); + if word.locative { + res.push_str(" | "); + res.push_str(get_inflected_from(&word, &table.locative).as_str()); + } + + res + } + + fn assert_noun_table(enunciated: &str, expected: &str) { + let word = get_word(enunciated); + let table = get_noun_table(&word).unwrap(); + + let res = stringify_with(&word, &table); + + assert_eq!(res, expected); + } + + fn assert_adjective_table(enunciated: &str, masculine: &str, feminine: &str, neuter: &str) { + let word = get_word(enunciated); + let tables = get_adjective_table(&word).unwrap(); + + let res = stringify_with(&word, &tables[0]); + assert_eq!(res, masculine); + + let res = stringify_with(&word, &tables[1]); + assert_eq!(res, feminine); + + let res = stringify_with(&word, &tables[2]); + assert_eq!(res, neuter); + } + + #[test] + fn test_nouns() { + assert_noun_table( + "rosa, rosae", + "rosa, rosae | rosa, rosae | rosam, rosās | rosae, rosārum | rosae, rosīs | rosā, rosīs", + ); + assert_noun_table( + "fīlia, fīliae", + "fīlia, fīliae | fīlia, fīliae | fīliam, fīliās | fīliae, fīliārum | fīliae, fīliīs/fīliābus | fīliā, fīliīs/fīliābus", + ); + assert_noun_table( + "dea, deae", + "dea, deae | dea, deae | deam, deās | deae, deārum | deae, deābus | deā, deābus", + ); + assert_noun_table( + "Rōma, Rōmae", + "Rōma | Rōma | Rōmam | Rōmae | Rōmae | Rōmā | Rōmae", + ); + assert_noun_table( + "lupus, lupī", + "lupus, lupī | lupe, lupī | lupum, lupōs | lupī, lupōrum | lupō, lupīs | lupō, lupīs", + ); + assert_noun_table( + "templum, templī", + "templum, templa | templum, templa | templum, templa | templī, templōrum | templō, templīs | templō, templīs", + ); + assert_noun_table( + "vir, virī", + "vir, virī | vir, virī | virum, virōs | virī, virōrum | virō, virīs | virō, virīs", + ); + assert_noun_table( + "liber, librī", + "liber, librī | liber, librī | librum, librōs | librī, librōrum | librō, librīs | librō, librīs", + ); + assert_noun_table( + "fīlius, fīliī", + "fīlius, fīliī | fīlī, fīliī | fīlium, fīliōs | fīlī/fīliī, fīliōrum | fīliō, fīliīs | fīliō, fīliīs", + ); + assert_noun_table( + "leō, leōnis", + "leō, leōnēs | leō, leōnēs | leōnem, leōnēs | leōnis, leōnum | leōnī, leōnibus | leōne, leōnibus", + ); + assert_noun_table( + "ovis, ovis", + "ovis, ovēs | ovis, ovēs | ovem, ovēs | ovis, ovium | ovī, ovibus | ove, ovibus", + ); + assert_noun_table( + "mare, maris", + "mare, maria | mare, maria | mare, maria | maris, marium/marum | marī, maribus | marī/mare, maribus", + ); + assert_noun_table( + "Iuppiter, Iovis", + "Iuppiter | Iuppiter | Iovem | Iovis | Iovī | Iove", + ); + assert_noun_table( + "portus, portūs", + "portus, portūs | portus, portūs | portum, portūs | portūs, portuum | portuī, portibus | portū, portibus", + ); + assert_noun_table( + "cornū, cornūs", + "cornū, cornua | cornū, cornua | cornū, cornua | cornūs, cornuum | cornuī, cornibus | cornū, cornibus", + ); + // TODO: domus + assert_noun_table( + "diēs, diēī", + "diēs, diēs | diēs, diēs | diem, diēs | diēī, diērum | diēī, diēbus | diē, diēbus", + ); + assert_noun_table( + "rēs, reī", + "rēs, rēs | rēs, rēs | rem, rēs | reī, rērum | reī, rēbus | rē, rēbus", + ); + } + + #[test] + fn test_adjectives() { + assert_adjective_table( + "novus, nova, novum", + "novus, novī | nove, novī | novum, novōs | novī, novōrum | novō, novīs | novō, novīs", + "nova, novae | nova, novae | novam, novās | novae, novārum | novae, novīs | novā, novīs", + "novum, nova | novum, nova | novum, nova | novī, novōrum | novō, novīs | novō, novīs", + ); + assert_adjective_table( + "pulcher, pulchra, pulchrum", + "pulcher, pulchrī | pulcher, pulchrī | pulchrum, pulchrōs | pulchrī, pulchrōrum | pulchrō, pulchrīs | pulchrō, pulchrīs", + "pulchra, pulchrae | pulchra, pulchrae | pulchram, pulchrās | pulchrae, pulchrārum | pulchrae, pulchrīs | pulchrā, pulchrīs", + "pulcher, pulchra | pulcher, pulchra | pulcher, pulchra | pulchrī, pulchrōrum | pulchrō, pulchrīs | pulchrō, pulchrīs", + ); + assert_adjective_table( + "ūnus, ūna, ūnum", + "ūnus, ūnī | ūne, ūnī | ūnum, ūnōs | ūnīus, ūnōrum | ūnī, ūnīs | ūnō, ūnīs", + "ūna, ūnae | ūna, ūnae | ūnam, ūnās | ūnīus, ūnārum | ūnī, ūnīs | ūnā, ūnīs", + "ūnum, ūna | ūnum, ūna | ūnum, ūna | ūnīus, ūnōrum | ūnī, ūnīs | ūnō, ūnīs", + ); + assert_adjective_table( + "ferōx, ferōx", + "ferōx, ferōcēs | ferōx, ferōcēs | ferōcem, ferōcēs | ferōcis, ferōcium | ferōcī, ferōcibus | ferōcī, ferōcibus", + "ferōx, ferōcēs | ferōx, ferōcēs | ferōcem, ferōcēs | ferōcis, ferōcium | ferōcī, ferōcibus | ferōcī, ferōcibus", + "ferōx, ferōcia | ferōx, ferōcia | ferōx, ferōcia | ferōcis, ferōcium | ferōcī, ferōcibus | ferōcī, ferōcibus", + ); + assert_adjective_table( + "gravis, grave", + "gravis, gravēs | gravis, gravēs | gravem/gravīs, gravēs | gravis, gravium | gravī, gravibus | gravī, gravibus", + "gravis, gravēs | gravis, gravēs | gravem/gravīs, gravēs | gravis, gravium | gravī, gravibus | gravī, gravibus", + "grave, gravia | grave, gravia | grave, gravia | gravis, gravium | gravī, gravibus | gravī, gravibus", + ); + assert_adjective_table( + "celer, celeris, celere", + "celer, celerēs | celer, celerēs | celerem, celerēs | celeris, celerium | celerī, celeribus | celerī, celeribus", + "celeris, celerēs | celeris, celerēs | celerem, celerēs | celeris, celerium | celerī, celeribus | celerī, celeribus", + "celere, celeria | celere, celeria | celere, celeria | celeris, celerium | celerī, celeribus | celerī, celeribus" + ); + assert_adjective_table( + "duo, duae, duo", + "duo | duo | duo/duōs | duōrum | duōbus | duōbus", + "duae | duae | duās | duārum | duābus | duābus", + "duo | duo | duo | duōrum | duōbus | duōbus", + ); + assert_adjective_table( + "trēs, trēs, tria", + "trēs | trēs | trēs/trīs | trium | tribus | tribus", + "trēs | trēs | trēs/trīs | trium | tribus | tribus", + "tria | tria | tria | trium | tribus | tribus", + ); + assert_adjective_table( + "mīlle, mīlle", + "mīlle, mīlia | mīlle, mīlia | mīlle, mīlia | mīlle, mīlium | mīlle, mīlibus | mīlle, mīlibus", + "mīlle, mīlia | mīlle, mīlia | mīlle, mīlia | mīlle, mīlium | mīlle, mīlibus | mīlle, mīlibus", + "mīlle, mīlia | mīlle, mīlia | mīlle, mīlia | mīlle, mīlium | mīlle, mīlibus | mīlle, mīlibus" + ); + } +} diff --git a/crates/cli/src/locale.rs b/crates/cli/src/locale.rs new file mode 100644 index 0000000..f9d42b6 --- /dev/null +++ b/crates/cli/src/locale.rs @@ -0,0 +1,38 @@ +// Locale represents the locales accepted for delivering answers on this +// tool. That is, it's not about i18n on the strings for this application. but +// rather the different translations accepted in places like +// `Word.translations`. +pub enum Locale { + English, + Catalan, +} + +impl Locale { + // Returns the string representation for the locale's code. + pub fn to_code(&self) -> &str { + match self { + Self::English => "en", + Self::Catalan => "ca", + } + } +} + +impl std::fmt::Display for Locale { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::English => write!(f, "english"), + Self::Catalan => write!(f, "català"), + } + } +} + +/// Fetches the Locale object that is suitable for the current environment. +pub fn current_locale() -> Locale { + let raw_locale = std::env::var("LC_ALL").unwrap_or("en".to_string()); + + if raw_locale.starts_with("ca") { + Locale::Catalan + } else { + Locale::English + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index ffbcdae..62ffd45 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,5 +1,7 @@ mod exercises; +mod inflection; mod init; +mod locale; mod nuke; mod run; mod words; diff --git a/crates/cli/src/run.rs b/crates/cli/src/run.rs index 0046d47..10e0721 100644 --- a/crates/cli/src/run.rs +++ b/crates/cli/src/run.rs @@ -6,6 +6,8 @@ use std::io::Write; use std::process::Command; use tempfile::NamedTempFile; +use crate::locale::{current_locale, Locale}; + // Maximum number of times a word has to be run in order to increase the number // of successful runs. const MAX_STEPS: usize = 5; @@ -21,39 +23,11 @@ fn help(msg: Option<&str>) { println!("Options:"); println!(" -c, --category <CATEGORY>\tOnly ask for words on the given <CATEGORY>."); println!(" -e, --exercises\t\tOnly practice with exercises."); - println!(" -f, --flag\t\tFilter words by a boolean flag. Multiple flags can be provided."); + println!(" -f, --flag\t\t\tFilter words by a boolean flag. Multiple flags can be provided."); println!(" -h, --help\t\t\tPrint this message."); println!(" -k, --kind <KIND>\t\tOnly ask for exercises for the given <KIND>."); } -// Locale represents the locales accepted for delivering answers on this -// tool. That is, it's not about i18n on the strings for this application. but -// rather the different translations accepted in places like -// `Word.translations`. -enum Locale { - English, - Catalan, -} - -impl Locale { - // Returns the string representation for the locale's code. - fn to_code(&self) -> &str { - match self { - Self::English => "en", - Self::Catalan => "ca", - } - } -} - -impl std::fmt::Display for Locale { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - Self::English => write!(f, "english"), - Self::Catalan => write!(f, "català"), - } - } -} - // Run the quiz for all the given `words` while expecting answers to be // delivered in the given `locale`. fn run_words(words: Vec<Word>, locale: &Locale) -> i32 { @@ -323,12 +297,7 @@ pub fn run(args: Vec<String>) { } } - let raw_locale = std::env::var("LC_ALL").unwrap_or("en".to_string()); - let locale = if raw_locale.starts_with("ca") { - Locale::Catalan - } else { - Locale::English - }; + let locale = current_locale(); let words = match category { Some(cat) => select_relevant_words(cat, &flags, 15), diff --git a/crates/cli/src/words.rs b/crates/cli/src/words.rs index 7db84a2..23a3946 100644 --- a/crates/cli/src/words.rs +++ b/crates/cli/src/words.rs @@ -1,3 +1,6 @@ +use crate::inflection::print_full_inflection_for; +use crate::locale::current_locale; + use inquire::{Confirm, Editor, Select, Text}; use mihi::{Category, Gender, Language, Word}; use std::vec::IntoIter; @@ -148,15 +151,22 @@ fn get_initial_guess(value: &str) -> Word { return Word::from( second[0..second.len() - 2].to_string(), Category::Noun, - Some(5), + Some(3), None, Gender::Masculine, - "es".to_string(), + "is".to_string(), ); } } - Word::default() + Word::from( + value.to_string(), + Category::Unknown, + None, + None, + Gender::None, + String::from("-"), + ) } // Remove comments from the "flags" text that was provided. @@ -196,6 +206,7 @@ fn ask_for_word_based_on(enunciated: String, word: Word) -> Result<Word, String> else { return Err("abort!".to_string()); }; + let particle = particle.trim().to_string(); let categories = vec![ Category::Unknown, @@ -236,28 +247,56 @@ fn ask_for_word_based_on(enunciated: String, word: Word) -> Result<Word, String> _ => Gender::None, }; - let Ok(kind) = Text::new("Kind:").with_initial_value(&word.kind).prompt() else { - return Err("abort!".to_string()); + 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::<usize>() else { + return Err(format!("bad value for inflection ID '{inflection}'")); + }; + Some(inflection_id) + } + _ => None, }; - let Ok(inflection) = Text::new("Inflection:") - .with_initial_value(word.inflection_id().to_string().as_str()) - .prompt() - else { - return Err("abort!".to_string()); - }; - let Ok(inflection_id) = inflection.parse::<usize>() else { - return Err(format!("bad value for inflection ID '{inflection}'")); + // 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 Ok(regular) = Confirm::new("Regular:").with_default(word.regular).prompt() else { - return Err("abort!".to_string()); + 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 Ok(locative) = Confirm::new("Locative:") - .with_default(word.locative) - .prompt() - else { - return Err("abort!".to_string()); + + 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:") @@ -267,7 +306,10 @@ fn ask_for_word_based_on(enunciated: String, word: Word) -> Result<Word, String> return Err("abort!".to_string()); }; let Ok(weight) = raw_weight.parse::<usize>() else { - return Err(format!("bad value for inflection ID '{inflection}'")); + return Err(format!( + "bad value for inflection ID '{}'", + inflection_id.unwrap_or(0) + )); }; if weight > 10 { return Err(format!( @@ -306,10 +348,10 @@ fn ask_for_word_based_on(enunciated: String, word: Word) -> Result<Word, String> declension_id: if matches!(category, Category::Verb) { None } else { - Some(inflection_id) + inflection_id }, conjugation_id: if matches!(category, Category::Verb) { - Some(inflection_id) + inflection_id } else { None }, @@ -339,7 +381,7 @@ fn ask_for_word_based_on(enunciated: String, word: Word) -> Result<Word, String> // 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(); + guess.enunciated = enunciated.trim().to_string(); let word = ask_for_word_based_on(enunciated.clone(), guess)?; match mihi::create_word(word) { @@ -409,7 +451,6 @@ 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")); @@ -509,8 +550,129 @@ fn edit(mut args: IntoIter<String>) -> i32 { } } -fn show(mut _args: IntoIter<String>) -> i32 { - todo!() +// 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 => match word.declension_id { + Some(did) => { + if did > 5 { + println!("Declension: {}", humanize_kind(&word.kind)); + } else { + println!( + "Declension: {} ({})", + word.declension_id.unwrap(), + humanize_kind(&word.kind) + ); + } + } + None => {} + }, + }; + + // 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 show(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; + } + + 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<String>) -> i32 { diff --git a/lib/mihi/src/lib.rs b/lib/mihi/src/lib.rs index 439e92e..57dc93f 100644 --- a/lib/mihi/src/lib.rs +++ b/lib/mihi/src/lib.rs @@ -70,6 +70,19 @@ pub enum Gender { None, } +impl Gender { + /// Returns a string containing the abbreviation for this gender. + pub fn abbrev(&self) -> &str { + match self { + Self::Masculine => "m.", + Self::Feminine => "f.", + Self::MasculineOrFeminine => "m./f.", + Self::Neuter => "n.", + Self::None => "(genderless)", + } + } +} + impl TryFrom<usize> for Gender { type Error = &'static str; @@ -174,7 +187,8 @@ pub fn add_language(language: String) -> Result<(), String> { /// Ensure that in the config path there is a fully initialized database. pub fn init_database() -> Result<(), String> { - let path = get_config_path()?.join("database.sqlite3"); + let name = &std::env::var("MIHI_DATABASE").unwrap_or("database.sqlite3".to_string()); + let path = get_config_path()?.join(name); let conn = match Connection::open(path) { Ok(handle) => handle, Err(e) => return Err(format!("could not initialize the database: {e}")), @@ -186,7 +200,7 @@ pub fn init_database() -> Result<(), String> { } } -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug)] pub struct Word { pub id: i32, pub enunciated: String, @@ -237,11 +251,25 @@ impl Word { } } - pub fn inflection_id(&self) -> usize { + pub fn inflection_id(&self) -> Option<usize> { if matches!(self.category, Category::Verb) { - return self.conjugation_id.unwrap(); + return Some(self.conjugation_id.unwrap()); + } + self.declension_id + } + + /// Returns whether the given flag is set to true on this word. + pub fn is_flag_set(&self, flag: &str) -> bool { + match self.flags.get(flag) { + Some(value) => { + if let Some(b) = value.as_bool() { + b + } else { + false + } + } + None => false, } - self.declension_id.unwrap() } } @@ -347,8 +375,29 @@ pub fn create_word(word: Word) -> Result<(), String> { )) } }, - // TODO - _ => { + Category::Verb => match word.conjugation_id { + Some(1..6) => { + if word.kind.as_str() != "verb" { + return Err(format!("bad kind for verb")); + } + } + Some(val) => return Err(format!("the conjugation ID '{val}' is not valid")), + None => { + return Err(String::from( + "you have to provide the conjugation ID for this verb", + )) + } + }, + Category::Adverb + | Category::Preposition + | Category::Conjunction + | Category::Interjection + | Category::Determiner => { + if word.declension_id.is_some() || word.conjugation_id.is_some() { + return Err(format!("no inflection allowed for '{}'", word.category)); + } + } + Category::Unknown | Category::Pronoun => { return Err(format!( "you cannot create a word from the '{}' category", word.category @@ -360,17 +409,17 @@ pub fn create_word(word: Word) -> Result<(), String> { match conn.execute( "INSERT INTO words (enunciated, particle, language_id, declension_id, \ conjugation_id, kind, category, regular, locative, \ - gender, suffix, flags, translation, weight, \ + gender, suffix, flags, translation, weight, succeeded, \ updated_at, created_at) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, \ datetime('now'), datetime('now'))", params![ - word.enunciated, - word.particle, + word.enunciated.trim(), + word.particle.trim(), word.language as usize, word.declension_id, word.conjugation_id, - word.kind, + word.kind.trim(), word.category as usize, word.regular, word.locative, @@ -378,7 +427,8 @@ pub fn create_word(word: Word) -> Result<(), String> { word.suffix, serde_json::to_string(&word.flags).unwrap(), serde_json::to_string(&word.translation).unwrap(), - word.weight + word.weight, + 0 ], ) { Ok(_) => Ok(()), @@ -584,7 +634,9 @@ pub fn delete_word(enunciated: &String) -> Result<(), String> { } fn get_connection() -> Result<rusqlite::Connection, String> { - let path = get_config_path()?.join("database.sqlite3"); + let name = &std::env::var("MIHI_DATABASE").unwrap_or("database.sqlite3".to_string()); + let path = get_config_path()?.join(name); + match Connection::open(path) { Ok(handle) => Ok(handle), Err(_) => Err( @@ -812,3 +864,311 @@ pub fn select_relevant_exercises( } Ok(res) } + +#[derive(Debug, Default)] +pub struct DeclensionInfo { + pub inflected: Vec<String>, +} + +#[derive(Debug, Default)] +pub struct DeclensionTable { + pub nominative: [DeclensionInfo; 2], + pub vocative: [DeclensionInfo; 2], + pub accusative: [DeclensionInfo; 2], + pub genitive: [DeclensionInfo; 2], + pub dative: [DeclensionInfo; 2], + pub ablative: [DeclensionInfo; 2], + pub locative: [DeclensionInfo; 2], +} + +impl DeclensionTable { + pub fn consume_blob( + &mut self, + case: usize, + blob: &Value, + word: &Word, + gender: usize, + add: bool, + ) { + if let Some(singular) = blob.get("singular") { + let values = singular.as_array().unwrap(); + for v in values { + let s = v.as_str().unwrap(); + if add { + self.add(word, case, 0, gender, s); + } else { + self.set(word, case, 0, gender, s); + } + } + } + + if let Some(plural) = blob.get("plural") { + let values = plural.as_array().unwrap(); + for v in values { + let s = v.as_str().unwrap(); + if add { + self.add(word, case, 1, gender, s); + } else { + self.set(word, case, 1, gender, s); + } + } + } + } + + pub fn set(&mut self, word: &Word, case: usize, number: usize, gender: usize, term: &str) { + match case { + 0 => { + self.nominative[number].inflected = inflect_from(word, case, number, gender, term); + } + 1 => { + self.vocative[number].inflected = inflect_from(word, case, number, gender, term); + } + 2 => { + self.accusative[number].inflected = inflect_from(word, case, number, gender, term); + } + 3 => { + self.genitive[number].inflected = inflect_from(word, case, number, gender, term); + } + 4 => { + self.dative[number].inflected = inflect_from(word, case, number, gender, term); + } + 5 => { + self.ablative[number].inflected = inflect_from(word, case, number, gender, term); + } + 6 => { + self.locative[number].inflected = inflect_from(word, case, number, gender, term); + } + _ => {} + } + } + + pub fn add(&mut self, word: &Word, case: usize, number: usize, gender: usize, term: &str) { + match case { + 0 => { + self.nominative[number] + .inflected + .append(&mut inflect_from(word, case, number, gender, term)); + } + 1 => { + self.vocative[number] + .inflected + .append(&mut inflect_from(word, case, number, gender, term)); + } + 2 => { + self.accusative[number] + .inflected + .append(&mut inflect_from(word, case, number, gender, term)); + } + 3 => { + self.genitive[number] + .inflected + .append(&mut inflect_from(word, case, number, gender, term)); + } + 4 => { + self.dative[number] + .inflected + .append(&mut inflect_from(word, case, number, gender, term)); + } + 5 => { + self.ablative[number] + .inflected + .append(&mut inflect_from(word, case, number, gender, term)); + } + 6 => { + self.locative[number] + .inflected + .append(&mut inflect_from(word, case, number, gender, term)); + } + _ => {} + } + } +} + +fn contract_root(word: &Word, case: usize, number: usize, gender: usize) -> bool { + // First off, check out that this is a word explicitely marked as to be + // contracted by either the flag or the kind. + if !word.is_flag_set("contracted_root") { + return false; + } + if word.kind != "er/ir" && word.kind != "unusnautaer/ir" { + return false; + } + + // All plurals have to be contracted. + if number == 1 { + return true; + } + + // Nominative/vocative singular are only contracted for feminine nouns. The + // accusative is only not contracted on neuter words. + match case { + 0 | 1 => gender == Gender::Feminine as usize, + 2 => gender != Gender::Neuter as usize, + _ => true, + } +} + +fn should_use_first_root(word: &Word, case: usize, number: usize, gender: usize) -> bool { + // All plurals always follow `word.particle`. + if number == 1 { + return false; + } + + match case { + 0 | 1 => { + word.kind == "is" + || word.kind == "istem" + || word.kind == "pureistem" + || word.kind == "one" + || word.kind == "onenonistem" + } + 2 => { + // Only neuter words should consider this on the accusative. + if gender != 3 { + return false; + } + word.kind == "is" + || word.kind == "istem" + || word.kind == "pureistem" + || word.kind == "one" + || word.kind == "onenonistem" + } + _ => false, + } +} + +fn inflect_from(word: &Word, case: usize, number: usize, gender: usize, term: &str) -> Vec<String> { + let mut inflections = vec![]; + + if !word.regular { + inflections.push(term.to_owned()); + } else if contract_root(word, case, number, gender) { + inflections.push( + word.particle[0..word.particle.len() - 2].to_string() + + &"r".to_owned() + + &term.to_owned(), + ); + } else if should_use_first_root(word, case, number, gender) { + let parts: Vec<&str> = word.enunciated.split(',').collect(); + inflections.push(parts.first().unwrap().to_string() + &term.to_owned()); + } else if word.kind == "ius" && number == 0 { + // Words of this kind are a bit troublesome on the singular, let's + // handle them now. + if case == 1 && word.is_flag_set("contracted_vocative") { + inflections + .push(word.particle[0..word.particle.len() - 1].to_string() + &term.to_owned()); + } else { + if case == 3 { + inflections + .push(word.particle[0..word.particle.len() - 1].to_string() + &term.to_owned()); + } + inflections.push(word.particle.to_string() + &term.to_owned()); + } + } else { + inflections.push(word.particle.clone() + &term.to_owned()); + } + + inflections +} + +fn case_str_to_i(key: &str) -> Result<usize, String> { + match key { + "nominative" => Ok(0), + "vocative" => Ok(1), + "accusative" => Ok(2), + "genitive" => Ok(3), + "dative" => Ok(4), + "ablative" => Ok(5), + "locative" => Ok(6), + _ => Err(format!("bad key '{}' for a case", key)), + } +} + +pub fn group_declension_inflections( + word: &Word, + kind: &String, + gender: usize, +) -> Result<DeclensionTable, String> { + let conn = get_connection()?; + let mut stmt = conn + .prepare( + "SELECT id, number, gender, \"case\", value, declension_id, \ + kind, tense, mood, voice, person, conjugation_id \ + FROM forms \ + WHERE kind = ?1 AND gender = ?2 + ORDER BY id", + ) + .unwrap(); + let mut it = stmt.query([kind, &gender.to_string()]).unwrap(); + + let mut table = DeclensionTable::default(); + + while let Some(row) = it.next().unwrap() { + // Fetch the number and account for defectives on number. + let number: usize = row.get(1).unwrap(); + if number == 0 && word.is_flag_set("onlyplural") { + continue; + } else if number == 1 && word.is_flag_set("onlysingular") { + continue; + } + + let case = row.get(3).unwrap(); + let term: String = row.get(4).unwrap(); + + table.add(word, case, number, gender, &term); + } + + if let Some(sets) = word.flags.get("sets") { + let object = sets.as_object().unwrap(); + + for (case_gender, blob) in object.iter() { + let case_gender_str = case_gender.as_str(); + match case_gender_str { + "masculine" | "feminine" | "neuter" => { + if (gender == 0 && case_gender_str == "masculine") + || (gender == 1 && case_gender_str == "feminine") + || (gender == 2 && case_gender_str == "neuter") + { + let inner = blob.as_object().unwrap(); + for (case, blob) in inner.iter() { + let case_i = case_str_to_i(case)?; + table.consume_blob(case_i, blob, word, gender, false); + } + } + } + _ => { + let case_i = case_str_to_i(case_gender)?; + table.consume_blob(case_i, blob, word, gender, false); + } + } + } + } + + if let Some(adds) = word.flags.get("adds") { + let object = adds.as_object().unwrap(); + + for (case_gender, blob) in object.iter() { + let case_gender_str = case_gender.as_str(); + match case_gender_str { + "masculine" | "feminine" | "neuter" => { + if (gender == 0 && case_gender_str == "masculine") + || (gender == 1 && case_gender_str == "feminine") + || (gender == 2 && case_gender_str == "neuter") + { + let inner = blob.as_object().unwrap(); + for (case, blob) in inner.iter() { + let case_i = case_str_to_i(case)?; + table.consume_blob(case_i, blob, word, gender, true); + } + } + } + _ => { + let case_i = case_str_to_i(case_gender)?; + table.consume_blob(case_i, blob, word, gender, true); + } + } + } + } + + Ok(table) +} diff --git a/lib/mihi/src/migrate.rs b/lib/mihi/src/migrate.rs index 97f32c9..58a1681 100644 --- a/lib/mihi/src/migrate.rs +++ b/lib/mihi/src/migrate.rs @@ -1,5 +1,13 @@ use rusqlite::{Connection, Result}; +// TODO: not just the definition, but also the bootstrapping (into Git as there is nothing sensitive?). +// - conjugations +// - declensions +// - forms +// - languages +// - language_cases +// - word_relations + /// Use the given `connection` in order to initialize the database. pub fn init(connection: Connection) -> Result<usize> { connection.execute( @@ -18,8 +26,8 @@ CREATE TABLE IF NOT EXISTS "words" ( "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "suffix" varchar, - "language_id" integer, - "succeeded" integer, + "language_id" integer NOT NULL, + "succeeded" integer DEFAULT 0 NOT NULL, "steps" integer DEFAULT 0 NOT NULL, "translation" jsonb DEFAULT '{}', "pending" boolean DEFAULT 0, diff --git a/testdata/test.sqlite3 b/testdata/test.sqlite3 Binary files differnew file mode 100644 index 0000000..371ca7f --- /dev/null +++ b/testdata/test.sqlite3 |
