aboutsummaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorMiquel Sabaté Solà <mikisabate@gmail.com>2025-08-11 09:28:57 +0200
committerMiquel Sabaté Solà <mssola@mssola.com>2025-10-27 10:26:37 +0100
commit76e4c5bd5016347dca083b6932e6adf03f398cad (patch)
tree816bd3f24e4f4dbc1f87f93bd91a3c91d88c0f44 /lib
parentab60cbfb64b43402e42e7cd823b53b03f651e535 (diff)
downloadmihi-76e4c5bd5016347dca083b6932e6adf03f398cad.tar.gz
mihi-76e4c5bd5016347dca083b6932e6adf03f398cad.zip
Provide initial implementation for word inflection
Word inflection for nouns, adjectives has been added so it can be initially be used in commands like 'words show'. Word categories which have no inflections (e.g. adverbs, conjunctions, et al) are skipped. Signed-off-by: Miquel Sabaté Solà <mikisabate@gmail.com>
Diffstat (limited to 'lib')
-rw-r--r--lib/mihi/src/lib.rs358
-rw-r--r--lib/mihi/src/migrate.rs8
2 files changed, 360 insertions, 6 deletions
diff --git a/lib/mihi/src/lib.rs b/lib/mihi/src/lib.rs
index 439e92e..12953c3 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,6 +375,15 @@ pub fn create_word(word: Word) -> Result<(), String> {
))
}
},
+ 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));
+ }
+ }
// TODO
_ => {
return Err(format!(
@@ -584,7 +621,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 +851,310 @@ 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 never contracted. The accusative is
+ // only not contracted on neuter words.
+ match case {
+ 0 | 1 => false,
+ 2 => gender != 3,
+ _ => 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",
+ )
+ .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..f8fcc8d 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(