aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--crates/cli/src/exercises.rs254
-rw-r--r--crates/cli/src/main.rs5
-rw-r--r--crates/cli/src/words.rs4
-rw-r--r--lib/mihi/src/lib.rs156
-rw-r--r--lib/mihi/src/migrate.rs23
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)
}