aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/ci.yml7
-rw-r--r--README.md2
-rw-r--r--crates/cli/src/inflection.rs336
-rw-r--r--crates/cli/src/locale.rs38
-rw-r--r--crates/cli/src/main.rs2
-rw-r--r--crates/cli/src/run.rs39
-rw-r--r--crates/cli/src/words.rs216
-rw-r--r--lib/mihi/src/lib.rs388
-rw-r--r--lib/mihi/src/migrate.rs12
-rw-r--r--testdata/test.sqlite3bin0 -> 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
diff --git a/README.md b/README.md
index b2c6f67..3575913 100644
--- a/README.md
+++ b/README.md
@@ -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
new file mode 100644
index 0000000..371ca7f
--- /dev/null
+++ b/testdata/test.sqlite3
Binary files differ