diff options
| author | Miquel Sabaté Solà <mssola@mssola.com> | 2026-01-29 22:03:31 +0100 |
|---|---|---|
| committer | Miquel Sabaté Solà <mssola@mssola.com> | 2026-01-29 22:03:31 +0100 |
| commit | ea33d92514c809a2efaa9940ce05a5765f3cb41c (patch) | |
| tree | bd9a82424a191b33bb2e0c44513d71c3c05432cb /lib | |
| parent | 3980cc7c38f043c53c9811078101d7a78de3f8d1 (diff) | |
| download | mihi-ea33d92514c809a2efaa9940ce05a5765f3cb41c.tar.gz mihi-ea33d92514c809a2efaa9940ce05a5765f3cb41c.zip | |
Split lib.rs into multiple files
Signed-off-by: Miquel Sabaté Solà <mssola@mssola.com>
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/mihi/src/cfg.rs | 140 | ||||
| -rw-r--r-- | lib/mihi/src/exercise.rs | 243 | ||||
| -rw-r--r-- | lib/mihi/src/inflection.rs | 375 | ||||
| -rw-r--r-- | lib/mihi/src/lib.rs | 1883 | ||||
| -rw-r--r-- | lib/mihi/src/tag.rs | 159 | ||||
| -rw-r--r-- | lib/mihi/src/word.rs | 969 |
6 files changed, 1897 insertions, 1872 deletions
diff --git a/lib/mihi/src/cfg.rs b/lib/mihi/src/cfg.rs new file mode 100644 index 0000000..49bb8c2 --- /dev/null +++ b/lib/mihi/src/cfg.rs @@ -0,0 +1,140 @@ +use std::fs::File; +use std::io::prelude::*; +use std::io::{self, BufRead, BufReader, Error}; +use std::path::{Path, PathBuf}; + +/// Returns the configuration path for the application, and it even creates it +/// if it doesn't exist already. +pub fn get_config_path() -> Result<PathBuf, String> { + let dir = match &std::env::var("XDG_CONFIG_HOME") { + Ok(path) => PathBuf::from(path), + Err(_) => match &std::env::var("HOME") { + Ok(path) => Path::new(path).join(".config"), + Err(_) => { + return Err(String::from( + "cannot find a suitable path for the configuration", + )) + } + }, + } + .join("mihi"); + + match std::fs::create_dir_all(&dir) { + Ok(_) => {} + Err(e) => return Err(e.to_string()), + }; + + Ok(dir) +} + +/// The case order to be followed by the current session. This is stored in the +/// configuration. +#[derive(Default, Debug)] +pub enum CaseOrder { + #[default] + European, + English, +} + +impl CaseOrder { + /// Returns the current case order as a bunch of integers which represent + /// each case on a given order. + pub fn to_usizes(&self) -> [usize; 7] { + match self { + CaseOrder::European => [0, 1, 2, 3, 4, 5, 6], + CaseOrder::English => [0, 3, 4, 2, 5, 1, 6], + } + } +} + +/// Representation for languages supported by this application. +#[derive(Clone, Debug, Default)] +pub enum Language { + #[default] + Unknown = 0, + Latin, +} + +impl TryFrom<isize> for Language { + type Error = &'static str; + + fn try_from(value: isize) -> Result<Self, Self::Error> { + match value { + 0 => Ok(Self::Unknown), + 1 => Ok(Self::Latin), + _ => Err("unknonwn language!"), + } + } +} + +impl std::fmt::Display for Language { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Unknown => write!(f, "unknown"), + Self::Latin => write!(f, "latin"), + } + } +} + +/// Add the given language into the configuration of this application. +pub fn add_language(language: String) -> Result<(), String> { + if language.as_str() != "latin" { + return Err(String::from("only 'latin' is allowed for a language")); + } + + let path = get_config_path()?; + let cfg = path.join("languages.txt"); + + if cfg.exists() { + return Ok(()); + } + + let mut file = match File::create(cfg) { + Ok(f) => f, + Err(e) => return Err(format!("could not create file: {e}")), + }; + match file.write_all(language.as_bytes()) { + Ok(_) => Ok(()), + Err(e) => Err(format!("could not save language '{language}': {e}")), + } +} + +/// Configuration object for this application. Obtain this via the +/// `configuration` function. +#[derive(Debug)] +pub struct Configuration { + pub language: Language, + pub case_order: CaseOrder, +} + +/// Reads the global configuration and returns a proper object for it. It will +/// assume some defaults if there is something that goes wrong when reading it. +pub fn configuration() -> Configuration { + let order = read_line_from(1).unwrap_or(String::from("european")); + let case_order = match order.as_str() { + "english" => CaseOrder::English, + _ => CaseOrder::European, + }; + + Configuration { + language: Language::Latin, + case_order, + } +} + +// Read a specific line from the configuration and return a String. +fn read_line_from(line: usize) -> Result<String, Error> { + let path = get_config_path().map_err(std::io::Error::other)?; + let cfg = path.join("languages.txt"); + + let file = File::open(cfg)?; + let reader = BufReader::new(file); + + let line = reader + .lines() + .nth(line) + .transpose()? + .ok_or_else(|| io::Error::other("line not found"))?; + + Ok(line) +} diff --git a/lib/mihi/src/exercise.rs b/lib/mihi/src/exercise.rs new file mode 100644 index 0000000..10fada1 --- /dev/null +++ b/lib/mihi/src/exercise.rs @@ -0,0 +1,243 @@ +use crate::get_connection; +use rusqlite::params; + +/// The exercise kinds supported by this application. +#[derive(Clone, Copy, Debug, Default)] +pub enum ExerciseKind { + #[default] + Pensum = 0, + Translation, + Transformation, + Numerical, +} + +impl std::fmt::Display for ExerciseKind { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Pensum => write!(f, "Pensum"), + Self::Translation => write!(f, "Translation"), + Self::Transformation => write!(f, "Transformation"), + Self::Numerical => write!(f, "Numerical"), + } + } +} + +impl TryFrom<isize> for ExerciseKind { + type Error = &'static str; + + fn try_from(value: isize) -> Result<Self, Self::Error> { + match value { + 0 => Ok(Self::Pensum), + 1 => Ok(Self::Translation), + 2 => Ok(Self::Transformation), + 3 => Ok(Self::Numerical), + _ => Err("unknonwn exercise kind"), + } + } +} + +impl TryFrom<&str> for ExerciseKind { + type Error = &'static str; + + fn try_from(value: &str) -> Result<Self, Self::Error> { + match value { + "pensum" => Ok(Self::Pensum), + "translation" => Ok(Self::Translation), + "transformation" => Ok(Self::Transformation), + "numerical" => Ok(Self::Numerical), + _ => Err("unknonwn exercise kind. Available: pensum, translation, transformation and numerical"), + } + } +} + +/// Exercise as laid out in the 'exercises' table. +#[derive(Clone, Debug, Default)] +pub struct Exercise { + pub id: i32, + pub title: String, + pub enunciate: String, + pub solution: String, + pub lessons: String, + pub kind: ExerciseKind, +} + +/// Creates the given exercise into the database. +pub fn create_exercise(exercise: Exercise) -> Result<(), String> { + let conn = get_connection()?; + match conn.execute( + "INSERT INTO exercises (title, enunciate, solution, lessons, kind, \ + updated_at, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'))", + params![ + exercise.title, + exercise.enunciate, + exercise.solution, + exercise.lessons, + exercise.kind as isize, + ], + ) { + Ok(_) => Ok(()), + Err(e) => Err(format!("could not create '{}': {}", exercise.title, e)), + } +} + +pub fn select_by_title(filter: Option<String>) -> Result<Vec<String>, String> { + let conn = get_connection()?; + + let mut stmt; + let mut it = match filter { + Some(filter) => { + stmt = conn + .prepare( + "SELECT title FROM exercises WHERE title LIKE ('%' || ?1 || '%') ORDER BY title", + ) + .unwrap(); + stmt.query([filter.as_str()]).unwrap() + } + None => { + stmt = conn + .prepare("SELECT title FROM exercises ORDER BY title") + .unwrap(); + stmt.query([]).unwrap() + } + }; + + let mut res = vec![]; + while let Some(row) = it.next().unwrap() { + res.push(row.get::<usize, String>(0).unwrap()); + } + Ok(res) +} + +pub fn find_exercise_by_title(title: &str) -> Result<Exercise, String> { + let conn = get_connection()?; + let mut stmt = conn + .prepare( + "SELECT id, title, enunciate, solution, lessons, kind \ + FROM exercises \ + WHERE title = ?1", + ) + .unwrap(); + let mut it = stmt.query([title]).unwrap(); + + match it.next() { + Err(_) => Err("no exercises were found with this title".to_string()), + Ok(rows) => match rows { + Some(row) => Ok(Exercise { + id: row.get(0).unwrap(), + title: row.get(1).unwrap(), + enunciate: row.get(2).unwrap(), + solution: row.get(3).unwrap(), + lessons: row.get(4).unwrap(), + kind: row.get::<usize, isize>(5).unwrap().try_into()?, + }), + None => Err("no exercises were found with this title".to_string()), + }, + } +} + +/// Updates the given exercise. +pub fn update_exercise(exercise: Exercise) -> Result<(), String> { + if exercise.id == 0 { + return Err("invalid exercise to update; seems it has not been created before".to_string()); + } + + let conn = get_connection()?; + + match conn.execute( + "UPDATE exercises \ + SET title = ?2, enunciate = ?3, solution = ?4, lessons = ?5, kind = ?6, \ + updated_at = datetime('now') \ + WHERE id = ?1", + params![ + exercise.id, + exercise.title, + exercise.enunciate, + exercise.solution, + exercise.lessons, + exercise.kind as isize, + ], + ) { + Ok(_) => Ok(()), + Err(e) => Err(format!("could not update '{}': {}", exercise.title, e)), + } +} + +/// Updates the 'updated_at' column for an exercise. +pub fn touch_exercise(exercise: Exercise) -> Result<(), String> { + if exercise.id == 0 { + return Err("invalid exercise to update; seems it has not been created before".to_string()); + } + + let conn = get_connection()?; + + match conn.execute( + "UPDATE exercises \ + SET updated_at = datetime('now') \ + WHERE id = ?1", + params![exercise.id], + ) { + Ok(_) => Ok(()), + Err(e) => Err(format!("could not update '{}': {}", exercise.title, e)), + } +} + +/// Delete an exercise from the database. +pub fn delete_exercise(title: &str) -> Result<(), String> { + let conn = get_connection()?; + + match conn.execute("DELETE FROM exercises WHERE title = ?1", params![title]) { + Ok(_) => Ok(()), + Err(e) => Err(format!("could not remove '{title}': {e}")), + } +} + +// Get a list of exercises sorted by relevance. A maximum of `limit` exercises +// will be returned, and you can also specify to filter the returned exercises +// by `kind`. +pub fn select_relevant_exercises( + kind: Option<ExerciseKind>, + limit: isize, +) -> Result<Vec<Exercise>, String> { + let conn = get_connection()?; + + let mut stmt; + let mut it = match kind { + Some(kind) => { + stmt = conn + .prepare( + "SELECT id, title, enunciate, solution, lessons, kind \ + FROM exercises \ + WHERE kind = ?1 \ + ORDER BY updated_at DESC \ + LIMIT ?2", + ) + .unwrap(); + stmt.query([kind as isize, limit]).unwrap() + } + None => { + stmt = conn + .prepare( + "SELECT id, title, enunciate, solution, lessons, kind \ + FROM exercises \ + ORDER BY updated_at DESC \ + LIMIT ?1", + ) + .unwrap(); + stmt.query([limit]).unwrap() + } + }; + + let mut res = vec![]; + while let Some(row) = it.next().unwrap() { + res.push(Exercise { + id: row.get(0).unwrap(), + title: row.get(1).unwrap(), + enunciate: row.get(2).unwrap(), + solution: row.get(3).unwrap(), + lessons: row.get(4).unwrap(), + kind: row.get::<usize, isize>(5).unwrap().try_into()?, + }); + } + Ok(res) +} diff --git a/lib/mihi/src/inflection.rs b/lib/mihi/src/inflection.rs new file mode 100644 index 0000000..b7203ab --- /dev/null +++ b/lib/mihi/src/inflection.rs @@ -0,0 +1,375 @@ +use crate::get_connection; +use crate::word::{Declension, Gender, Word}; +use serde_json::Value; +use std::convert::TryFrom; + +#[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" + term); + } 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); + } 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); + } else { + if case == 3 { + inflections.push(word.particle[0..word.particle.len() - 1].to_string() + term); + } + inflections.push(word.particle.to_string() + term); + } + } else { + inflections.push(word.particle.clone() + term); + } + + 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)), + } +} + +/// Returns a string which describes the enunciate of the given `word` as +/// inflected considering the singular/plural declension `row`. +pub 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("/") + ) + } +} + +/// Returns the declension table of the given `word` by assuming it's a noun. +pub 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) +} + +/// Returns the declension tables for each gender of the given `word` by +/// assuming it's an adjective. +pub 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 { + Some(Declension::First | Declension::Second) => &"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)?, + ]) +} + +/// Returns the declension table for the given `word` by using the given `kind` +/// and `gender`. +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() { + let onlyplural = word.is_flag_set("onlyplural"); + + // Fetch the number and account for defectives on number. + let number_i: isize = row.get(1).unwrap(); + let number: usize = usize::try_from(number_i).expect("not expecting a negative number"); + if (number == 0 && onlyplural) || (number == 1 && word.is_flag_set("onlysingular")) { + continue; + } + + let case_i: isize = row.get(3).unwrap(); + let term: String = row.get(4).unwrap(); + + // If this is the locative, on the plural, and 'onlyplural' was not + // specified, then chances are that the locative in the plural doesn't + // exist. That is because it only existed for defective nouns such as + // 'Athēnīs'. + if case_i == 6 && number == 1 && !onlyplural { + continue; + } + + table.add( + word, + usize::try_from(case_i).expect("not expecting a negative number"), + 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/lib.rs b/lib/mihi/src/lib.rs index f944b82..53aed35 100644 --- a/lib/mihi/src/lib.rs +++ b/lib/mihi/src/lib.rs @@ -1,1267 +1,16 @@ -use serde_json::Value; -use std::convert::TryFrom; -use std::fs; -use std::fs::File; -use std::io::prelude::*; -use std::io::{self, BufRead, BufReader, Error}; -use std::path::{Path, PathBuf}; - -use rusqlite::types::{FromSql, FromSqlResult, ToSql, ToSqlOutput, ValueRef}; -use rusqlite::Result; -use rusqlite::{params, Connection}; - -/// Returns the configuration path for the application, and it even creates it -/// if it doesn't exist already. -pub fn get_config_path() -> Result<PathBuf, String> { - let dir = match &std::env::var("XDG_CONFIG_HOME") { - Ok(path) => PathBuf::from(path), - Err(_) => match &std::env::var("HOME") { - Ok(path) => Path::new(path).join(".config"), - Err(_) => { - return Err(String::from( - "cannot find a suitable path for the configuration", - )) - } - }, - } - .join("mihi"); - - match fs::create_dir_all(&dir) { - Ok(_) => {} - Err(e) => return Err(e.to_string()), - }; - - Ok(dir) -} - -#[derive(Default, Debug)] -pub enum CaseOrder { - #[default] - European, - English, -} - -impl CaseOrder { - /// Returns the current case order as a bunch of integers which represent - /// each case on a given order. - pub fn to_usizes(&self) -> [usize; 7] { - match self { - CaseOrder::European => [0, 1, 2, 3, 4, 5, 6], - CaseOrder::English => [0, 3, 4, 2, 5, 1, 6], - } - } -} - -#[derive(Debug)] -pub struct Configuration { - pub language: Language, - pub case_order: CaseOrder, -} - -/// Reads the global configuration and returns a proper object for it. It will -/// assume some defaults if there is something that goes wrong when reading it. -pub fn configuration() -> Configuration { - let order = read_line_from(1).unwrap_or(String::from("european")); - let case_order = match order.as_str() { - "english" => CaseOrder::English, - _ => CaseOrder::European, - }; - - Configuration { - language: Language::Latin, - case_order, - } -} - -// Read a specific line from the configuration and return a String. -fn read_line_from(line: usize) -> Result<String, Error> { - let path = get_config_path().map_err(std::io::Error::other)?; - let cfg = path.join("languages.txt"); - - let file = File::open(cfg)?; - let reader = BufReader::new(file); - - let line = reader - .lines() - .nth(line) - .transpose()? - .ok_or_else(|| io::Error::other("line not found"))?; - - Ok(line) -} - -#[derive(Clone, Copy, Debug, Default)] -pub enum Category { - #[default] - Unknown = 0, - Noun, - Adjective, - Verb, - Pronoun, - Adverb, - Preposition, - Conjunction, - Interjection, - Determiner, -} - -impl std::fmt::Display for Category { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - Self::Unknown => write!(f, "unknown"), - Self::Noun => write!(f, "noun"), - Self::Adjective => write!(f, "adjective"), - Self::Verb => write!(f, "verb"), - Self::Pronoun => write!(f, "pronoun"), - Self::Adverb => write!(f, "adverb"), - Self::Preposition => write!(f, "preposition"), - Self::Conjunction => write!(f, "conjunction"), - Self::Interjection => write!(f, "interjection"), - Self::Determiner => write!(f, "determiner"), - } - } -} - -impl TryFrom<isize> for Category { - type Error = &'static str; - - fn try_from(value: isize) -> Result<Self, Self::Error> { - match value { - 0 => Ok(Self::Unknown), - 1 => Ok(Self::Noun), - 2 => Ok(Self::Adjective), - 3 => Ok(Self::Verb), - 4 => Ok(Self::Pronoun), - 5 => Ok(Self::Adverb), - 6 => Ok(Self::Preposition), - 7 => Ok(Self::Conjunction), - 8 => Ok(Self::Interjection), - 9 => Ok(Self::Determiner), - _ => Err("unknonwn category!"), - } - } -} - -#[derive(Clone, Copy, Debug, Default)] -pub enum Gender { - Masculine = 0, - Feminine, - MasculineOrFeminine, - Neuter, - #[default] - 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<isize> for Gender { - type Error = &'static str; - - fn try_from(value: isize) -> Result<Self, Self::Error> { - match value { - 0 => Ok(Self::Masculine), - 1 => Ok(Self::Feminine), - 2 => Ok(Self::MasculineOrFeminine), - 3 => Ok(Self::Neuter), - 4 => Ok(Self::None), - _ => Err("unknonwn gender!"), - } - } -} - -impl std::fmt::Display for Gender { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - Self::Masculine => write!(f, "masculine"), - Self::Feminine => write!(f, "feminine"), - Self::MasculineOrFeminine => write!(f, "masculine or feminine"), - Self::Neuter => write!(f, "neuter"), - Self::None => write!(f, "none"), - } - } -} - -/// Identifies the declension for a given word, and it allows to do SQL to/from -/// conversions. -#[derive(Clone, Debug)] -pub enum Declension { - First = 1, - Second, - Third, - Fourth, - Fifth, - Other, -} - -impl ToSql for Declension { - fn to_sql(&self) -> Result<ToSqlOutput<'_>> { - Ok(ToSqlOutput::from(self.clone() as isize)) - } -} - -impl FromSql for Declension { - fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> { - let val = value.as_i64().unwrap_or(0); - - match val { - 1 => Ok(Declension::First), - 2 => Ok(Declension::Second), - 3 => Ok(Declension::Third), - 4 => Ok(Declension::Fourth), - 5 => Ok(Declension::Fifth), - _ => Ok(Declension::Other), - } - } -} - -impl std::fmt::Display for Declension { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - Declension::First => write!(f, "1st (-ae)"), - Declension::Second => write!(f, "2nd (-ī)"), - Declension::Third => write!(f, "3rd (-is)"), - Declension::Fourth => write!(f, "4th (-ūs)"), - Declension::Fifth => write!(f, "5th (-eī/-ēī)"), - Declension::Other => write!(f, "other"), - } - } -} - -/// Identifies the conjugation for a given verb, and it allows to do SQL to/from -/// conversions. -#[derive(Clone, Debug)] -pub enum Conjugation { - First = 1, - Second, - Third, - ThirdIo, - Fourth, - - // The 'Other' conjugation is a container for a bunch of verbs like 'sum', - // 'volō', etc. That is, we expect the user to realize this is an irregular - // verb, and then we rely on the 'kind' column to determine which kind of - // irregular verb that is. This is better than enlarging a list of kinds of - // irregular verbs as it was done in the past. - Other, -} - -impl ToSql for Conjugation { - fn to_sql(&self) -> Result<ToSqlOutput<'_>> { - Ok(ToSqlOutput::from(self.clone() as isize)) - } -} - -impl FromSql for Conjugation { - fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> { - let val = value.as_i64().unwrap_or(0); - - match val { - 1 => Ok(Conjugation::First), - 2 => Ok(Conjugation::Second), - 3 => Ok(Conjugation::Third), - 4 => Ok(Conjugation::ThirdIo), - 5 => Ok(Conjugation::Fourth), - _ => Ok(Conjugation::Other), - } - } -} - -impl std::fmt::Display for Conjugation { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - Conjugation::First => write!(f, "1st (ā stems)"), - Conjugation::Second => write!(f, "2nd (ē stems)"), - Conjugation::Third => write!(f, "3rd (ĕ stems)"), - Conjugation::ThirdIo => write!(f, "3rd (-iō variants)"), - Conjugation::Fourth => write!(f, "4th (ī stems)"), - Conjugation::Other => write!(f, "other"), - } - } -} - -impl Conjugation { - /// Returns a String containing how a conjugation would be displayed by also - /// considering the given 'kind' identifier. This way we can display 'other' - /// conjugations in a more natural way. - pub fn display_with_kind(&self, kind: &str) -> String { - if !matches!(self, Conjugation::Other) { - return format!("{}", self); - } - - match kind { - "sum" => "irregular; like 'sum, esse, fuī, futūrus'", - "possum" => "irregular; like 'possum, posse, potuī'", - "eo" => "irregular; like 'eō, īre, iī, itum'", - "volo" => "irregular; like 'volō, velle, voluī'", - "nolo" => "irregular; like 'nōlō, nōlle, nōluī'", - "malo" => "irregular; like 'mālō, mālle, māluī'", - "fero" => "irregular; like 'ferō, ferre, tulī, lātum'", - "facio" => "3rd (-iō variants) and suppletive; like 'faciō, facere, fēcī, factum'", - "do" => "1st; irregular short ă in most forms", - "inquam" => "irregular, highly defective", - "aio" => "3rd (-iō variants); highly defective", - _ => "other", - } - .to_string() - } -} - -#[derive(Clone, Debug, Default)] -pub enum Language { - #[default] - Unknown = 0, - Latin, -} - -impl TryFrom<isize> for Language { - type Error = &'static str; - - fn try_from(value: isize) -> Result<Self, Self::Error> { - match value { - 0 => Ok(Self::Unknown), - 1 => Ok(Self::Latin), - _ => Err("unknonwn language!"), - } - } -} - -impl std::fmt::Display for Language { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - Self::Unknown => write!(f, "unknown"), - Self::Latin => write!(f, "latin"), - } - } -} - -/// Add the given language into the configuration of this application. -pub fn add_language(language: String) -> Result<(), String> { - if language.as_str() != "latin" { - return Err(String::from("only 'latin' is allowed for a language")); - } - - let path = get_config_path()?; - let cfg = path.join("languages.txt"); - - if cfg.exists() { - return Ok(()); - } - - let mut file = match File::create(cfg) { - Ok(f) => f, - Err(e) => return Err(format!("could not create file: {e}")), - }; - match file.write_all(language.as_bytes()) { - Ok(_) => Ok(()), - Err(e) => Err(format!("could not save language '{language}': {e}")), - } -} - -/// Defines in which way two words are related. -#[derive(Clone, Debug)] -pub enum RelationKind { - /// The destination word is the comparative of the source (e.g. 'magnus, - /// magna, magnum' -> has irregular comparative -> 'māior, māius'). - Comparative = 1, - - /// The destination word is the superlative of the source (e.g. 'magnus, - /// magna, magnum' -> has irregular superlative -> 'maximus, maxima, - /// maximum'). - Superlative, - - /// The destination word is the adverb of the other (e.g. 'magnus, magna, - /// magnum' -> has an adverb -> 'magnē'). - Adverb, - - /// Two given words are the alternative of the other because of their root - /// or because of some sort of historical contraction (e.g. 'nihil' <-> - /// 'nīl', or the root on 'versō' <-> 'vōrsō'). - Alternative, - - /// One is the gendered alternative of the other (e.g. 'victor' <-> - /// 'victrix'). - Gendered, -} - -// Needed for inquire's (Multi)Select. -impl std::fmt::Display for RelationKind { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - Self::Comparative => write!(f, "comparative form"), - Self::Superlative => write!(f, "superlative form"), - Self::Adverb => write!(f, "adverbial form"), - Self::Alternative => write!(f, "alternative word"), - Self::Gendered => write!(f, "alternative word because of gender"), - } - } -} - -impl TryFrom<isize> for RelationKind { - type Error = String; - - fn try_from(v: isize) -> Result<Self, Self::Error> { - match v { - 1 => Ok(RelationKind::Comparative), - 2 => Ok(RelationKind::Superlative), - 3 => Ok(RelationKind::Adverb), - 4 => Ok(RelationKind::Alternative), - 5 => Ok(RelationKind::Gendered), - _ => Err(format!("unknown relation kind value '{}'", v)), - } - } -} - -/// Add a row in `word_relations` so the words identified by `one_id` and -/// `other_id` are set to have the `kind` relationship. -pub fn add_word_relationship(one_id: i64, other_id: i64, kind: RelationKind) -> Result<(), String> { - let conn = get_connection()?; - - match conn.execute( - "INSERT INTO word_relations (source_id, destination_id, kind, updated_at, created_at) \ - VALUES (?1, ?2, ?3, datetime('now'), datetime('now'))", - params![one_id, other_id, kind as isize], - ) { - Ok(_) => Ok(()), - Err(e) => Err(e.to_string()), - } -} - -/// Join by enunciate the given words. -pub fn joint_related_words(related: &[Word]) -> String { - related - .iter() - .map(|w| w.enunciated.clone()) - .collect::<Vec<String>>() - .join("; ") -} - -/// Returns a string with the enunciate of the comparative form of the given -/// `word`. This function assumes that it really does, or at least it's -/// contained in the `related` vector. -pub fn comparative(word: &Word, related: &[Word]) -> String { - if !related.is_empty() { - return joint_related_words(related); - } - if word.is_flag_set("compsup_prefix") { - return format!("magis {}", word.singular_nominative()); - } - - let part = word.real_particle(); - format!("{part}ior, {part}ius") -} - -/// Returns a string with the enunciate of the superlative form of the given -/// `word`. This function assumes that it really does, or at least it's -/// contained in the `related` vector. -pub fn superlative(word: &Word, related: &[Word]) -> String { - if !related.is_empty() { - return joint_related_words(related); - } - if word.is_flag_set("compsup_prefix") { - return format!("maximē {}", word.singular_nominative()); - } - - let part = &word.particle; - if word.is_flag_set("irregularsup") { - return format!("{part}limus, {part}lima, {part}limum"); - } else if word.is_flag_set("contracted_root") { - return format!("{part}rimus, {part}rima, {part}rimum"); - } - format!("{part}issimus, {part}issima, {part}issimum") -} - -/// Returns a string with the enunciate of the adverbial form of the given -/// `word`. This function assumes that it really does, or at least it's -/// contained in the `related` vector. -pub fn adverb(word: &Word, related: &[Word]) -> String { - if !related.is_empty() { - return joint_related_words(related); - } - - let part = word.real_particle(); - match word.declension { - Some(Declension::First | Declension::Second) => format!("{part}ē"), - Some(Declension::Third) => format!("{part}iter"), - _ => "<unknown>".to_string(), - } -} - -#[derive(Clone, Debug)] -pub struct Tag { - pub id: i32, - pub name: String, -} - -// Needed for inquire's (Multi)Select. -impl std::fmt::Display for Tag { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "{}", self.name) - } -} - -#[derive(Clone, Debug)] -pub struct Word { - pub id: i32, - pub enunciated: String, - pub particle: String, - pub language: Language, - pub declension: Option<Declension>, - pub conjugation: Option<Conjugation>, - pub kind: String, - pub category: Category, - pub regular: bool, - pub locative: bool, - pub gender: Gender, - pub suffix: Option<String>, - pub translation: Value, - pub flags: Value, - pub succeeded: isize, - pub steps: isize, - pub weight: isize, -} - -impl Word { - pub fn from( - particle: String, - category: Category, - declension: Option<Declension>, - conjugation: Option<Conjugation>, - gender: Gender, - kind: String, - ) -> Word { - Word { - id: 0, - enunciated: "".to_string(), - particle, - category, - declension, - conjugation, - kind, - regular: true, - locative: false, - gender, - suffix: None, - language: Language::Latin, - translation: serde_json::from_str("{}").unwrap(), - flags: serde_json::from_str("{}").unwrap(), - succeeded: 0, - steps: 0, - weight: 5, - } - } - - /// 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) => value.as_bool().unwrap_or_default(), - None => false, - } - } - - /// Returns the nominative version of the enunciate. - pub fn singular_nominative(&self) -> String { - self.enunciated - .split(',') - .nth(0) - .unwrap_or("") - .trim() - .to_string() - } - - pub fn real_particle(&self) -> String { - if self.is_flag_set("contracted_root") { - return format!( - "{}{}", - &self.particle[0..(self.particle.len() - 2)], - self.particle.chars().last().unwrap_or(' '), - ); - } - self.particle.clone() - } -} - -/// List of boolean flags supported for words. -pub const BOOLEAN_FLAGS: &[&str] = &[ - "deponent", - "onlysingular", - "onlyplural", - "contracted_root", - "nonpositive", - "compsup_prefix", - "indeclinable", - "irregularsup", - "nopassive", - "nosupine", - "noperfect", - "nogerundive", - "impersonal", - "impersonalpassive", - "noimperative", - "noinfinitive", - "shortimperative", - "onlythirdpassive", - "enclitic", - "notcomparable", - "onlyperfect", - "semideponent", - "contracted_vocative", -]; - -/// Returns true if the given flag is supported by this application. -pub fn is_valid_word_flag(flag: &str) -> bool { - BOOLEAN_FLAGS.contains(&flag) -} - -/// Creates the given word into the database and returns its ID on success. -pub fn create_word(word: Word) -> Result<i64, String> { - match word.category { - Category::Noun | Category::Adjective => { - if word.declension.is_none() { - return Err(String::from( - "you have to provide the declension for this verb", - )); - } - } - Category::Verb => { - if word.conjugation.is_none() { - return Err(String::from( - "you have to provide the conjugation for this verb", - )); - } - } - Category::Adverb - | Category::Preposition - | Category::Conjunction - | Category::Interjection - | Category::Determiner => { - if word.declension.is_some() || word.conjugation.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 - )) - } - } - - let conn = get_connection()?; - match conn.execute( - "INSERT INTO words (enunciated, particle, language_id, declension_id, \ - conjugation_id, kind, category, regular, locative, \ - gender, suffix, flags, translation, weight, succeeded, \ - updated_at, created_at) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, \ - datetime('now'), datetime('now'))", - params![ - word.enunciated.trim(), - word.particle.trim(), - word.language as isize, - word.declension, - word.conjugation, - word.kind.trim(), - word.category as isize, - word.regular, - word.locative, - word.gender as isize, - word.suffix, - serde_json::to_string(&word.flags).unwrap(), - serde_json::to_string(&word.translation).unwrap(), - word.weight, - 0 - ], - ) { - Ok(_) => Ok(conn.last_insert_rowid()), - Err(e) => Err(format!("could not create '{}': {}", word.enunciated, e)), - } -} - -/// Update the word that matches the ID on `word` and set it to the new values -/// contained in the `word` object. -pub fn update_word(word: Word) -> Result<(), String> { - if word.id == 0 { - return Err("invalid word to update; seems it has not been created before".to_string()); - } - - let conn = get_connection()?; - - match conn.execute( - "UPDATE words \ - SET enunciated = ?2, particle = ?3, declension_id = ?4, conjugation_id = ?5, \ - kind = ?6, category = ?7, regular = ?8, locative = ?9, gender = ?10, \ - suffix = ?11, flags = ?12, translation = ?13, weight = ?14, \ - updated_at = datetime('now') \ - WHERE id = ?1", - params![ - word.id, - word.enunciated, - word.particle, - word.declension, - word.conjugation, - word.kind, - word.category as isize, - word.regular, - word.locative, - word.gender as isize, - word.suffix, - serde_json::to_string(&word.flags).unwrap(), - serde_json::to_string(&word.translation).unwrap(), - word.weight - ], - ) { - Ok(_) => Ok(()), - Err(e) => Err(format!("could not update '{}': {}", word.enunciated, e)), - } -} - -/// Update the `updated_at` timestamp for any word matching the given -/// `enunciated` string. In theory the given enunciated should identify only a -/// single word, but nothing forbids the caller from updating every word which -/// somehow matches the given string. -pub fn update_timestamp(enunciated: &str) -> Result<(), String> { - let conn = get_connection()?; - - match conn.execute( - "UPDATE words \ - SET updated_at = datetime('now') \ - WHERE enunciated = ?1", - params![enunciated], - ) { - Ok(_) => Ok(()), - Err(e) => Err(format!("could not update '{}': {}", enunciated, e)), - } -} - -/// Select words based on the given `filter` for the enunciated column, which -/// can be further filtered out by providing a set of `tags`. The words selected -/// must then have any of the given tags provided by this vector, and it will be -/// ignored if the passed vector is empty. -pub fn select_enunciated(filter: Option<String>, tags: &[String]) -> Result<Vec<String>, String> { - let conn = get_connection()?; - - let mut stmt; - let mut it = match filter { - Some(filter) => { - stmt = if tags.is_empty() { - conn - .prepare( - "SELECT enunciated FROM words WHERE enunciated LIKE ('%' || ?1 || '%') ORDER BY enunciated", - ) - .unwrap() - } else { - conn.prepare( - format!( - "SELECT w.enunciated \ - FROM words w \ - JOIN tag_associations ta ON w.id = ta.word_id \ - JOIN tags t ON t.id = ta.tag_id \ - WHERE w.enunciated LIKE ('%' || ?1 || '%') AND t.name IN ({}) \ - ORDER BY w.enunciated", - tags.iter() - .map(|t| format!("'{}'", t)) - .collect::<Vec<_>>() - .join(", "), - ) - .as_str(), - ) - .unwrap() - }; - stmt.query([filter.as_str()]).unwrap() - } - None => { - stmt = if tags.is_empty() { - conn.prepare("SELECT enunciated FROM words ORDER BY enunciated") - .unwrap() - } else { - conn.prepare( - format!( - "SELECT w.enunciated \ - FROM words w \ - JOIN tag_associations ta ON w.id = ta.word_id \ - JOIN tags t ON t.id = ta.tag_id \ - WHERE t.name IN ({}) \ - ORDER BY w.enunciated", - tags.iter() - .map(|t| format!("'{}'", t)) - .collect::<Vec<_>>() - .join(", "), - ) - .as_str(), - ) - .unwrap() - }; - stmt.query([]).unwrap() - } - }; - - let mut res = vec![]; - while let Some(row) = it.next().unwrap() { - res.push(row.get::<usize, String>(0).unwrap()); - } - Ok(res) -} - -/// Returns all words that are related to the given `word` in one way or -/// another. The result is given as an array where each element is indexed by -/// RelationKind, and has a vector of words following that relationship. -pub fn select_related_words(word: &Word) -> Result<[Vec<Word>; 5], String> { - let mut res = [vec![], vec![], vec![], vec![], vec![]]; - - let conn = get_connection()?; - let mut stmt = conn - .prepare( - "SELECT w.id, w.enunciated, w.particle, w.language_id, w.declension_id, w.conjugation_id, \ - w.kind as wkind, w.category, w.regular, w.locative, w.gender, w.suffix, w.translation, \ - w.succeeded, w.steps, w.flags, w.weight, r.kind as rkind \ - FROM words w \ - JOIN word_relations r ON w.id = r.destination_id - WHERE r.source_id = ?1", - ) - .unwrap(); - let mut it = stmt.query([word.id]).unwrap(); - - while let Some(row) = it.next().unwrap() { - let relation: RelationKind = row.get::<usize, isize>(17).unwrap().try_into()?; - - res[relation as usize - 1].push(Word { - id: row.get(0).unwrap(), - enunciated: row.get(1).unwrap(), - particle: row.get(2).unwrap(), - language: row.get::<usize, isize>(3).unwrap().try_into()?, - declension: row.get(4).unwrap(), - conjugation: row.get(5).unwrap(), - kind: row.get(6).unwrap(), - category: row.get::<usize, isize>(7).unwrap().try_into()?, - regular: row.get(8).unwrap(), - locative: row.get(9).unwrap(), - gender: row.get::<usize, isize>(10).unwrap().try_into()?, - suffix: row.get(11).unwrap(), - translation: serde_json::from_str(&row.get::<usize, String>(12).unwrap()).unwrap(), - succeeded: row.get(13).unwrap(), - steps: row.get(14).unwrap(), - flags: serde_json::from_str(&row.get::<usize, String>(15).unwrap()).unwrap(), - weight: row.get(16).unwrap(), - }); - } - - Ok(res) -} - -pub fn find_by(enunciated: &str) -> Result<Word, String> { - let conn = get_connection()?; - let mut stmt = conn - .prepare( - "SELECT id, enunciated, particle, language_id, declension_id, conjugation_id, \ - kind, category, regular, locative, gender, suffix, translation, \ - succeeded, steps, flags, weight \ - FROM words \ - WHERE enunciated = ?1", - ) - .unwrap(); - let mut it = stmt.query([enunciated]).unwrap(); - - match it.next() { - Err(_) => Err("no words were found with this enunciate".to_string()), - Ok(rows) => match rows { - Some(row) => Ok(Word { - id: row.get(0).unwrap(), - enunciated: row.get(1).unwrap(), - particle: row.get(2).unwrap(), - language: row.get::<usize, isize>(3).unwrap().try_into()?, - declension: row.get(4).unwrap(), - conjugation: row.get(5).unwrap(), - kind: row.get(6).unwrap(), - category: row.get::<usize, isize>(7).unwrap().try_into()?, - regular: row.get(8).unwrap(), - locative: row.get(9).unwrap(), - gender: row.get::<usize, isize>(10).unwrap().try_into()?, - suffix: row.get(11).unwrap(), - translation: serde_json::from_str(&row.get::<usize, String>(12).unwrap()).unwrap(), - succeeded: row.get(13).unwrap(), - steps: row.get(14).unwrap(), - flags: serde_json::from_str(&row.get::<usize, String>(15).unwrap()).unwrap(), - weight: row.get(16).unwrap(), - }), - None => Err("no words were found with this enunciate".to_string()), - }, - } -} - -// Builds up a chain of OR clauses that check whether either of the given -// `flags` are set for a row. If no flags are given, then an empty string is -// returned. Otherwise the string is prepended by an "AND" clause, meaning that -// it expects the caller to have other clauses before this one. -fn flags_clause(flags: &[String]) -> String { - if flags.is_empty() { - return "".to_string(); - } - - let mut clauses: Vec<String> = vec![]; - for flag in flags { - clauses.push(format!("json_extract(flags, '$.{flag}') = 1")); - } - - "AND (".to_owned() + &clauses.join(" OR ") + ")" -} - -// Select a maximum of `number` words which match a given word `category` and -// have set one of the given boolean `flags`. You may also pass a `tags` vector -// which contains the name of the tags for which each word must have at least -// one match. -pub fn select_relevant_words( - category: Category, - flags: &[String], - tags: &[String], - number: isize, -) -> Result<Vec<Word>, String> { - let conn = get_connection()?; - let mut stmt = if tags.is_empty() { - conn.prepare( - format!( - "SELECT id, enunciated, particle, language_id, declension_id, conjugation_id, \ - kind, category, regular, locative, gender, suffix, translation, \ - succeeded, steps, flags, weight \ - FROM words \ - WHERE category = ?1 AND translation != '{{}}' {} \ - ORDER BY weight DESC, succeeded ASC, updated_at DESC - LIMIT ?2", - flags_clause(flags) - ) - .as_str(), - ) - .unwrap() - } else { - conn.prepare( - format!( - "SELECT w.id, w.enunciated, w.particle, w.language_id, w.declension_id, w.conjugation_id, \ - w.kind, w.category, w.regular, w.locative, w.gender, w.suffix, w.translation, \ - w.succeeded, w.steps, w.flags, w.weight \ - FROM words w \ - JOIN tag_associations ta ON w.id = ta.word_id \ - JOIN tags t ON t.id = ta.tag_id \ - WHERE w.category = ?1 AND t.name IN ({}) AND w.translation != '{{}}' {} \ - ORDER BY w.weight DESC, w.succeeded ASC, w.updated_at DESC - LIMIT ?2", - tags.iter().map(|t| format!("'{}'", t)).collect::<Vec<_>>().join(", "), - flags_clause(flags) - ) - .as_str(), - ) - .unwrap() - }; - let mut it = stmt.query([category as isize, number]).unwrap(); - - let mut res = vec![]; - while let Some(row) = it.next().unwrap() { - res.push(Word { - id: row.get(0).unwrap(), - enunciated: row.get(1).unwrap(), - particle: row.get(2).unwrap(), - language: row.get::<usize, isize>(3).unwrap().try_into()?, - declension: row.get(4).unwrap(), - conjugation: row.get(5).unwrap(), - kind: row.get(6).unwrap(), - category: row.get::<usize, isize>(7).unwrap().try_into()?, - regular: row.get(8).unwrap(), - locative: row.get(9).unwrap(), - gender: row.get::<usize, isize>(10).unwrap().try_into()?, - suffix: row.get(11).unwrap(), - translation: serde_json::from_str(&row.get::<usize, String>(12).unwrap()).unwrap(), - succeeded: row.get(13).unwrap(), - steps: row.get(14).unwrap(), - flags: serde_json::from_str(&row.get::<usize, String>(15).unwrap()).unwrap(), - weight: row.get(16).unwrap(), - }); - } - Ok(res) -} - -/// Select a set of words except for the ones passed in the `excluded` -/// vector. You have to pass the categories to be selected via the `categories` -/// parameter, which cannot be empty. It also accepts a set of boolean `flags` -/// as with functions like `select_relevant_words`; and the `tags` filtering -/// option. -pub fn select_words_except( - excluded: &[Word], - categories: &[Category], - flags: &[String], - tags: &[String], -) -> Result<Vec<Word>, String> { - assert!(!categories.is_empty()); - - let ids = excluded.iter().map(|w| w.id).collect::<Vec<i32>>(); - let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(", "); - let cats = categories - .iter() - .map(|c| format!("{}", *c as isize)) - .collect::<Vec<_>>() - .join(", "); - - let conn = get_connection()?; - let mut stmt = if tags.is_empty() { - conn.prepare( - format!( - "SELECT id, enunciated, particle, language_id, declension_id, conjugation_id, \ - kind, category, regular, locative, gender, suffix, translation, \ - succeeded, steps, flags, weight \ - FROM words \ - WHERE id NOT IN ({}) AND category IN ({}) AND translation != '{{}}' {} \ - ORDER BY weight DESC, succeeded ASC, updated_at DESC - LIMIT 5", - placeholders, - cats, - flags_clause(flags) - ) - .as_str(), - ) - .unwrap() - } else { - conn.prepare( - format!( - "SELECT w.id, w.enunciated, w.particle, w.language_id, w.declension_id, w.conjugation_id, \ - w.kind, w.category, w.regular, w.locative, w.gender, w.suffix, w.translation, \ - w.succeeded, w.steps, w.flags, w.weight \ - FROM words w \ - JOIN tag_associations ta ON w.id = ta.word_id \ - JOIN tags t ON t.id = ta.tag_id \ - WHERE w.id NOT IN ({}) AND t.name IN ({}) AND w.category IN ({}) AND w.translation != '{{}}' {} \ - ORDER BY w.weight DESC, w.succeeded ASC, w.updated_at DESC - LIMIT 5", - placeholders, - tags.iter().map(|t| format!("'{}'", t)).collect::<Vec<_>>().join(", "), - cats, - flags_clause(flags) - ) - .as_str(), - ) - .unwrap() - }; - - let mut it = stmt.query(rusqlite::params_from_iter(ids)).unwrap(); - let mut res = vec![]; - while let Some(row) = it.next().unwrap() { - res.push(Word { - id: row.get(0).unwrap(), - enunciated: row.get(1).unwrap(), - particle: row.get(2).unwrap(), - language: row.get::<usize, isize>(3).unwrap().try_into()?, - declension: row.get(4).unwrap(), - conjugation: row.get(5).unwrap(), - kind: row.get(6).unwrap(), - category: row.get::<usize, isize>(7).unwrap().try_into()?, - regular: row.get(8).unwrap(), - locative: row.get(9).unwrap(), - gender: row.get::<usize, isize>(10).unwrap().try_into()?, - suffix: row.get(11).unwrap(), - translation: serde_json::from_str(&row.get::<usize, String>(12).unwrap()).unwrap(), - succeeded: row.get(13).unwrap(), - steps: row.get(14).unwrap(), - flags: serde_json::from_str(&row.get::<usize, String>(15).unwrap()).unwrap(), - weight: row.get(16).unwrap(), - }); - } - - Ok(res) -} - -/// Returns a vector with the names for tags that match the given `filter`, or -/// all of them if None is passed as the filter. -pub fn select_tag_names(filter: &Option<String>) -> Result<Vec<String>, String> { - let conn = get_connection()?; - - let mut stmt; - let mut it = match filter { - Some(filter) => { - stmt = conn - .prepare("SELECT name FROM tags WHERE name LIKE ('%' || ?1 || '%') ORDER BY name") - .unwrap(); - stmt.query([filter.as_str()]).unwrap() - } - None => { - stmt = conn.prepare("SELECT name FROM tags ORDER BY name").unwrap(); - stmt.query([]).unwrap() - } - }; - - let mut res = vec![]; - while let Some(row) = it.next().unwrap() { - res.push(row.get::<usize, String>(0).unwrap()); - } - Ok(res) -} - -/// Select all tags for the given `word`. If None is provided, then all tags -/// from the database are returned. -pub fn select_tags_for(word: Option<i32>) -> Result<Vec<Tag>, String> { - let conn = get_connection()?; - - let mut stmt; - let mut it = match word { - Some(id) => { - stmt = conn - .prepare( - "SELECT t.id, t.name \ - FROM tags t \ - JOIN tag_associations ta ON t.id = ta.tag_id \ - JOIN words w ON w.id = ta.word_id \ - WHERE w.id = ?1 \ - ORDER BY t.name", - ) - .unwrap(); - stmt.query([id]).unwrap() - } - None => { - stmt = conn - .prepare("SELECT id, name FROM tags ORDER BY name") - .unwrap(); - stmt.query([]).unwrap() - } - }; - - let mut res = vec![]; - while let Some(row) = it.next().unwrap() { - res.push(Tag { - id: row.get::<usize, i32>(0).unwrap(), - name: row.get::<usize, String>(1).unwrap(), - }); - } - Ok(res) -} - -/// Insert into the database the tag identified by the given name. -pub fn create_tag(name: &str) -> Result<(), String> { - let conn = get_connection()?; - - match conn.execute( - "INSERT INTO tags (name, updated_at, created_at) \ - VALUES (?1, datetime('now'), datetime('now'))", - params![name.trim()], - ) { - Ok(_) => Ok(()), - Err(e) => Err(format!("could not create '{}': {}", name, e)), - } -} - -/// Inserts the pair of IDs into the tag_associations table. -pub fn attach_tag_to_word(tag_id: i64, word_id: i64) -> Result<(), String> { - let conn = get_connection()?; - - match conn.execute( - "INSERT INTO tag_associations (tag_id, word_id, updated_at, created_at) \ - VALUES (?1, ?2, datetime('now'), datetime('now'))", - params![tag_id, word_id], - ) { - Ok(_) => Ok(()), - Err(e) => Err(format!("could not attach tag: {e}")), - } -} - -/// Inserts the pair of IDs into the tag_associations table. -pub fn dettach_tags_from_word(tags: &[i32], word_id: i64) -> Result<(), String> { - if tags.is_empty() { - return Ok(()); - } - - let conn = get_connection()?; - - match conn.execute( - format!( - "DELETE FROM tag_associations \ - WHERE tag_id in ({}) AND word_id = ?1", - tags.iter() - .map(|t| format!("{}", t)) - .collect::<Vec<_>>() - .join(", ") - ) - .as_str(), - params![word_id], - ) { - Ok(_) => Ok(()), - Err(e) => Err(format!("could not attach tag: {e}")), - } -} - -/// Delete the tag from the database. -pub fn delete_tag(name: &String) -> Result<(), String> { - let conn = get_connection()?; - - match conn.execute("DELETE FROM tags WHERE name = ?1", params![name.trim()]) { - Ok(_) => Ok(()), - Err(e) => Err(format!("could not remove '{name}': {e}")), - } -} - -pub fn update_success(word: &Word, success: isize, steps: isize) -> Result<(), String> { - let conn = get_connection()?; - - match conn.execute( - "UPDATE words \ - SET succeeded = ?1, steps = ?2, updated_at = datetime('now') \ - WHERE id = ?3", - params![success, steps, word.id], - ) { - Ok(_) => Ok(()), - Err(e) => Err(format!("could not update '{}': {}", word.enunciated, e)), - } -} - -/// Delete the given word while also removing any relationship with other words -/// and tags. -pub fn delete_word(word: &Word) -> Result<(), String> { - let conn = get_connection()?; - - // Remove the word itself. - if let Err(e) = conn.execute( - "DELETE FROM words \ - WHERE id = ?1", - params![word.id], - ) { - return Err(format!("could not remove '{}': {e}", word.enunciated)); - } - - // Remove any relationships that mention this word. - if let Err(e) = conn.execute( - "DELETE FROM word_relations \ - WHERE source_id = ?1 OR destination_id = ?1", - params![word.id], - ) { - return Err(format!( - "could not remove relationships from '{}': {e}", - word.enunciated - )); - } - - // Remove any tag relationships with this now defunct word. - match conn.execute( - "DELETE FROM tag_associations \ - WHERE word_id = ?1", - params![word.id], - ) { - Ok(_) => Ok(()), - Err(e) => Err(format!( - "count not detach words for '{}': {e}", - word.enunciated - )), - } -} - -fn get_connection() -> Result<rusqlite::Connection, String> { +pub mod cfg; +pub mod exercise; +pub mod inflection; +pub mod tag; +pub mod word; + +/// Get a connection to the database. Note that you can set the 'MIHI_DATABASE' +/// environment variable to define an alternative path. +pub fn get_connection() -> Result<rusqlite::Connection, String> { let name = &std::env::var("MIHI_DATABASE").unwrap_or("database.sqlite3".to_string()); - let path = get_config_path()?.join(name); + let path = crate::cfg::get_config_path()?.join(name); - match Connection::open(&path) { + match rusqlite::Connection::open(&path) { Ok(handle) => Ok(handle), Err(_) => Err(format!( "could not fetch the database in '{}'", @@ -1269,613 +18,3 @@ fn get_connection() -> Result<rusqlite::Connection, String> { )), } } - -#[derive(Clone, Copy, Debug, Default)] -pub enum ExerciseKind { - #[default] - Pensum = 0, - Translation, - Transformation, - Numerical, -} - -impl std::fmt::Display for ExerciseKind { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - Self::Pensum => write!(f, "Pensum"), - Self::Translation => write!(f, "Translation"), - Self::Transformation => write!(f, "Transformation"), - Self::Numerical => write!(f, "Numerical"), - } - } -} - -impl TryFrom<isize> for ExerciseKind { - type Error = &'static str; - - fn try_from(value: isize) -> Result<Self, Self::Error> { - match value { - 0 => Ok(Self::Pensum), - 1 => Ok(Self::Translation), - 2 => Ok(Self::Transformation), - 3 => Ok(Self::Numerical), - _ => Err("unknonwn exercise kind"), - } - } -} - -impl TryFrom<&str> for ExerciseKind { - type Error = &'static str; - - fn try_from(value: &str) -> Result<Self, Self::Error> { - match value { - "pensum" => Ok(Self::Pensum), - "translation" => Ok(Self::Translation), - "transformation" => Ok(Self::Transformation), - "numerical" => Ok(Self::Numerical), - _ => Err("unknonwn exercise kind. Available: pensum, translation, transformation and numerical"), - } - } -} - -#[derive(Clone, Debug, Default)] -pub struct Exercise { - pub id: i32, - pub title: String, - pub enunciate: String, - pub solution: String, - pub lessons: String, - pub kind: ExerciseKind, -} - -/// Creates the given exercise into the database. -pub fn create_exercise(exercise: Exercise) -> Result<(), String> { - let conn = get_connection()?; - match conn.execute( - "INSERT INTO exercises (title, enunciate, solution, lessons, kind, \ - updated_at, created_at) \ - VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'))", - params![ - exercise.title, - exercise.enunciate, - exercise.solution, - exercise.lessons, - exercise.kind as isize, - ], - ) { - Ok(_) => Ok(()), - Err(e) => Err(format!("could not create '{}': {}", exercise.title, e)), - } -} - -pub fn select_by_title(filter: Option<String>) -> Result<Vec<String>, String> { - let conn = get_connection()?; - - let mut stmt; - let mut it = match filter { - Some(filter) => { - stmt = conn - .prepare( - "SELECT title FROM exercises WHERE title LIKE ('%' || ?1 || '%') ORDER BY title", - ) - .unwrap(); - stmt.query([filter.as_str()]).unwrap() - } - None => { - stmt = conn - .prepare("SELECT title FROM exercises ORDER BY title") - .unwrap(); - stmt.query([]).unwrap() - } - }; - - let mut res = vec![]; - while let Some(row) = it.next().unwrap() { - res.push(row.get::<usize, String>(0).unwrap()); - } - Ok(res) -} - -pub fn find_exercise_by_title(title: &str) -> Result<Exercise, String> { - let conn = get_connection()?; - let mut stmt = conn - .prepare( - "SELECT id, title, enunciate, solution, lessons, kind \ - FROM exercises \ - WHERE title = ?1", - ) - .unwrap(); - let mut it = stmt.query([title]).unwrap(); - - match it.next() { - Err(_) => Err("no exercises were found with this title".to_string()), - Ok(rows) => match rows { - Some(row) => Ok(Exercise { - id: row.get(0).unwrap(), - title: row.get(1).unwrap(), - enunciate: row.get(2).unwrap(), - solution: row.get(3).unwrap(), - lessons: row.get(4).unwrap(), - kind: row.get::<usize, isize>(5).unwrap().try_into()?, - }), - None => Err("no exercises were found with this title".to_string()), - }, - } -} - -/// Updates the given exercise. -pub fn update_exercise(exercise: Exercise) -> Result<(), String> { - if exercise.id == 0 { - return Err("invalid exercise to update; seems it has not been created before".to_string()); - } - - let conn = get_connection()?; - - match conn.execute( - "UPDATE exercises \ - SET title = ?2, enunciate = ?3, solution = ?4, lessons = ?5, kind = ?6, \ - updated_at = datetime('now') \ - WHERE id = ?1", - params![ - exercise.id, - exercise.title, - exercise.enunciate, - exercise.solution, - exercise.lessons, - exercise.kind as isize, - ], - ) { - Ok(_) => Ok(()), - Err(e) => Err(format!("could not update '{}': {}", exercise.title, e)), - } -} - -/// Updates the 'updated_at' column for an exercise. -pub fn touch_exercise(exercise: Exercise) -> Result<(), String> { - if exercise.id == 0 { - return Err("invalid exercise to update; seems it has not been created before".to_string()); - } - - let conn = get_connection()?; - - match conn.execute( - "UPDATE exercises \ - SET updated_at = datetime('now') \ - WHERE id = ?1", - params![exercise.id], - ) { - Ok(_) => Ok(()), - Err(e) => Err(format!("could not update '{}': {}", exercise.title, e)), - } -} - -/// Delete an exercise from the database. -pub fn delete_exercise(title: &str) -> Result<(), String> { - let conn = get_connection()?; - - match conn.execute("DELETE FROM exercises WHERE title = ?1", params![title]) { - Ok(_) => Ok(()), - Err(e) => Err(format!("could not remove '{title}': {e}")), - } -} - -// Get a list of exercises sorted by relevance. A maximum of `limit` exercises -// will be returned, and you can also specify to filter the returned exercises -// by `kind`. -pub fn select_relevant_exercises( - kind: Option<ExerciseKind>, - limit: isize, -) -> Result<Vec<Exercise>, String> { - let conn = get_connection()?; - - let mut stmt; - let mut it = match kind { - Some(kind) => { - stmt = conn - .prepare( - "SELECT id, title, enunciate, solution, lessons, kind \ - FROM exercises \ - WHERE kind = ?1 \ - ORDER BY updated_at DESC \ - LIMIT ?2", - ) - .unwrap(); - stmt.query([kind as isize, limit]).unwrap() - } - None => { - stmt = conn - .prepare( - "SELECT id, title, enunciate, solution, lessons, kind \ - FROM exercises \ - ORDER BY updated_at DESC \ - LIMIT ?1", - ) - .unwrap(); - stmt.query([limit]).unwrap() - } - }; - - let mut res = vec![]; - while let Some(row) = it.next().unwrap() { - res.push(Exercise { - id: row.get(0).unwrap(), - title: row.get(1).unwrap(), - enunciate: row.get(2).unwrap(), - solution: row.get(3).unwrap(), - lessons: row.get(4).unwrap(), - kind: row.get::<usize, isize>(5).unwrap().try_into()?, - }); - } - 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" + term); - } 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); - } 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); - } else { - if case == 3 { - inflections.push(word.particle[0..word.particle.len() - 1].to_string() + term); - } - inflections.push(word.particle.to_string() + term); - } - } else { - inflections.push(word.particle.clone() + term); - } - - 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)), - } -} - -/// Returns a string which describes the enunciate of the given `word` as -/// inflected considering the singular/plural declension `row`. -pub 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("/") - ) - } -} - -/// Returns the declension table of the given `word` by assuming it's a noun. -pub 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) -} - -/// Returns the declension tables for each gender of the given `word` by -/// assuming it's an adjective. -pub 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 { - Some(Declension::First | Declension::Second) => &"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)?, - ]) -} - -/// Returns the declension table for the given `word` by using the given `kind` -/// and `gender`. -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() { - let onlyplural = word.is_flag_set("onlyplural"); - - // Fetch the number and account for defectives on number. - let number_i: isize = row.get(1).unwrap(); - let number: usize = usize::try_from(number_i).expect("not expecting a negative number"); - if (number == 0 && onlyplural) || (number == 1 && word.is_flag_set("onlysingular")) { - continue; - } - - let case_i: isize = row.get(3).unwrap(); - let term: String = row.get(4).unwrap(); - - // If this is the locative, on the plural, and 'onlyplural' was not - // specified, then chances are that the locative in the plural doesn't - // exist. That is because it only existed for defective nouns such as - // 'Athēnīs'. - if case_i == 6 && number == 1 && !onlyplural { - continue; - } - - table.add( - word, - usize::try_from(case_i).expect("not expecting a negative number"), - 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/tag.rs b/lib/mihi/src/tag.rs new file mode 100644 index 0000000..87ba326 --- /dev/null +++ b/lib/mihi/src/tag.rs @@ -0,0 +1,159 @@ +use crate::word::Word; +use rusqlite::params; + +/// A tag which can be associated with multiple words. It is mapped in the +/// database via the 'tags' and 'tag_associations' tables. +#[derive(Clone, Debug)] +pub struct Tag { + pub id: i32, + pub name: String, +} + +// Needed for inquire's (Multi)Select. +impl std::fmt::Display for Tag { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} + +/// Returns a vector with the names for tags that match the given `filter`, or +/// all of them if None is passed as the filter. +pub fn select_tag_names(filter: &Option<String>) -> Result<Vec<String>, String> { + let conn = crate::get_connection()?; + + let mut stmt; + let mut it = match filter { + Some(filter) => { + stmt = conn + .prepare("SELECT name FROM tags WHERE name LIKE ('%' || ?1 || '%') ORDER BY name") + .unwrap(); + stmt.query([filter.as_str()]).unwrap() + } + None => { + stmt = conn.prepare("SELECT name FROM tags ORDER BY name").unwrap(); + stmt.query([]).unwrap() + } + }; + + let mut res = vec![]; + while let Some(row) = it.next().unwrap() { + res.push(row.get::<usize, String>(0).unwrap()); + } + Ok(res) +} + +/// Select all tags for the given `word`. If None is provided, then all tags +/// from the database are returned. +pub fn select_tags_for(word: Option<i32>) -> Result<Vec<Tag>, String> { + let conn = crate::get_connection()?; + + let mut stmt; + let mut it = match word { + Some(id) => { + stmt = conn + .prepare( + "SELECT t.id, t.name \ + FROM tags t \ + JOIN tag_associations ta ON t.id = ta.tag_id \ + JOIN words w ON w.id = ta.word_id \ + WHERE w.id = ?1 \ + ORDER BY t.name", + ) + .unwrap(); + stmt.query([id]).unwrap() + } + None => { + stmt = conn + .prepare("SELECT id, name FROM tags ORDER BY name") + .unwrap(); + stmt.query([]).unwrap() + } + }; + + let mut res = vec![]; + while let Some(row) = it.next().unwrap() { + res.push(Tag { + id: row.get::<usize, i32>(0).unwrap(), + name: row.get::<usize, String>(1).unwrap(), + }); + } + Ok(res) +} + +/// Insert into the database the tag identified by the given name. +pub fn create_tag(name: &str) -> Result<(), String> { + let conn = crate::get_connection()?; + + match conn.execute( + "INSERT INTO tags (name, updated_at, created_at) \ + VALUES (?1, datetime('now'), datetime('now'))", + params![name.trim()], + ) { + Ok(_) => Ok(()), + Err(e) => Err(format!("could not create '{}': {}", name, e)), + } +} + +/// Inserts the pair of IDs into the tag_associations table. +pub fn attach_tag_to_word(tag_id: i64, word_id: i64) -> Result<(), String> { + let conn = crate::get_connection()?; + + match conn.execute( + "INSERT INTO tag_associations (tag_id, word_id, updated_at, created_at) \ + VALUES (?1, ?2, datetime('now'), datetime('now'))", + params![tag_id, word_id], + ) { + Ok(_) => Ok(()), + Err(e) => Err(format!("could not attach tag: {e}")), + } +} + +/// Inserts the pair of IDs into the tag_associations table. +pub fn dettach_tags_from_word(tags: &[i32], word_id: i64) -> Result<(), String> { + if tags.is_empty() { + return Ok(()); + } + + let conn = crate::get_connection()?; + + match conn.execute( + format!( + "DELETE FROM tag_associations \ + WHERE tag_id in ({}) AND word_id = ?1", + tags.iter() + .map(|t| format!("{}", t)) + .collect::<Vec<_>>() + .join(", ") + ) + .as_str(), + params![word_id], + ) { + Ok(_) => Ok(()), + Err(e) => Err(format!("could not attach tag: {e}")), + } +} + +/// Delete the tag from the database. +pub fn delete_tag(name: &String) -> Result<(), String> { + let conn = crate::get_connection()?; + + match conn.execute("DELETE FROM tags WHERE name = ?1", params![name.trim()]) { + Ok(_) => Ok(()), + Err(e) => Err(format!("could not remove '{name}': {e}")), + } +} + +/// Update the success and steps rates for a given word. +pub fn update_success(word: &Word, success: isize, steps: isize) -> Result<(), String> { + let conn = crate::get_connection()?; + + match conn.execute( + "UPDATE words \ + SET succeeded = ?1, steps = ?2, updated_at = datetime('now') \ + WHERE id = ?3", + params![success, steps, word.id], + ) { + Ok(_) => Ok(()), + Err(e) => Err(format!("could not update '{}': {}", word.enunciated, e)), + } +} diff --git a/lib/mihi/src/word.rs b/lib/mihi/src/word.rs new file mode 100644 index 0000000..b12609b --- /dev/null +++ b/lib/mihi/src/word.rs @@ -0,0 +1,969 @@ +use crate::cfg::Language; +use crate::get_connection; +use rusqlite::params; +use rusqlite::types::{FromSql, FromSqlResult, ToSql, ToSqlOutput, ValueRef}; +use rusqlite::Result; +use serde_json::Value; + +/// A word as represented in the 'words' table of the database. +#[derive(Clone, Debug)] +pub struct Word { + pub id: i32, + pub enunciated: String, + pub particle: String, + pub language: Language, + pub declension: Option<Declension>, + pub conjugation: Option<Conjugation>, + pub kind: String, + pub category: Category, + pub regular: bool, + pub locative: bool, + pub gender: Gender, + pub suffix: Option<String>, + pub translation: Value, + pub flags: Value, + pub succeeded: isize, + pub steps: isize, + pub weight: isize, +} + +impl Word { + pub fn from( + particle: String, + category: Category, + declension: Option<Declension>, + conjugation: Option<Conjugation>, + gender: Gender, + kind: String, + ) -> Word { + Word { + id: 0, + enunciated: "".to_string(), + particle, + category, + declension, + conjugation, + kind, + regular: true, + locative: false, + gender, + suffix: None, + language: Language::Latin, + translation: serde_json::from_str("{}").unwrap(), + flags: serde_json::from_str("{}").unwrap(), + succeeded: 0, + steps: 0, + weight: 5, + } + } + + /// 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) => value.as_bool().unwrap_or_default(), + None => false, + } + } + + /// Returns the nominative version of the enunciate. + pub fn singular_nominative(&self) -> String { + self.enunciated + .split(',') + .nth(0) + .unwrap_or("") + .trim() + .to_string() + } + + pub fn real_particle(&self) -> String { + if self.is_flag_set("contracted_root") { + return format!( + "{}{}", + &self.particle[0..(self.particle.len() - 2)], + self.particle.chars().last().unwrap_or(' '), + ); + } + self.particle.clone() + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub enum Category { + #[default] + Unknown = 0, + Noun, + Adjective, + Verb, + Pronoun, + Adverb, + Preposition, + Conjunction, + Interjection, + Determiner, +} + +impl std::fmt::Display for Category { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Unknown => write!(f, "unknown"), + Self::Noun => write!(f, "noun"), + Self::Adjective => write!(f, "adjective"), + Self::Verb => write!(f, "verb"), + Self::Pronoun => write!(f, "pronoun"), + Self::Adverb => write!(f, "adverb"), + Self::Preposition => write!(f, "preposition"), + Self::Conjunction => write!(f, "conjunction"), + Self::Interjection => write!(f, "interjection"), + Self::Determiner => write!(f, "determiner"), + } + } +} + +impl TryFrom<isize> for Category { + type Error = &'static str; + + fn try_from(value: isize) -> Result<Self, Self::Error> { + match value { + 0 => Ok(Self::Unknown), + 1 => Ok(Self::Noun), + 2 => Ok(Self::Adjective), + 3 => Ok(Self::Verb), + 4 => Ok(Self::Pronoun), + 5 => Ok(Self::Adverb), + 6 => Ok(Self::Preposition), + 7 => Ok(Self::Conjunction), + 8 => Ok(Self::Interjection), + 9 => Ok(Self::Determiner), + _ => Err("unknonwn category!"), + } + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub enum Gender { + Masculine = 0, + Feminine, + MasculineOrFeminine, + Neuter, + #[default] + 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<isize> for Gender { + type Error = &'static str; + + fn try_from(value: isize) -> Result<Self, Self::Error> { + match value { + 0 => Ok(Self::Masculine), + 1 => Ok(Self::Feminine), + 2 => Ok(Self::MasculineOrFeminine), + 3 => Ok(Self::Neuter), + 4 => Ok(Self::None), + _ => Err("unknonwn gender!"), + } + } +} + +impl std::fmt::Display for Gender { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Masculine => write!(f, "masculine"), + Self::Feminine => write!(f, "feminine"), + Self::MasculineOrFeminine => write!(f, "masculine or feminine"), + Self::Neuter => write!(f, "neuter"), + Self::None => write!(f, "none"), + } + } +} + +/// Identifies the declension for a given word, and it allows to do SQL to/from +/// conversions. +#[derive(Clone, Debug)] +pub enum Declension { + First = 1, + Second, + Third, + Fourth, + Fifth, + Other, +} + +impl ToSql for Declension { + fn to_sql(&self) -> Result<ToSqlOutput<'_>> { + Ok(ToSqlOutput::from(self.clone() as isize)) + } +} + +impl FromSql for Declension { + fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> { + let val = value.as_i64().unwrap_or(0); + + match val { + 1 => Ok(Declension::First), + 2 => Ok(Declension::Second), + 3 => Ok(Declension::Third), + 4 => Ok(Declension::Fourth), + 5 => Ok(Declension::Fifth), + _ => Ok(Declension::Other), + } + } +} + +impl std::fmt::Display for Declension { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Declension::First => write!(f, "1st (-ae)"), + Declension::Second => write!(f, "2nd (-ī)"), + Declension::Third => write!(f, "3rd (-is)"), + Declension::Fourth => write!(f, "4th (-ūs)"), + Declension::Fifth => write!(f, "5th (-eī/-ēī)"), + Declension::Other => write!(f, "other"), + } + } +} + +/// Identifies the conjugation for a given verb, and it allows to do SQL to/from +/// conversions. +#[derive(Clone, Debug)] +pub enum Conjugation { + First = 1, + Second, + Third, + ThirdIo, + Fourth, + + // The 'Other' conjugation is a container for a bunch of verbs like 'sum', + // 'volō', etc. That is, we expect the user to realize this is an irregular + // verb, and then we rely on the 'kind' column to determine which kind of + // irregular verb that is. This is better than enlarging a list of kinds of + // irregular verbs as it was done in the past. + Other, +} + +impl ToSql for Conjugation { + fn to_sql(&self) -> Result<ToSqlOutput<'_>> { + Ok(ToSqlOutput::from(self.clone() as isize)) + } +} + +impl FromSql for Conjugation { + fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> { + let val = value.as_i64().unwrap_or(0); + + match val { + 1 => Ok(Conjugation::First), + 2 => Ok(Conjugation::Second), + 3 => Ok(Conjugation::Third), + 4 => Ok(Conjugation::ThirdIo), + 5 => Ok(Conjugation::Fourth), + _ => Ok(Conjugation::Other), + } + } +} + +impl std::fmt::Display for Conjugation { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Conjugation::First => write!(f, "1st (ā stems)"), + Conjugation::Second => write!(f, "2nd (ē stems)"), + Conjugation::Third => write!(f, "3rd (ĕ stems)"), + Conjugation::ThirdIo => write!(f, "3rd (-iō variants)"), + Conjugation::Fourth => write!(f, "4th (ī stems)"), + Conjugation::Other => write!(f, "other"), + } + } +} + +impl Conjugation { + /// Returns a String containing how a conjugation would be displayed by also + /// considering the given 'kind' identifier. This way we can display 'other' + /// conjugations in a more natural way. + pub fn display_with_kind(&self, kind: &str) -> String { + if !matches!(self, Conjugation::Other) { + return format!("{}", self); + } + + match kind { + "sum" => "irregular; like 'sum, esse, fuī, futūrus'", + "possum" => "irregular; like 'possum, posse, potuī'", + "eo" => "irregular; like 'eō, īre, iī, itum'", + "volo" => "irregular; like 'volō, velle, voluī'", + "nolo" => "irregular; like 'nōlō, nōlle, nōluī'", + "malo" => "irregular; like 'mālō, mālle, māluī'", + "fero" => "irregular; like 'ferō, ferre, tulī, lātum'", + "facio" => "3rd (-iō variants) and suppletive; like 'faciō, facere, fēcī, factum'", + "do" => "1st; irregular short ă in most forms", + "inquam" => "irregular, highly defective", + "aio" => "3rd (-iō variants); highly defective", + _ => "other", + } + .to_string() + } +} + +/// Defines in which way two words are related. +#[derive(Clone, Debug)] +pub enum RelationKind { + /// The destination word is the comparative of the source (e.g. 'magnus, + /// magna, magnum' -> has irregular comparative -> 'māior, māius'). + Comparative = 1, + + /// The destination word is the superlative of the source (e.g. 'magnus, + /// magna, magnum' -> has irregular superlative -> 'maximus, maxima, + /// maximum'). + Superlative, + + /// The destination word is the adverb of the other (e.g. 'magnus, magna, + /// magnum' -> has an adverb -> 'magnē'). + Adverb, + + /// Two given words are the alternative of the other because of their root + /// or because of some sort of historical contraction (e.g. 'nihil' <-> + /// 'nīl', or the root on 'versō' <-> 'vōrsō'). + Alternative, + + /// One is the gendered alternative of the other (e.g. 'victor' <-> + /// 'victrix'). + Gendered, +} + +// Needed for inquire's (Multi)Select. +impl std::fmt::Display for RelationKind { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Comparative => write!(f, "comparative form"), + Self::Superlative => write!(f, "superlative form"), + Self::Adverb => write!(f, "adverbial form"), + Self::Alternative => write!(f, "alternative word"), + Self::Gendered => write!(f, "alternative word because of gender"), + } + } +} + +impl TryFrom<isize> for RelationKind { + type Error = String; + + fn try_from(v: isize) -> Result<Self, Self::Error> { + match v { + 1 => Ok(RelationKind::Comparative), + 2 => Ok(RelationKind::Superlative), + 3 => Ok(RelationKind::Adverb), + 4 => Ok(RelationKind::Alternative), + 5 => Ok(RelationKind::Gendered), + _ => Err(format!("unknown relation kind value '{}'", v)), + } + } +} + +/// Add a row in `word_relations` so the words identified by `one_id` and +/// `other_id` are set to have the `kind` relationship. +pub fn add_word_relationship(one_id: i64, other_id: i64, kind: RelationKind) -> Result<(), String> { + let conn = get_connection()?; + + match conn.execute( + "INSERT INTO word_relations (source_id, destination_id, kind, updated_at, created_at) \ + VALUES (?1, ?2, ?3, datetime('now'), datetime('now'))", + params![one_id, other_id, kind as isize], + ) { + Ok(_) => Ok(()), + Err(e) => Err(e.to_string()), + } +} + +/// Join by enunciate the given words. +pub fn joint_related_words(related: &[Word]) -> String { + related + .iter() + .map(|w| w.enunciated.clone()) + .collect::<Vec<String>>() + .join("; ") +} + +/// Returns a string with the enunciate of the comparative form of the given +/// `word`. This function assumes that it really does, or at least it's +/// contained in the `related` vector. +pub fn comparative(word: &Word, related: &[Word]) -> String { + if !related.is_empty() { + return joint_related_words(related); + } + if word.is_flag_set("compsup_prefix") { + return format!("magis {}", word.singular_nominative()); + } + + let part = word.real_particle(); + format!("{part}ior, {part}ius") +} + +/// Returns a string with the enunciate of the superlative form of the given +/// `word`. This function assumes that it really does, or at least it's +/// contained in the `related` vector. +pub fn superlative(word: &Word, related: &[Word]) -> String { + if !related.is_empty() { + return joint_related_words(related); + } + if word.is_flag_set("compsup_prefix") { + return format!("maximē {}", word.singular_nominative()); + } + + let part = &word.particle; + if word.is_flag_set("irregularsup") { + return format!("{part}limus, {part}lima, {part}limum"); + } else if word.is_flag_set("contracted_root") { + return format!("{part}rimus, {part}rima, {part}rimum"); + } + format!("{part}issimus, {part}issima, {part}issimum") +} + +/// Returns a string with the enunciate of the adverbial form of the given +/// `word`. This function assumes that it really does, or at least it's +/// contained in the `related` vector. +pub fn adverb(word: &Word, related: &[Word]) -> String { + if !related.is_empty() { + return joint_related_words(related); + } + + let part = word.real_particle(); + match word.declension { + Some(Declension::First | Declension::Second) => format!("{part}ē"), + Some(Declension::Third) => format!("{part}iter"), + _ => "<unknown>".to_string(), + } +} + +/// List of boolean flags supported for words. +pub const BOOLEAN_FLAGS: &[&str] = &[ + "deponent", + "onlysingular", + "onlyplural", + "contracted_root", + "nonpositive", + "compsup_prefix", + "indeclinable", + "irregularsup", + "nopassive", + "nosupine", + "noperfect", + "nogerundive", + "impersonal", + "impersonalpassive", + "noimperative", + "noinfinitive", + "shortimperative", + "onlythirdpassive", + "enclitic", + "notcomparable", + "onlyperfect", + "semideponent", + "contracted_vocative", +]; + +/// Returns true if the given flag is supported by this application. +pub fn is_valid_word_flag(flag: &str) -> bool { + BOOLEAN_FLAGS.contains(&flag) +} + +/// Creates the given word into the database and returns its ID on success. +pub fn create_word(word: Word) -> Result<i64, String> { + match word.category { + Category::Noun | Category::Adjective => { + if word.declension.is_none() { + return Err(String::from( + "you have to provide the declension for this verb", + )); + } + } + Category::Verb => { + if word.conjugation.is_none() { + return Err(String::from( + "you have to provide the conjugation for this verb", + )); + } + } + Category::Adverb + | Category::Preposition + | Category::Conjunction + | Category::Interjection + | Category::Determiner => { + if word.declension.is_some() || word.conjugation.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 + )) + } + } + + let conn = get_connection()?; + match conn.execute( + "INSERT INTO words (enunciated, particle, language_id, declension_id, \ + conjugation_id, kind, category, regular, locative, \ + gender, suffix, flags, translation, weight, succeeded, \ + updated_at, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, \ + datetime('now'), datetime('now'))", + params![ + word.enunciated.trim(), + word.particle.trim(), + word.language as isize, + word.declension, + word.conjugation, + word.kind.trim(), + word.category as isize, + word.regular, + word.locative, + word.gender as isize, + word.suffix, + serde_json::to_string(&word.flags).unwrap(), + serde_json::to_string(&word.translation).unwrap(), + word.weight, + 0 + ], + ) { + Ok(_) => Ok(conn.last_insert_rowid()), + Err(e) => Err(format!("could not create '{}': {}", word.enunciated, e)), + } +} + +/// Update the word that matches the ID on `word` and set it to the new values +/// contained in the `word` object. +pub fn update_word(word: Word) -> Result<(), String> { + if word.id == 0 { + return Err("invalid word to update; seems it has not been created before".to_string()); + } + + let conn = get_connection()?; + + match conn.execute( + "UPDATE words \ + SET enunciated = ?2, particle = ?3, declension_id = ?4, conjugation_id = ?5, \ + kind = ?6, category = ?7, regular = ?8, locative = ?9, gender = ?10, \ + suffix = ?11, flags = ?12, translation = ?13, weight = ?14, \ + updated_at = datetime('now') \ + WHERE id = ?1", + params![ + word.id, + word.enunciated, + word.particle, + word.declension, + word.conjugation, + word.kind, + word.category as isize, + word.regular, + word.locative, + word.gender as isize, + word.suffix, + serde_json::to_string(&word.flags).unwrap(), + serde_json::to_string(&word.translation).unwrap(), + word.weight + ], + ) { + Ok(_) => Ok(()), + Err(e) => Err(format!("could not update '{}': {}", word.enunciated, e)), + } +} + +/// Update the `updated_at` timestamp for any word matching the given +/// `enunciated` string. In theory the given enunciated should identify only a +/// single word, but nothing forbids the caller from updating every word which +/// somehow matches the given string. +pub fn update_timestamp(enunciated: &str) -> Result<(), String> { + let conn = get_connection()?; + + match conn.execute( + "UPDATE words \ + SET updated_at = datetime('now') \ + WHERE enunciated = ?1", + params![enunciated], + ) { + Ok(_) => Ok(()), + Err(e) => Err(format!("could not update '{}': {}", enunciated, e)), + } +} + +/// Select words based on the given `filter` for the enunciated column, which +/// can be further filtered out by providing a set of `tags`. The words selected +/// must then have any of the given tags provided by this vector, and it will be +/// ignored if the passed vector is empty. +pub fn select_enunciated(filter: Option<String>, tags: &[String]) -> Result<Vec<String>, String> { + let conn = get_connection()?; + + let mut stmt; + let mut it = match filter { + Some(filter) => { + stmt = if tags.is_empty() { + conn + .prepare( + "SELECT enunciated FROM words WHERE enunciated LIKE ('%' || ?1 || '%') ORDER BY enunciated", + ) + .unwrap() + } else { + conn.prepare( + format!( + "SELECT w.enunciated \ + FROM words w \ + JOIN tag_associations ta ON w.id = ta.word_id \ + JOIN tags t ON t.id = ta.tag_id \ + WHERE w.enunciated LIKE ('%' || ?1 || '%') AND t.name IN ({}) \ + ORDER BY w.enunciated", + tags.iter() + .map(|t| format!("'{}'", t)) + .collect::<Vec<_>>() + .join(", "), + ) + .as_str(), + ) + .unwrap() + }; + stmt.query([filter.as_str()]).unwrap() + } + None => { + stmt = if tags.is_empty() { + conn.prepare("SELECT enunciated FROM words ORDER BY enunciated") + .unwrap() + } else { + conn.prepare( + format!( + "SELECT w.enunciated \ + FROM words w \ + JOIN tag_associations ta ON w.id = ta.word_id \ + JOIN tags t ON t.id = ta.tag_id \ + WHERE t.name IN ({}) \ + ORDER BY w.enunciated", + tags.iter() + .map(|t| format!("'{}'", t)) + .collect::<Vec<_>>() + .join(", "), + ) + .as_str(), + ) + .unwrap() + }; + stmt.query([]).unwrap() + } + }; + + let mut res = vec![]; + while let Some(row) = it.next().unwrap() { + res.push(row.get::<usize, String>(0).unwrap()); + } + Ok(res) +} + +/// Returns all words that are related to the given `word` in one way or +/// another. The result is given as an array where each element is indexed by +/// RelationKind, and has a vector of words following that relationship. +pub fn select_related_words(word: &Word) -> Result<[Vec<Word>; 5], String> { + let mut res = [vec![], vec![], vec![], vec![], vec![]]; + + let conn = get_connection()?; + let mut stmt = conn + .prepare( + "SELECT w.id, w.enunciated, w.particle, w.language_id, w.declension_id, w.conjugation_id, \ + w.kind as wkind, w.category, w.regular, w.locative, w.gender, w.suffix, w.translation, \ + w.succeeded, w.steps, w.flags, w.weight, r.kind as rkind \ + FROM words w \ + JOIN word_relations r ON w.id = r.destination_id + WHERE r.source_id = ?1", + ) + .unwrap(); + let mut it = stmt.query([word.id]).unwrap(); + + while let Some(row) = it.next().unwrap() { + let relation: RelationKind = row.get::<usize, isize>(17).unwrap().try_into()?; + + res[relation as usize - 1].push(Word { + id: row.get(0).unwrap(), + enunciated: row.get(1).unwrap(), + particle: row.get(2).unwrap(), + language: row.get::<usize, isize>(3).unwrap().try_into()?, + declension: row.get(4).unwrap(), + conjugation: row.get(5).unwrap(), + kind: row.get(6).unwrap(), + category: row.get::<usize, isize>(7).unwrap().try_into()?, + regular: row.get(8).unwrap(), + locative: row.get(9).unwrap(), + gender: row.get::<usize, isize>(10).unwrap().try_into()?, + suffix: row.get(11).unwrap(), + translation: serde_json::from_str(&row.get::<usize, String>(12).unwrap()).unwrap(), + succeeded: row.get(13).unwrap(), + steps: row.get(14).unwrap(), + flags: serde_json::from_str(&row.get::<usize, String>(15).unwrap()).unwrap(), + weight: row.get(16).unwrap(), + }); + } + + Ok(res) +} + +pub fn find_by(enunciated: &str) -> Result<Word, String> { + let conn = get_connection()?; + let mut stmt = conn + .prepare( + "SELECT id, enunciated, particle, language_id, declension_id, conjugation_id, \ + kind, category, regular, locative, gender, suffix, translation, \ + succeeded, steps, flags, weight \ + FROM words \ + WHERE enunciated = ?1", + ) + .unwrap(); + let mut it = stmt.query([enunciated]).unwrap(); + + match it.next() { + Err(_) => Err("no words were found with this enunciate".to_string()), + Ok(rows) => match rows { + Some(row) => Ok(Word { + id: row.get(0).unwrap(), + enunciated: row.get(1).unwrap(), + particle: row.get(2).unwrap(), + language: row.get::<usize, isize>(3).unwrap().try_into()?, + declension: row.get(4).unwrap(), + conjugation: row.get(5).unwrap(), + kind: row.get(6).unwrap(), + category: row.get::<usize, isize>(7).unwrap().try_into()?, + regular: row.get(8).unwrap(), + locative: row.get(9).unwrap(), + gender: row.get::<usize, isize>(10).unwrap().try_into()?, + suffix: row.get(11).unwrap(), + translation: serde_json::from_str(&row.get::<usize, String>(12).unwrap()).unwrap(), + succeeded: row.get(13).unwrap(), + steps: row.get(14).unwrap(), + flags: serde_json::from_str(&row.get::<usize, String>(15).unwrap()).unwrap(), + weight: row.get(16).unwrap(), + }), + None => Err("no words were found with this enunciate".to_string()), + }, + } +} + +// Builds up a chain of OR clauses that check whether either of the given +// `flags` are set for a row. If no flags are given, then an empty string is +// returned. Otherwise the string is prepended by an "AND" clause, meaning that +// it expects the caller to have other clauses before this one. +fn flags_clause(flags: &[String]) -> String { + if flags.is_empty() { + return "".to_string(); + } + + let mut clauses: Vec<String> = vec![]; + for flag in flags { + clauses.push(format!("json_extract(flags, '$.{flag}') = 1")); + } + + "AND (".to_owned() + &clauses.join(" OR ") + ")" +} + +// Select a maximum of `number` words which match a given word `category` and +// have set one of the given boolean `flags`. You may also pass a `tags` vector +// which contains the name of the tags for which each word must have at least +// one match. +pub fn select_relevant_words( + category: Category, + flags: &[String], + tags: &[String], + number: isize, +) -> Result<Vec<Word>, String> { + let conn = get_connection()?; + let mut stmt = if tags.is_empty() { + conn.prepare( + format!( + "SELECT id, enunciated, particle, language_id, declension_id, conjugation_id, \ + kind, category, regular, locative, gender, suffix, translation, \ + succeeded, steps, flags, weight \ + FROM words \ + WHERE category = ?1 AND translation != '{{}}' {} \ + ORDER BY weight DESC, succeeded ASC, updated_at DESC + LIMIT ?2", + flags_clause(flags) + ) + .as_str(), + ) + .unwrap() + } else { + conn.prepare( + format!( + "SELECT w.id, w.enunciated, w.particle, w.language_id, w.declension_id, w.conjugation_id, \ + w.kind, w.category, w.regular, w.locative, w.gender, w.suffix, w.translation, \ + w.succeeded, w.steps, w.flags, w.weight \ + FROM words w \ + JOIN tag_associations ta ON w.id = ta.word_id \ + JOIN tags t ON t.id = ta.tag_id \ + WHERE w.category = ?1 AND t.name IN ({}) AND w.translation != '{{}}' {} \ + ORDER BY w.weight DESC, w.succeeded ASC, w.updated_at DESC + LIMIT ?2", + tags.iter().map(|t| format!("'{}'", t)).collect::<Vec<_>>().join(", "), + flags_clause(flags) + ) + .as_str(), + ) + .unwrap() + }; + let mut it = stmt.query([category as isize, number]).unwrap(); + + let mut res = vec![]; + while let Some(row) = it.next().unwrap() { + res.push(Word { + id: row.get(0).unwrap(), + enunciated: row.get(1).unwrap(), + particle: row.get(2).unwrap(), + language: row.get::<usize, isize>(3).unwrap().try_into()?, + declension: row.get(4).unwrap(), + conjugation: row.get(5).unwrap(), + kind: row.get(6).unwrap(), + category: row.get::<usize, isize>(7).unwrap().try_into()?, + regular: row.get(8).unwrap(), + locative: row.get(9).unwrap(), + gender: row.get::<usize, isize>(10).unwrap().try_into()?, + suffix: row.get(11).unwrap(), + translation: serde_json::from_str(&row.get::<usize, String>(12).unwrap()).unwrap(), + succeeded: row.get(13).unwrap(), + steps: row.get(14).unwrap(), + flags: serde_json::from_str(&row.get::<usize, String>(15).unwrap()).unwrap(), + weight: row.get(16).unwrap(), + }); + } + Ok(res) +} + +/// Select a set of words except for the ones passed in the `excluded` +/// vector. You have to pass the categories to be selected via the `categories` +/// parameter, which cannot be empty. It also accepts a set of boolean `flags` +/// as with functions like `select_relevant_words`; and the `tags` filtering +/// option. +pub fn select_words_except( + excluded: &[Word], + categories: &[Category], + flags: &[String], + tags: &[String], +) -> Result<Vec<Word>, String> { + assert!(!categories.is_empty()); + + let ids = excluded.iter().map(|w| w.id).collect::<Vec<i32>>(); + let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(", "); + let cats = categories + .iter() + .map(|c| format!("{}", *c as isize)) + .collect::<Vec<_>>() + .join(", "); + + let conn = get_connection()?; + let mut stmt = if tags.is_empty() { + conn.prepare( + format!( + "SELECT id, enunciated, particle, language_id, declension_id, conjugation_id, \ + kind, category, regular, locative, gender, suffix, translation, \ + succeeded, steps, flags, weight \ + FROM words \ + WHERE id NOT IN ({}) AND category IN ({}) AND translation != '{{}}' {} \ + ORDER BY weight DESC, succeeded ASC, updated_at DESC + LIMIT 5", + placeholders, + cats, + flags_clause(flags) + ) + .as_str(), + ) + .unwrap() + } else { + conn.prepare( + format!( + "SELECT w.id, w.enunciated, w.particle, w.language_id, w.declension_id, w.conjugation_id, \ + w.kind, w.category, w.regular, w.locative, w.gender, w.suffix, w.translation, \ + w.succeeded, w.steps, w.flags, w.weight \ + FROM words w \ + JOIN tag_associations ta ON w.id = ta.word_id \ + JOIN tags t ON t.id = ta.tag_id \ + WHERE w.id NOT IN ({}) AND t.name IN ({}) AND w.category IN ({}) AND w.translation != '{{}}' {} \ + ORDER BY w.weight DESC, w.succeeded ASC, w.updated_at DESC + LIMIT 5", + placeholders, + tags.iter().map(|t| format!("'{}'", t)).collect::<Vec<_>>().join(", "), + cats, + flags_clause(flags) + ) + .as_str(), + ) + .unwrap() + }; + + let mut it = stmt.query(rusqlite::params_from_iter(ids)).unwrap(); + let mut res = vec![]; + while let Some(row) = it.next().unwrap() { + res.push(Word { + id: row.get(0).unwrap(), + enunciated: row.get(1).unwrap(), + particle: row.get(2).unwrap(), + language: row.get::<usize, isize>(3).unwrap().try_into()?, + declension: row.get(4).unwrap(), + conjugation: row.get(5).unwrap(), + kind: row.get(6).unwrap(), + category: row.get::<usize, isize>(7).unwrap().try_into()?, + regular: row.get(8).unwrap(), + locative: row.get(9).unwrap(), + gender: row.get::<usize, isize>(10).unwrap().try_into()?, + suffix: row.get(11).unwrap(), + translation: serde_json::from_str(&row.get::<usize, String>(12).unwrap()).unwrap(), + succeeded: row.get(13).unwrap(), + steps: row.get(14).unwrap(), + flags: serde_json::from_str(&row.get::<usize, String>(15).unwrap()).unwrap(), + weight: row.get(16).unwrap(), + }); + } + + Ok(res) +} + +/// Delete the given word while also removing any relationship with other words +/// and tags. +pub fn delete_word(word: &Word) -> Result<(), String> { + let conn = get_connection()?; + + // Remove the word itself. + if let Err(e) = conn.execute( + "DELETE FROM words \ + WHERE id = ?1", + params![word.id], + ) { + return Err(format!("could not remove '{}': {e}", word.enunciated)); + } + + // Remove any relationships that mention this word. + if let Err(e) = conn.execute( + "DELETE FROM word_relations \ + WHERE source_id = ?1 OR destination_id = ?1", + params![word.id], + ) { + return Err(format!( + "could not remove relationships from '{}': {e}", + word.enunciated + )); + } + + // Remove any tag relationships with this now defunct word. + match conn.execute( + "DELETE FROM tag_associations \ + WHERE word_id = ?1", + params![word.id], + ) { + Ok(_) => Ok(()), + Err(e) => Err(format!( + "count not detach words for '{}': {e}", + word.enunciated + )), + } +} |
