aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMiquel Sabaté Solà <mikisabate@gmail.com>2025-06-12 22:06:03 +0200
committerMiquel Sabaté Solà <mikisabate@gmail.com>2025-06-12 22:06:03 +0200
commit8c5e5dd0e0aae161c4943e7ff4fe0c93be1c8fb2 (patch)
treee2aff9ecb2a70b5d6ecca86c252bf7640e247524
parent32d01b0045f2254d2b6808b7de4effe2223346bb (diff)
downloadmihi-8c5e5dd0e0aae161c4943e7ff4fe0c93be1c8fb2.tar.gz
mihi-8c5e5dd0e0aae161c4943e7ff4fe0c93be1c8fb2.zip
Implement the run command
This does not accept yet some flags that might be convenient for more advanced uses of it, nor it asks to inflect the word or stuff like that. For now it's as simple as "give me the meaning of...". Signed-off-by: Miquel Sabaté Solà <mikisabate@gmail.com>
-rw-r--r--.gitignore2
-rw-r--r--Cargo.lock87
-rw-r--r--crates/cli/src/run.rs142
-rw-r--r--lib/mihi/Cargo.toml2
-rw-r--r--lib/mihi/src/lib.rs216
5 files changed, 430 insertions, 19 deletions
diff --git a/.gitignore b/.gitignore
index e5099bc..56767f0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
/target
*.sqlite3
+tmp/
+TODO
diff --git a/Cargo.lock b/Cargo.lock
index cf0e5c6..8130300 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -152,6 +152,12 @@ dependencies = [
]
[[package]]
+name = "itoa"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
+
+[[package]]
name = "libc"
version = "0.2.172"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -185,10 +191,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
+name = "memchr"
+version = "2.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
+
+[[package]]
name = "mihi"
version = "0.1.0"
dependencies = [
"rusqlite",
+ "serde",
+ "serde_json",
]
[[package]]
@@ -248,6 +262,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
+name = "proc-macro2"
+version = "1.0.95"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
name = "redox_syscall"
version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -271,12 +303,50 @@ dependencies = [
]
[[package]]
+name = "ryu"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
+
+[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
+name = "serde"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.140"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
+dependencies = [
+ "itoa",
+ "memchr",
+ "ryu",
+ "serde",
+]
+
+[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -319,6 +389,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9"
[[package]]
+name = "syn"
+version = "2.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6397daf94fa90f058bd0fd88429dd9e5738999cca8d701813c80723add80462"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
name = "thread_local"
version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -329,6 +410,12 @@ dependencies = [
]
[[package]]
+name = "unicode-ident"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
+
+[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/crates/cli/src/run.rs b/crates/cli/src/run.rs
index 2288a17..fe193b9 100644
--- a/crates/cli/src/run.rs
+++ b/crates/cli/src/run.rs
@@ -1,3 +1,143 @@
+use inquire::Text;
+use mihi::{select_random_words, update_success, Category, Word};
+
+fn help(msg: Option<&str>) {
+ if msg.is_some() {
+ println!("{}.\n", msg.unwrap());
+ }
+
+ println!("mihi run: Run exercises. Default command if none was given.\n");
+ println!("usage: mihi run [OPTIONS]\n");
+
+ println!("Options:");
+ println!(" -h, --help\t\tPrint this message.");
+}
+
+enum Locale {
+ English,
+ Catalan,
+}
+
+impl Locale {
+ fn to_code(&self) -> &str {
+ match self {
+ Self::English => "en",
+ Self::Catalan => "ca",
+ }
+ }
+}
+
+impl std::fmt::Display for Locale {
+ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ match self {
+ Self::English => write!(f, "english"),
+ Self::Catalan => write!(f, "català"),
+ }
+ }
+}
+
+fn run_words(words: Vec<Word>, locale: Locale) -> i32 {
+ let mut errors = 0;
+
+ for word in words {
+ // If the translation cannot be found, skip this word.
+ let Some(translation) = word.translation.get(locale.to_code()) else {
+ continue;
+ };
+
+ println!("Word: {}", word.enunciated);
+
+ let Ok(raw) = Text::new(format!("Translation ({}):", locale).as_str()).prompt() else {
+ return 1;
+ };
+ let answer = raw.trim();
+
+ let tr = translation.as_str().unwrap_or("");
+ let found = !answer.is_empty() && tr.split(',').any(|tr| tr.trim().contains(&answer));
+
+ if found {
+ let _ = update_success(&word, word.succeeded + 1);
+ println!("\x1b[92m✓ {}\x1b[0m", tr);
+ } else {
+ if word.succeeded > 0 {
+ let _ = update_success(&word, word.succeeded - 1);
+ }
+ println!("\x1b[91m❌{}\x1b[0m", tr);
+ errors += 1;
+ }
+ }
+
+ errors
+}
+
+fn select_general_words() -> Result<Vec<Word>, String> {
+ let mut res = select_random_words(Category::Noun, 4)?;
+ res.append(&mut select_random_words(Category::Adjective, 2)?);
+ res.append(&mut select_random_words(Category::Verb, 4)?);
+ res.append(&mut select_random_words(Category::Pronoun, 1)?);
+ res.append(&mut select_random_words(Category::Adverb, 2)?);
+ res.append(&mut select_random_words(Category::Preposition, 1)?);
+ res.append(&mut select_random_words(Category::Conjunction, 1)?);
+ Ok(res)
+}
+
pub fn run(args: Vec<String>) {
- println!("TBD: {:#?}", args);
+ let mut it = args.into_iter();
+ let mut category = None;
+
+ while let Some(first) = it.next() {
+ match first.as_str() {
+ "-h" | "--help" => {
+ help(None);
+ std::process::exit(0);
+ }
+ "-c" | "--category" => {
+ if category.is_some() {
+ help(Some("error: run: you cannot provide multiple categories"));
+ }
+ match it.next() {
+ Some(cat) => {
+ category = match cat.trim().to_lowercase().as_str() {
+ "noun" => Some(Category::Noun),
+ "adjective" => Some(Category::Adjective),
+ "verb" => Some(Category::Verb),
+ "pronoun" => Some(Category::Pronoun),
+ "adverb" => Some(Category::Adverb),
+ "preposition" => Some(Category::Preposition),
+ "conjunction" => Some(Category::Conjunction),
+ "determiner" => Some(Category::Determiner),
+ _ => return help(Some("error: run: category not allowed")),
+ };
+ }
+ None => help(Some("error: run: you have to provide a category")),
+ }
+ }
+ _ => {
+ help(Some(
+ format!("error: run: unknown flag or command '{}'", first).as_str(),
+ ));
+ std::process::exit(1);
+ }
+ }
+ }
+
+ let raw_locale = std::env::var("LC_ALL").unwrap_or("en".to_string());
+ let locale = if raw_locale.starts_with("ca") {
+ Locale::Catalan
+ } else {
+ Locale::English
+ };
+
+ let words = match category {
+ Some(cat) => select_random_words(cat, 15),
+ None => select_general_words(),
+ };
+
+ match words {
+ Ok(list) => std::process::exit(run_words(list, locale)),
+ Err(e) => {
+ println!("error: run: {}", e);
+ std::process::exit(1);
+ }
+ };
}
diff --git a/lib/mihi/Cargo.toml b/lib/mihi/Cargo.toml
index 33f3439..4b973d6 100644
--- a/lib/mihi/Cargo.toml
+++ b/lib/mihi/Cargo.toml
@@ -9,3 +9,5 @@ license.workspace = true
[dependencies]
rusqlite = { version = "0.35.0", features = ["bundled"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
diff --git a/lib/mihi/src/lib.rs b/lib/mihi/src/lib.rs
index 906953d..e464549 100644
--- a/lib/mihi/src/lib.rs
+++ b/lib/mihi/src/lib.rs
@@ -1,3 +1,4 @@
+use serde_json::Value;
use std::fs;
use std::fs::File;
use std::io::prelude::*;
@@ -7,6 +8,138 @@ use rusqlite::{params, Connection};
mod migrate;
+#[derive(Debug)]
+pub enum Category {
+ 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 std::default::Default for Category {
+ fn default() -> Self {
+ Category::Unknown
+ }
+}
+
+impl TryFrom<usize> for Category {
+ type Error = &'static str;
+
+ fn try_from(value: usize) -> 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(Debug)]
+pub enum Gender {
+ Masculine = 0,
+ Feminine,
+ MasculineOrFeminine,
+ Neuter,
+ None,
+}
+
+impl TryFrom<usize> for Gender {
+ type Error = &'static str;
+
+ fn try_from(value: usize) -> 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"),
+ }
+ }
+}
+
+impl std::default::Default for Gender {
+ fn default() -> Self {
+ Gender::None
+ }
+}
+
+#[derive(Debug)]
+pub enum Language {
+ Unknown = 0,
+ Latin,
+}
+
+impl TryFrom<usize> for Language {
+ type Error = &'static str;
+
+ fn try_from(value: usize) -> 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"),
+ }
+ }
+}
+
+impl std::default::Default for Language {
+ fn default() -> Self {
+ Language::Unknown
+ }
+}
+
/// 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> {
@@ -73,24 +206,23 @@ pub fn init_database() -> Result<(), String> {
}
}
-// #[derive(Debug)]
-// struct Word {
-// id: i32,
-// enunciated: String,
-// particle: String,
-// language_id: u64,
-// declension_id: u64,
-// conjugation_id: u64,
-// kind: String,
-// category: String,
-// regular: bool,
-// locative: bool,
-// gender: u32,
-// suffix: String,
-// translation: String,
-// // TODO: datetime
-// // TODO: jsonb
-// }
+#[derive(Debug)]
+pub struct Word {
+ pub id: i32,
+ pub enunciated: String,
+ pub particle: String,
+ pub language: Language,
+ pub declension_id: Option<usize>,
+ pub conjugation_id: Option<usize>,
+ pub kind: String,
+ pub category: Category,
+ pub regular: bool,
+ pub locative: bool,
+ pub gender: Gender,
+ pub suffix: Option<String>,
+ pub translation: Value,
+ pub succeeded: usize,
+}
pub fn select_enunciated(filter: Option<String>) -> Result<Vec<String>, String> {
let conn = get_connection()?;
@@ -120,6 +252,54 @@ pub fn select_enunciated(filter: Option<String>) -> Result<Vec<String>, String>
Ok(res)
}
+pub fn select_random_words(category: Category, number: usize) -> Result<Vec<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 \
+ FROM words \
+ WHERE category = ?1 AND translation != '{}' \
+ ORDER BY succeeded ASC, updated_at DESC
+ LIMIT ?2",
+ )
+ .unwrap();
+ let mut it = stmt.query([category as usize, 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, 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(),
+ });
+ }
+ Ok(res)
+}
+
+pub fn update_success(word: &Word, success: usize) -> Result<(), String> {
+ let conn = get_connection()?;
+
+ match conn.execute(
+ "UPDATE words SET succeeded = ?1, updated_at = datetime('now') WHERE id = ?2",
+ params![success, word.id],
+ ) {
+ Ok(_) => Ok(()),
+ Err(e) => return Err(format!("could not update '{}': {}", word.enunciated, e)),
+ }
+}
+
pub fn delete_word(enunciated: &String) -> Result<(), String> {
let conn = get_connection()?;