diff options
| author | Miquel Sabaté Solà <mikisabate@gmail.com> | 2025-07-29 21:54:30 +0200 |
|---|---|---|
| committer | Miquel Sabaté Solà <mikisabate@gmail.com> | 2025-07-29 21:54:30 +0200 |
| commit | b25f575e41f078be3e47d843fda1827881f23646 (patch) | |
| tree | caedf8553e1237e37260e1f970f417c2a64af089 | |
| parent | e012aa80006a0de8135929243e1311958b791048 (diff) | |
| download | mihi-b25f575e41f078be3e47d843fda1827881f23646.tar.gz mihi-b25f575e41f078be3e47d843fda1827881f23646.zip | |
Add the exercises command
Subcommands have been added to it which are similar to the ones on words
on purpose.
Signed-off-by: Miquel Sabaté Solà <mikisabate@gmail.com>
| -rw-r--r-- | crates/cli/src/exercises.rs | 254 | ||||
| -rw-r--r-- | crates/cli/src/main.rs | 5 | ||||
| -rw-r--r-- | crates/cli/src/words.rs | 4 | ||||
| -rw-r--r-- | lib/mihi/src/lib.rs | 156 | ||||
| -rw-r--r-- | lib/mihi/src/migrate.rs | 23 |
5 files changed, 440 insertions, 2 deletions
diff --git a/crates/cli/src/exercises.rs b/crates/cli/src/exercises.rs new file mode 100644 index 0000000..a4bff78 --- /dev/null +++ b/crates/cli/src/exercises.rs @@ -0,0 +1,254 @@ +use inquire::{Confirm, Editor, Select, Text}; +use mihi::{Exercise, ExerciseKind}; +use std::vec::IntoIter; + +// Show the help message. +fn help(msg: Option<&str>) { + if msg.is_some() { + println!("{}.\n", msg.unwrap()); + } + + println!("mihi exercises: Manage exercises.\n"); + println!("usage: mihi exercises [OPTIONS] <subcommand>\n"); + + println!("Options:"); + println!(" -h, --help\t\tPrint this message."); + + println!("\nSubcommands:"); + println!(" create\t\tCreate a new exercise."); + println!(" edit\t\t\tEdit information from an exercise."); + println!(" ls\t\t\tList exercises from the database."); + println!(" rm\t\t\tRemove an exercises from the database."); +} + +// Interactively ask the user to fill up an exercise based on the given +// `exercise` object. +fn ask_for_exercise_based_on(exercise: Exercise) -> Result<Exercise, String> { + let Ok(title) = Text::new("Title:") + .with_initial_value(&exercise.title) + .prompt() + else { + return Err("abort!".to_string()); + }; + if title.trim().is_empty() { + return Err("the title is required".to_string()); + } + + let kinds = vec![ + ExerciseKind::Pensum, + ExerciseKind::Translation, + ExerciseKind::Transformation, + ExerciseKind::Numerical, + ]; + let Ok(kind) = Select::new("Kind:", kinds) + .with_starting_cursor(exercise.kind as usize) + .prompt() + else { + return Err("abort!".to_string()); + }; + + let Ok(enunciate) = Editor::new("Enunciate:") + .with_predefined_text(&exercise.enunciate) + .with_file_extension(".md") + .prompt() + else { + return Err("abort!".to_string()); + }; + let enunciate = enunciate.trim().to_string(); + if enunciate.is_empty() { + return Err("the enunciate is required".to_string()); + } + + let Ok(solution) = Editor::new("Solution:") + .with_predefined_text(&exercise.solution) + .with_file_extension(".md") + .prompt() + else { + return Err("abort!".to_string()); + }; + let solution = solution.trim().to_string(); + if solution.trim().is_empty() { + return Err("the solution is required".to_string()); + } + + let Ok(lessons) = Editor::new("Lessons:") + .with_predefined_text(&exercise.lessons) + .with_file_extension(".md") + .prompt() + else { + return Err("abort!".to_string()); + }; + let lessons = lessons.trim().to_string(); + + Ok(Exercise { + id: exercise.id, + title, + enunciate, + solution, + lessons, + kind, + }) +} + +fn create(args: IntoIter<String>) -> i32 { + if args.len() > 0 { + help(Some( + "error: exercises: no arguments were expected for this command", + )); + return 1; + } + + let exercise = match ask_for_exercise_based_on(Exercise::default()) { + Ok(ex) => ex, + Err(e) => { + println!("error: exercises: {e}"); + return 1; + } + }; + + let title = exercise.title.clone(); + match mihi::create_exercise(exercise) { + Ok(_) => { + println!("Exercise '{title}' has been successfully created!"); + 0 + } + Err(e) => { + println!("error: exercises: {e}"); + 1 + } + } +} + +fn select_single_exercise(search: Option<String>) -> Result<Exercise, String> { + let exercises = mihi::select_by_title(search)?; + + let title = match exercises.len() { + 0 => return Err("not found".to_string()), + 1 => exercises.first().unwrap().to_owned(), + _ => match Select::new("Which exercise?", exercises) + .with_page_size(20) + .prompt() + { + Ok(choice) => choice, + Err(_) => return Err("abort!".to_string()), + }, + }; + + mihi::find_exercise_by_title(title.as_str()) +} + +fn edit(mut args: IntoIter<String>) -> i32 { + if args.len() > 1 { + help(Some("error: exercises: too many filters")); + return 1; + } + + let exercise = match select_single_exercise(args.next()) { + Ok(exercise) => exercise, + Err(e) => { + println!("error: exercises: {e}"); + return 1; + } + }; + + let exercise = match ask_for_exercise_based_on(exercise) { + Ok(ex) => ex, + Err(e) => { + println!("error: exercises: {e}"); + return 1; + } + }; + + let title = exercise.title.clone(); + match mihi::update_exercise(exercise) { + Ok(_) => { + println!("Exercise '{title}' has been successfully updated!"); + 0 + } + Err(e) => { + println!("error: exercises: {e}"); + 1 + } + } +} + +fn rm(mut args: IntoIter<String>) -> i32 { + if args.len() > 1 { + help(Some("error: exercises: too many filters")); + return 1; + } + + let exercise = match select_single_exercise(args.next()) { + Ok(exercise) => exercise, + Err(e) => { + println!("error: words: {e}"); + return 1; + } + }; + let selection = exercise.title.as_str(); + + let ans = Confirm::new( + format!("Do you really want to remove '{selection}' from the database?",).as_str(), + ) + .with_default(false) + .prompt(); + + match ans { + Ok(true) => match mihi::delete_exercise(selection) { + Ok(_) => println!("Removed '{}' from the database!", selection), + Err(e) => { + println!("error: words: {e}"); + return 1; + } + }, + Ok(false) => { + println!("Doing nothing..."); + } + Err(_) => return 1, + } + + 0 +} + +pub fn run(args: Vec<String>) { + if args.is_empty() { + help(Some( + "error: exercises: you have to provide at least a subcommand", + )); + std::process::exit(1); + } + + let mut it = args.into_iter(); + + match it.next() { + Some(first) => match first.as_str() { + "-h" | "--help" => { + help(None); + std::process::exit(0); + } + "create" => { + std::process::exit(create(it)); + } + "edit" => { + std::process::exit(edit(it)); + } + "rm" => { + std::process::exit(rm(it)); + } + _ => { + help(Some( + format!("error: exercises: unknown flag or command '{first}'").as_str(), + )); + std::process::exit(1); + } + }, + None => { + help(Some( + "error: exercises: you need to provide a command" + .to_string() + .as_str(), + )); + std::process::exit(1); + } + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index db1de33..ffbcdae 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,3 +1,4 @@ +mod exercises; mod init; mod nuke; mod run; @@ -48,6 +49,10 @@ fn main() { let rest: Vec<String> = args.collect(); init::run(rest); } + "exercises" => { + let rest: Vec<String> = args.collect(); + exercises::run(rest); + } "nuke" => { let rest: Vec<String> = args.collect(); nuke::run(rest); diff --git a/crates/cli/src/words.rs b/crates/cli/src/words.rs index 72748ae..7db84a2 100644 --- a/crates/cli/src/words.rs +++ b/crates/cli/src/words.rs @@ -166,7 +166,7 @@ fn trim_flags(given: String) -> String { for line in given.lines() { let trimmed = line.trim(); - if !line.trim().starts_with('#') { + if !trimmed.starts_with('#') { res.push_str(trimmed); } } @@ -510,7 +510,7 @@ fn edit(mut args: IntoIter<String>) -> i32 { } fn show(mut _args: IntoIter<String>) -> i32 { - 0 + todo!() } fn rm(mut args: IntoIter<String>) -> i32 { diff --git a/lib/mihi/src/lib.rs b/lib/mihi/src/lib.rs index 7a5bfc9..7d5cc8c 100644 --- a/lib/mihi/src/lib.rs +++ b/lib/mihi/src/lib.rs @@ -533,3 +533,159 @@ 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<usize> for ExerciseKind { + type Error = &'static str; + + fn try_from(value: usize) -> Result<Self, Self::Error> { + match value { + 0 => Ok(Self::Pensum), + 1 => Ok(Self::Translation), + 2 => Ok(Self::Transformation), + 3 => Ok(Self::Numerical), + _ => Err("unknonwn kind!"), + } + } +} + +#[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 usize, + ], + ) { + 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, usize>(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 usize, + ], + ) { + 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}")), + } +} diff --git a/lib/mihi/src/migrate.rs b/lib/mihi/src/migrate.rs index 9b1e831..97f32c9 100644 --- a/lib/mihi/src/migrate.rs +++ b/lib/mihi/src/migrate.rs @@ -42,5 +42,28 @@ CREATE UNIQUE INDEX IF NOT EXISTS "index_words_on_enunciated" ON "words" ("enunc (), )?; + connection.execute( + r#" +CREATE TABLE IF NOT EXISTS "exercises" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "title" varchar NOT NULL, + "enunciate" text NOT NULL, + "solution" text NOT NULL, + "lessons" text NOT NULL, + "kind" integer DEFAULT 0, + "created_at" datetime(6) NOT NULL, + "updated_at" datetime(6) NOT NULL +); +"#, + (), + )?; + + connection.execute( + r#" +CREATE UNIQUE INDEX IF NOT EXISTS "index_exercises_on_title" ON "exercises" ("title"); +"#, + (), + )?; + Ok(0) } |
