aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--crates/cli/src/words.rs283
-rw-r--r--lib/mihi/src/lib.rs214
2 files changed, 358 insertions, 139 deletions
diff --git a/crates/cli/src/words.rs b/crates/cli/src/words.rs
index 832fa01..4743001 100644
--- a/crates/cli/src/words.rs
+++ b/crates/cli/src/words.rs
@@ -1,12 +1,12 @@
use inquire::{Confirm, Editor, Select, Text};
+use mihi::{Category, Gender, Language, Word};
use std::vec::IntoIter;
-use mihi::{create_word, delete_word, select_enunciated, Category, Gender, Language, Word};
-
static NEW_MESSAGE: &str = "New word";
static NEXT_MESSAGE: &str = "Skip this one!";
static QUIT_MESSAGE: &str = "Quit!";
+// Documentation text which is prepended to editing flags.
static FLAGS_TEXT: &str = r#"# Write a JSON blob with the following allowed keys.
#
# => Boolean
@@ -50,11 +50,9 @@ static FLAGS_TEXT: &str = r#"# Write a JSON blob with the following allowed keys
# }
# }
# }
-
-{
-}
"#;
+// Show the help message.
fn help(msg: Option<&str>) {
if msg.is_some() {
println!("{}.\n", msg.unwrap());
@@ -74,16 +72,9 @@ fn help(msg: Option<&str>) {
println!(" show\t\t\tShow information from a word.");
}
-#[derive(Default)]
-struct Guess {
- particle: String,
- category: Category,
- inflection_id: usize,
- gender: Gender,
- kind: String,
-}
-
-fn get_initial_guess(value: &str) -> Guess {
+// Given an enunciated value, try to guess a word from it. If that's not
+// possible then an empty word is given.
+fn get_initial_guess(value: &str) -> Word {
let parts = value.trim().split(',').collect::<Vec<_>>();
if parts.len() == 2 {
@@ -91,73 +82,81 @@ fn get_initial_guess(value: &str) -> Guess {
let second = parts.last().unwrap();
if first.ends_with('a') && second.ends_with("ae") {
- return Guess {
- particle: first[0..first.len() - 1].to_string(),
- category: Category::Noun,
- inflection_id: 1,
- gender: Gender::Feminine,
- kind: "a".to_string(),
- };
+ return Word::from(
+ first[0..first.len() - 1].to_string(),
+ Category::Noun,
+ Some(1),
+ None,
+ Gender::Feminine,
+ "a".to_string(),
+ );
} else if first.ends_with("us") && second.ends_with("ī") {
- return Guess {
- particle: first[0..first.len() - 2].to_string(),
- category: Category::Noun,
- inflection_id: 2,
- gender: Gender::Masculine,
- kind: "us".to_string(),
- };
+ return Word::from(
+ first[0..first.len() - 2].to_string(),
+ Category::Noun,
+ Some(2),
+ None,
+ Gender::Masculine,
+ "us".to_string(),
+ );
} else if first.ends_with("um") && second.ends_with("ī") {
- return Guess {
- particle: first[0..first.len() - 2].to_string(),
- category: Category::Noun,
- inflection_id: 2,
- gender: Gender::Neuter,
- kind: "um".to_string(),
- };
+ return Word::from(
+ first[0..first.len() - 2].to_string(),
+ Category::Noun,
+ Some(2),
+ None,
+ Gender::Neuter,
+ "um".to_string(),
+ );
} else if first.ends_with("us") && second.ends_with("ūs") {
- return Guess {
- particle: first[0..first.len() - 2].to_string(),
- category: Category::Noun,
- inflection_id: 4,
- gender: Gender::Masculine,
- kind: "fus".to_string(),
- };
+ return Word::from(
+ first[0..first.len() - 2].to_string(),
+ Category::Noun,
+ Some(4),
+ None,
+ Gender::Masculine,
+ "fus".to_string(),
+ );
} else if first.ends_with("ū") && second.ends_with("ūs") {
- return Guess {
- particle: first[0..first.len() - 1].to_string(),
- category: Category::Noun,
- inflection_id: 4,
- gender: Gender::Masculine,
- kind: "fus".to_string(),
- };
+ return Word::from(
+ first[0..first.len() - 1].to_string(),
+ Category::Noun,
+ Some(4),
+ None,
+ Gender::Masculine,
+ "fus".to_string(),
+ );
} else if first.ends_with("iēs") && second.ends_with("ēī") {
- return Guess {
- particle: first[0..first.len() - 3].to_string(),
- category: Category::Noun,
- inflection_id: 5,
- gender: Gender::Masculine,
- kind: "ies".to_string(),
- };
+ return Word::from(
+ first[0..first.len() - 3].to_string(),
+ Category::Noun,
+ Some(5),
+ None,
+ Gender::Masculine,
+ "ies".to_string(),
+ );
} else if first.ends_with("ēs") && second.ends_with("eī") {
- return Guess {
- particle: first[0..first.len() - 2].to_string(),
- category: Category::Noun,
- inflection_id: 5,
- gender: Gender::Masculine,
- kind: "es".to_string(),
- };
+ return Word::from(
+ first[0..first.len() - 2].to_string(),
+ Category::Noun,
+ Some(5),
+ None,
+ Gender::Masculine,
+ "es".to_string(),
+ );
} else if second.ends_with("is") {
- return Guess {
- particle: second[0..second.len() - 2].to_string(),
- category: Category::Noun,
- inflection_id: 5,
- gender: Gender::Masculine,
- kind: "es".to_string(),
- };
+ return Word::from(
+ second[0..second.len() - 2].to_string(),
+ Category::Noun,
+ Some(5),
+ None,
+ Gender::Masculine,
+ "es".to_string(),
+ );
}
}
- Guess::default()
+ Word::default()
}
// Remove comments from the "flags" text that was provided.
@@ -175,11 +174,24 @@ fn trim_flags(given: String) -> String {
res
}
-fn do_create(enunciated: String) -> Result<(), String> {
- let guess = get_initial_guess(enunciated.as_str());
+// Get the translation from `word.translated` which matches the given language
+// `key`. If that cannot be found, or for some reason is not a String, then an
+// error is returned.
+fn get_translated<'a>(word: &'a Word, key: &'a str) -> Result<&'a String, String> {
+ match word.translation.get(key) {
+ Some(value) => match value {
+ serde_json::Value::String(s) => Ok(s),
+ _ => Err("unexpected key type".to_string()),
+ },
+ None => Err("key does not exist".to_string()),
+ }
+}
+// Interactively ask the user to provide information for a word by the given
+// `enunciated`. The default values will be based on the given `word` parameter.
+fn ask_for_word_based_on(enunciated: String, word: Word) -> Result<Word, String> {
let Ok(particle) = Text::new("Particle:")
- .with_initial_value(&guess.particle)
+ .with_initial_value(&word.particle)
.prompt()
else {
return Err("abort!".to_string());
@@ -198,7 +210,7 @@ fn do_create(enunciated: String) -> Result<(), String> {
Category::Determiner,
];
let Ok(category) = Select::new("Category:", categories)
- .with_starting_cursor(guess.category as usize)
+ .with_starting_cursor(word.category as usize)
.prompt()
else {
return Err("abort!".to_string());
@@ -214,7 +226,7 @@ fn do_create(enunciated: String) -> Result<(), String> {
let gender = match category {
Category::Noun => {
match Select::new("Gender:", genders)
- .with_starting_cursor(guess.gender as usize)
+ .with_starting_cursor(word.gender as usize)
.prompt()
{
Ok(selection) => selection,
@@ -224,8 +236,12 @@ fn do_create(enunciated: String) -> Result<(), String> {
_ => Gender::None,
};
+ let Ok(kind) = Text::new("Kind:").with_initial_value(&word.kind).prompt() else {
+ return Err("abort!".to_string());
+ };
+
let Ok(inflection) = Text::new("Inflection:")
- .with_initial_value(&guess.inflection_id.to_string())
+ .with_initial_value(word.inflection_id().to_string().as_str())
.prompt()
else {
return Err("abort!".to_string());
@@ -234,35 +250,42 @@ fn do_create(enunciated: String) -> Result<(), String> {
return Err(format!("bad value for inflection ID '{inflection}'"));
};
- let Ok(kind) = Text::new("Kind:").with_initial_value(&guess.kind).prompt() else {
- return Err("abort!".to_string());
- };
-
- let Ok(regular) = Confirm::new("Regular:").with_default(true).prompt() else {
+ let Ok(regular) = Confirm::new("Regular:").with_default(word.regular).prompt() else {
return Err("abort!".to_string());
};
- let Ok(locative) = Confirm::new("Locative:").with_default(false).prompt() else {
+ let Ok(locative) = Confirm::new("Locative:")
+ .with_default(word.locative)
+ .prompt()
+ else {
return Err("abort!".to_string());
};
+ let raw_flags = serde_json::to_string(&word.flags).unwrap();
+
let Ok(flags) = Editor::new("Flags:")
- .with_predefined_text(FLAGS_TEXT)
+ .with_predefined_text(format!("{FLAGS_TEXT}\n{raw_flags}").as_str())
.prompt()
else {
return Err("abort!".to_string());
};
let trimmed_flags = trim_flags(flags);
- let Ok(translation_en) = Text::new("Translation (english):").prompt() else {
+ let Ok(translation_en) = Text::new("Translation (english):")
+ .with_initial_value(get_translated(&word, "en").unwrap_or(&String::from("")))
+ .prompt()
+ else {
return Err("abort!".to_string());
};
- let Ok(translation_ca) = Text::new("Translation (catalan):").prompt() else {
+ let Ok(translation_ca) = Text::new("Translation (catalan):")
+ .with_initial_value(get_translated(&word, "ca").unwrap_or(&String::from("")))
+ .prompt()
+ else {
return Err("abort!".to_string());
};
- let word = Word {
- id: 0,
- enunciated: enunciated.clone(),
+ Ok(Word {
+ id: word.id,
+ enunciated,
particle,
language: Language::Latin,
declension_id: if matches!(category, Category::Verb) {
@@ -293,9 +316,17 @@ fn do_create(enunciated: String) -> Result<(), String> {
flags: serde_json::from_str(&trimmed_flags).unwrap(),
succeeded: 0,
steps: 0,
- };
+ })
+}
+
+// Interactively ask the user for the given `enunciated`, build up a Word object
+// from it, and insert it into the database.
+fn do_create(enunciated: String) -> Result<(), String> {
+ let mut guess = get_initial_guess(enunciated.as_str());
+ guess.enunciated = enunciated.clone();
- match create_word(word) {
+ let word = ask_for_word_based_on(enunciated.clone(), guess)?;
+ match mihi::create_word(word) {
Ok(_) => {
println!("Word '{enunciated}' has been successfully created!");
Ok(())
@@ -323,7 +354,7 @@ fn create(args: IntoIter<String>) -> i32 {
// Now we try to fetch whether the word already existed, by doing a
// general search on the database.
- let mut words = match select_enunciated(Some(enunciated.clone())) {
+ let mut words = match mihi::select_enunciated(Some(enunciated.clone())) {
Ok(words) => words,
Err(e) => {
println!("error: words: {e}");
@@ -362,13 +393,14 @@ fn create(args: IntoIter<String>) -> i32 {
}
}
+// TODO: accept a --raw flag, which is implied on pipe
fn ls(mut args: IntoIter<String>) -> i32 {
if args.len() > 1 {
help(Some("error: words: too many filters"));
return 1;
}
- let words = match select_enunciated(args.next()) {
+ let words = match mihi::select_enunciated(args.next()) {
Ok(words) => words,
Err(e) => {
println!("error: words: {e}");
@@ -387,7 +419,7 @@ fn ls(mut args: IntoIter<String>) -> i32 {
// multiple words match the same search parameter, then the user is asked to
// select one from a list of candidates.
fn select_single_word(search: Option<String>) -> Result<String, String> {
- let words = select_enunciated(search)?;
+ let words = mihi::select_enunciated(search)?;
match words.len() {
0 => Err("not found".to_string()),
@@ -402,8 +434,63 @@ fn select_single_word(search: Option<String>) -> Result<String, String> {
}
}
-fn edit(mut _args: IntoIter<String>) -> i32 {
- 0
+fn edit(mut args: IntoIter<String>) -> i32 {
+ if args.len() > 1 {
+ help(Some(
+ "error: words: only one argument. If it's an enunciate, wrap it in double quotes",
+ ));
+ return 1;
+ }
+
+ // Only one word can be modified at a time.
+ let enunciated = match select_single_word(args.next()) {
+ Ok(word) => word,
+ Err(e) => {
+ println!("error: words: {e}");
+ return 1;
+ }
+ };
+
+ // Fetch the word object for it which will serve as the initial values.
+ let word = match mihi::find_by(enunciated.as_str()) {
+ Ok(word) => word,
+ Err(e) => {
+ println!("error: words: {e}");
+ return 1;
+ }
+ };
+
+ // The enunciate might change, let's ask for it again. This way we get the
+ // same experience as with the 'create' command.
+ let Ok(enunciated) = Text::new("Enunciated:")
+ .with_initial_value(&word.enunciated)
+ .prompt()
+ else {
+ return 1;
+ };
+ if enunciated.trim().is_empty() {
+ return 0;
+ }
+
+ // And ask again column by column to check for changes.
+ let updated = match ask_for_word_based_on(enunciated.clone(), word) {
+ Ok(word) => word,
+ Err(e) => {
+ println!("error: words: {e}");
+ return 1;
+ }
+ };
+
+ match mihi::update_word(updated) {
+ Ok(_) => {
+ println!("Word '{enunciated}' has been updated!");
+ 0
+ }
+ Err(e) => {
+ println!("error: words: {e}");
+ 1
+ }
+ }
}
fn show(mut _args: IntoIter<String>) -> i32 {
@@ -431,7 +518,7 @@ fn rm(mut args: IntoIter<String>) -> i32 {
.prompt();
match ans {
- Ok(true) => match delete_word(&selection) {
+ Ok(true) => match mihi::delete_word(&selection) {
Ok(_) => println!("Removed '{selection}' from the database!"),
Err(e) => {
println!("error: words: {e}");
diff --git a/lib/mihi/src/lib.rs b/lib/mihi/src/lib.rs
index 54efcf9..95cb6e6 100644
--- a/lib/mihi/src/lib.rs
+++ b/lib/mihi/src/lib.rs
@@ -8,8 +8,7 @@ use rusqlite::{params, Connection};
mod migrate;
-#[derive(Debug)]
-#[derive(Default)]
+#[derive(Clone, Copy, Debug, Default)]
pub enum Category {
#[default]
Unknown = 0,
@@ -41,7 +40,6 @@ impl std::fmt::Display for Category {
}
}
-
impl TryFrom<usize> for Category {
type Error = &'static str;
@@ -62,8 +60,7 @@ impl TryFrom<usize> for Category {
}
}
-#[derive(Debug)]
-#[derive(Default)]
+#[derive(Clone, Copy, Debug, Default)]
pub enum Gender {
Masculine = 0,
Feminine,
@@ -100,9 +97,7 @@ impl std::fmt::Display for Gender {
}
}
-
-#[derive(Debug)]
-#[derive(Default)]
+#[derive(Clone, Debug, Default)]
pub enum Language {
#[default]
Unknown = 0,
@@ -130,7 +125,6 @@ impl std::fmt::Display for Language {
}
}
-
/// 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> {
@@ -183,11 +177,7 @@ pub fn init_database() -> Result<(), String> {
let path = get_config_path()?.join("database.sqlite3");
let conn = match Connection::open(path) {
Ok(handle) => handle,
- Err(e) => {
- return Err(format!(
- "could not initialize the database: {e}"
- ))
- }
+ Err(e) => return Err(format!("could not initialize the database: {e}")),
};
match migrate::init(conn) {
@@ -196,7 +186,7 @@ pub fn init_database() -> Result<(), String> {
}
}
-#[derive(Debug)]
+#[derive(Clone, Debug, Default)]
pub struct Word {
pub id: i32,
pub enunciated: String,
@@ -216,10 +206,59 @@ pub struct Word {
pub steps: usize,
}
+impl Word {
+ pub fn from(
+ particle: String,
+ category: Category,
+ declension_id: Option<usize>,
+ conjugation_id: Option<usize>,
+ gender: Gender,
+ kind: String,
+ ) -> Word {
+ Word {
+ id: 0,
+ enunciated: "".to_string(),
+ particle,
+ category,
+ declension_id,
+ conjugation_id,
+ 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,
+ }
+ }
+
+ pub fn inflection_id(&self) -> usize {
+ if matches!(self.category, Category::Verb) {
+ return self.conjugation_id.unwrap();
+ }
+ self.declension_id.unwrap()
+ }
+}
+
const DECLENSIONS_WITH_KINDS: &[&[&str]] = &[
&["a"],
&["us", "um", "ius", "er/ir"],
- &["is", "istem", "pureistem", "one", "onenonistem", "two", "three", "visvis", "sussuis", "bosbovis", "iuppiteriovis"],
+ &[
+ "is",
+ "istem",
+ "pureistem",
+ "one",
+ "onenonistem",
+ "two",
+ "three",
+ "visvis",
+ "sussuis",
+ "bosbovis",
+ "iuppiteriovis",
+ ],
&["fus", "domusdomus"],
&["ies", "es"],
&["indeclinable"],
@@ -228,36 +267,59 @@ const DECLENSIONS_WITH_KINDS: &[&[&str]] = &[
const ADJECTIVE_KINDS: &[&[&str]] = &[
&["us", "er/ir"],
&[],
- &["one", "onenonistem", "two", "three", "unusnauta", "unusnautaer/ir", "duo", "tres", "mille"],
+ &[
+ "one",
+ "onenonistem",
+ "two",
+ "three",
+ "unusnauta",
+ "unusnautaer/ir",
+ "duo",
+ "tres",
+ "mille",
+ ],
];
/// Creates the given word into the database.
pub fn create_word(word: Word) -> Result<(), String> {
match word.category {
- Category::Noun => {
- match word.declension_id {
- Some(id @ 1..7) => {
- if !DECLENSIONS_WITH_KINDS[id - 1].contains(&word.kind.as_str()) {
- return Err(format!("bad kind for declension '{id}'"));
- }
+ Category::Noun => match word.declension_id {
+ Some(id @ 1..7) => {
+ if !DECLENSIONS_WITH_KINDS[id - 1].contains(&word.kind.as_str()) {
+ return Err(format!("bad kind for declension '{id}'"));
}
- Some(val) => return Err(format!("the declension ID '{val}' is not valid for nouns")),
- None => return Err(String::from("you have to provide the declension ID for this noun")),
+ }
+ Some(val) => return Err(format!("the declension ID '{val}' is not valid for nouns")),
+ None => {
+ return Err(String::from(
+ "you have to provide the declension ID for this noun",
+ ))
}
},
- Category::Adjective => {
- match word.declension_id {
- Some(id @ (1 | 3)) => {
- if !ADJECTIVE_KINDS[id - 1].contains(&word.kind.as_str()) {
- return Err(format!("bad kind for declension '{id}'"));
- }
+ Category::Adjective => match word.declension_id {
+ Some(id @ (1 | 3)) => {
+ if !ADJECTIVE_KINDS[id - 1].contains(&word.kind.as_str()) {
+ return Err(format!("bad kind for declension '{id}'"));
}
- Some(val) => return Err(format!("the declension ID '{val}' is not valid for adjectives")),
- None => return Err(String::from("you have to provide the declension ID for this adjective")),
+ }
+ Some(val) => {
+ return Err(format!(
+ "the declension ID '{val}' is not valid for adjectives"
+ ))
+ }
+ None => {
+ return Err(String::from(
+ "you have to provide the declension ID for this adjective",
+ ))
}
},
// TODO
- _ => return Err(format!("you cannot create a word from the '{}' category", word.category)),
+ _ => {
+ return Err(format!(
+ "you cannot create a word from the '{}' category",
+ word.category
+ ))
+ }
}
let conn = get_connection()?;
@@ -273,6 +335,40 @@ pub fn create_word(word: Word) -> Result<(), String> {
}
}
+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, updated_at = datetime('now') \
+ WHERE id = ?1",
+ params![
+ word.id,
+ word.enunciated,
+ word.particle,
+ word.declension_id,
+ word.conjugation_id,
+ word.kind,
+ word.category as usize,
+ word.regular,
+ word.locative,
+ word.gender as usize,
+ word.suffix,
+ serde_json::to_string(&word.flags).unwrap(),
+ serde_json::to_string(&word.translation).unwrap()
+ ],
+ ) {
+ Ok(_) => Ok(()),
+ Err(e) => Err(format!("could not update '{}': {}", word.enunciated, e)),
+ }
+}
+
pub fn select_enunciated(filter: Option<String>) -> Result<Vec<String>, String> {
let conn = get_connection()?;
@@ -301,6 +397,45 @@ pub fn select_enunciated(filter: Option<String>) -> Result<Vec<String>, String>
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 \
+ 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, usize>(3).unwrap().try_into()?,
+ declension_id: row.get(4).unwrap(),
+ conjugation_id: row.get(5).unwrap(),
+ kind: row.get(6).unwrap(),
+ category: row.get::<usize, usize>(7).unwrap().try_into()?,
+ regular: row.get(8).unwrap(),
+ locative: row.get(9).unwrap(),
+ gender: row.get::<usize, usize>(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(),
+ }),
+ None => Err("no words were found with this enunciate".to_string()),
+ },
+ }
+}
+
pub fn select_random_words(category: Category, number: usize) -> Result<Vec<Word>, String> {
let conn = get_connection()?;
let mut stmt = conn
@@ -334,7 +469,7 @@ pub fn select_random_words(category: Category, number: usize) -> Result<Vec<Word
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("").unwrap(),
+ flags: serde_json::from_str("{}").unwrap(),
});
}
Ok(res)
@@ -370,11 +505,8 @@ fn get_connection() -> Result<rusqlite::Connection, String> {
let path = get_config_path()?.join("database.sqlite3");
match Connection::open(path) {
Ok(handle) => Ok(handle),
- Err(_) => {
- Err(
- "could not fetch the database. Ensure that you have called 'init' first"
- .to_string(),
- )
- }
+ Err(_) => Err(
+ "could not fetch the database. Ensure that you have called 'init' first".to_string(),
+ ),
}
}