aboutsummaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorMiquel Sabaté Solà <mssola@mssola.com>2026-01-13 15:27:05 +0100
committerMiquel Sabaté Solà <mssola@mssola.com>2026-01-13 15:27:05 +0100
commit43632164dcacb3424ccb9ceba76ddfd7586cb59c (patch)
tree5c8d42f793326fecef757a7e60eb94ad45c13511 /lib
parentbb3bbcfd018816a80077a6c1f96f842d2bf5f3a4 (diff)
parent472d7fb9d37ec838fc69692cf24c8cb4a68af47a (diff)
downloadmihi-43632164dcacb3424ccb9ceba76ddfd7586cb59c.tar.gz
mihi-43632164dcacb3424ccb9ceba76ddfd7586cb59c.zip
Merge branch 'inflection'
Diffstat (limited to 'lib')
-rw-r--r--lib/mihi/src/lib.rs388
-rw-r--r--lib/mihi/src/migrate.rs12
2 files changed, 384 insertions, 16 deletions
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,