diff --git a/Cargo.toml b/Cargo.toml index 9c778ec..6e8fbe7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ serde_json = "1" anyhow = "1" rand = "0.8" lazy_static = "1" -rhai = { version = "1.14.0", features = ["serde", "sync"] } +rhai = { version = "1.14.0", features = ["serde", "sync", "internals"] } include_dir = "0.7.3" uuid = { version = "1.3.2", features = ["v4", "serde"] } diff --git a/src/bin/main.rs b/src/bin/main.rs index 89a482d..ffec206 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -24,27 +24,41 @@ fn main() -> anyhow::Result<()> { println!("New set created!"); }, - Command::Learn { set: set_file, method, ty, count } => { + Command::Learn { set: set_file, method, ty, count, reset } => { let json = fs::read_to_string(&set_file).with_context(|| "failed to read from set file")?; let set = Set::from_json(&json)?; let mut california = California::from_set(set); let method = method_from_string(method)?; + if reset && confirm("Are you absolutely certain you want to reset your learn progress? This action is IRREVERSIBLE!!!")? { + california.reset_learn(method.clone())?; + } else { + println!("Continuing with previous progress..."); + } let mut driver = california .learn(method)?; - driver.set_target(ty) - .set_max_count(count); + driver.set_target(ty); + if let Some(count) = count { + driver.set_max_count(count); + } let num_reviewed = drive(driver, &set_file)?; - println!("\nLearn session complete! You reviewed {} cards.", num_reviewed); + println!("\nLearn session complete! You reviewed {} card(s).", num_reviewed); }, - Command::Test { set: set_file, static_test, no_star, no_unstar, ty, count } => { + Command::Test { set: set_file, static_test, no_star, no_unstar, ty, count, reset } => { let json = fs::read_to_string(&set_file).with_context(|| "failed to read from set file")?; let set = Set::from_json(&json)?; let mut california = California::from_set(set); + if reset && confirm("Are you sure you want to reset your test progress?")? { + california.reset_test(); + } else { + println!("Continuing with previous progress..."); + } let mut driver = california .test(); - driver.set_target(ty) - .set_max_count(count); + driver.set_target(ty); + if let Some(count) = count { + driver.set_max_count(count); + } if static_test { driver.no_mark_starred().no_mark_unstarred(); } else if no_star { @@ -54,7 +68,7 @@ fn main() -> anyhow::Result<()> { } let num_reviewed = drive(driver, &set_file)?; - println!("\nTest complete! You reviewed {} cards.", num_reviewed); + println!("\nTest complete! You reviewed {} card(s).", num_reviewed); }, Command::List { set, ty } => { @@ -185,10 +199,42 @@ fn drive<'a>(mut driver: california::Driver<'a, 'a>, set_file: &str) -> anyhow:: // This will adjust weights etc. and get us a new card, if one exists card_option = driver.next(res)?; } + stdout.reset()?; + let json = driver.save_set_to_json()?; + fs::write(set_file, json).with_context(|| "failed to save set to json (progress up to the previous card was saved though)")?; Ok(driver.get_count()) } +/// Asks the user to confirm something with the given message. +#[cfg(feature = "cli")] +fn confirm(message: &str) -> anyhow::Result { + use std::io::{self, Write}; + use anyhow::bail; + + let stdin = io::stdin(); + let mut stdout = io::stdout(); + print!("{} [y/n] ", message); + stdout.flush()?; + let mut input = String::new(); + let res = match stdin.read_line(&mut input) { + Ok(_) => { + let input = input.strip_suffix("\n").unwrap_or(&input); + if input == "y" { + true + } else if input == "n" { + false + } else { + println!("Invalid option!"); + confirm(message)? + } + } + Err(_) => bail!("failed to read from stdin"), + }; + + Ok(res) +} + #[cfg(feature = "cli")] mod opts { use std::path::PathBuf; @@ -231,7 +277,10 @@ mod opts { ty: CardType, /// Limit the number of terms studied to the given amount (useful for consistent long-term learning); your progress will be saved #[arg(short, long)] - count: u32, + count: Option, + /// Starts a new learn session from scratch, irretrievably deleting any progress in a previous session + #[arg(long)] + reset: bool, }, /// Starts or resumes a test on the given set Test { @@ -252,7 +301,10 @@ mod opts { ty: CardType, /// Limit the number of terms studied to the given amount (useful for consistent long-term learning); your progress will be saved #[arg(short, long)] - count: u32, + count: Option, + /// Starts a new test from scratch, irretrievably deleting any progress in a previous test + #[arg(long)] + reset: bool, }, /// Lists all the terms in the given set List { @@ -263,7 +315,6 @@ mod opts { ty: CardType, }, } - } @@ -596,28 +647,5 @@ impl Set { } } -/// Asks the user to confirm something with the given message. -fn confirm(message: &str) -> Result { - let stdin = io::stdin(); - let mut stdout = io::stdout(); - print!("{} [y/n] ", message); - stdout.flush()?; - let mut input = String::new(); - let res = match stdin.read_line(&mut input) { - Ok(_) => { - let input = input.strip_suffix("\n").unwrap_or(&input); - if input == "y" { - true - } else if input == "n" { - false - } else { - println!("Invalid option!"); - confirm(message)? - } - } - Err(_) => bail!("failed to read from stdin"), - }; - Ok(res) -} */ diff --git a/src/driver.rs b/src/driver.rs index abfe5d9..5add3de 100644 --- a/src/driver.rs +++ b/src/driver.rs @@ -163,11 +163,10 @@ impl<'e, 's> Driver<'e, 's> { let mut cards_with_ids = self.set.cards.iter().collect::>(); let (card_id, card) = match cards_with_ids.choose_weighted_mut(&mut rng, |(_, card): &(&Uuid, &Card)| { if let Some(method) = &self.method { - let card = (*card).clone(); let res = match &self.target { - CardType::All => (method.get_weight)(card), - CardType::Starred if card.starred => (method.get_weight)(card), - CardType::Difficult if card.difficult => (method.get_weight)(card), + CardType::All => (method.get_weight)(card.method_data.clone(), card.difficult), + CardType::Starred if card.starred => (method.get_weight)(card.method_data.clone(), card.difficult), + CardType::Difficult if card.difficult => (method.get_weight)(card.method_data.clone(), card.difficult), _ => Ok(0.0) }; // TODO handle errors (very realistic that they would occur with custom scripts!) @@ -184,11 +183,13 @@ impl<'e, 's> Driver<'e, 's> { Ok(data) => data, // We're done! Err(WeightedError::AllWeightsZero) => { - // If we've genuinely finished, say so (but tests will never finish a set in this way) - if self.method.is_some() { + // If we've genuinely finished, say so + if let Some(method) = &self.method { self.set.run_state = None; + self.set.reset_learn((method.get_default_metadata)()?); } else { self.set.test_in_progress = false; + self.set.reset_test(); } return Ok(None); @@ -224,7 +225,7 @@ impl<'e, 's> Driver<'e, 's> { /// you should call `.first()` instead, as calling this will lead to an error. Note that the provided response /// must be *identical* to one of the responses defined by the method in use (these can be found with `.allowed_responses()`). pub fn next(&mut self, response: String) -> Result> { - if self.allowed_responses().iter().any(|x| x == &response) { + if !self.allowed_responses().iter().any(|x| x == &response) { bail!("invalid user response to card"); } @@ -232,19 +233,19 @@ impl<'e, 's> Driver<'e, 's> { // We know this element exists (we hold the only mutable reference to the set) let card = self.set.cards.get_mut(card_id).unwrap(); if let Some(method) = &self.method { - let (method_data, difficult) = (method.adjust_card)(response, card.clone())?; + let (method_data, difficult) = (method.adjust_card)(response, card.method_data.clone(), card.difficult)?; card.method_data = method_data; if self.mutate_difficulty { card.difficult = difficult; } } else { card.seen_in_test = true; - // TODO Allow this behaviour to be disabled + if response == "n" && self.mark_starred { card.starred = true; } else if response == "y" && self.mark_unstarred { card.starred = false; - } else { unreachable!() } + } // Prevent this card from being double-adjusted if there's an error later self.latest_card = None; diff --git a/src/lib.rs b/src/lib.rs index e1a16b9..b870807 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -67,12 +67,24 @@ impl California { pub fn save_set(&self) -> Result { self.set.save() } + /// Resets all cards in a learn session back to the default metadata values prescribed by the learning method. + pub fn reset_learn(&mut self, method: RawMethod) -> Result<()> { + let method = method.into_method(&self.rhai_engine)?; + self.set.reset_learn((method.get_default_metadata)()?); + + Ok(()) + } + /// Resets all test progress for this set. This is irreversible! + /// + /// This will not change whether or not cards are starred. + pub fn reset_test(&mut self) { + self.set.reset_test(); + } /// Creates a Rhai engine with the utilities California provides all pre-registered. fn create_engine() -> Engine { // TODO regexp utilities - let mut engine = Engine::new(); - engine.register_type_with_name::("Card"); + let engine = Engine::new(); engine } } diff --git a/src/methods/mod.rs b/src/methods/mod.rs index d7ea526..7e2ea86 100644 --- a/src/methods/mod.rs +++ b/src/methods/mod.rs @@ -1,7 +1,6 @@ -use anyhow::{Result, Context, bail}; +use anyhow::{Result, Context, bail, anyhow}; use include_dir::{Dir, include_dir}; -use rhai::{Dynamic, Scope, Engine, AST}; -use crate::set::Card; +use rhai::{Dynamic, Scope, Engine, AST, Array}; /// The `src/methods` directory that includes this file. static METHODS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/methods"); @@ -16,18 +15,18 @@ pub struct Method<'e> { /// A list of responses the user can give after having been shown the answer to a card. These will /// be displayed as options in the order they are provided in here. pub responses: Vec, - /// A closure that, given a card, produces a weight. This weight represents how - /// likely the card is to be presented to the user in the next random choice. When a card is finished + /// A closure that, given a card's metadata and whether or not it has been marked as difficult, produces a weight. + /// This weight represents how likely the card is to be presented to the user in the next random choice. When a card is finished /// with, this should be set to 0.0. When all cards have a weight 0.0, the run will naturally terminate. /// /// Any cards not part of the relevant run target will not be presented to this function in the first /// place. - pub get_weight: Box Result + Send + Sync + 'e>, - /// A closure that, given a card and the user's response to it, returns the new dynamic method state and - /// whether or not this card should be marked as difficult. + pub get_weight: Box Result + Send + Sync + 'e>, + /// A closure that, given the user's response to a card, the card's metadata, and whether or not the card has been marked + /// as difficult, returns new metadata and whether or not the card should now be marked as difficult. /// - /// Note that learn runs do not have the authority to mark cards as starred. - pub adjust_card: Box Result<(Dynamic, bool)> + Send + Sync + 'e>, + /// Note that learn runs do not have the authority to mark cards as starred, or even determine whether or not they are. + pub adjust_card: Box Result<(Dynamic, bool)> + Send + Sync + 'e>, /// A closure that produces the default metadata for this method. This is used when a new set is created for /// this method to initialise all its cards with metadata that is appropriate to this method. Generally, /// methods should keep this as small as possible to minimise the size of sets on-disk. @@ -81,25 +80,42 @@ impl<'e> Method<'e> { // Extract the closures directly (using the shared engine) let ast1 = ast.clone(); let ast2 = ast.clone(); - let get_weight = Box::new(move |card| { - engine.call_fn(&mut Scope::new(), &ast, "get_weight", (card,)).with_context(|| "failed to get weight for card (this is a bug in the selected learning method)") + let ast3 = ast.clone(); + let get_weight = Box::new(move |method_data, difficult| { + engine.call_fn(&mut Scope::new(), &ast, "get_weight", (method_data, difficult)).with_context(|| "failed to get weight for card (this is a bug in the selected learning method)") }); - let adjust_card = Box::new(move |res, card| { - engine.call_fn(&mut Scope::new(), &ast1, "adjust_card", (res, card)).with_context(|| "failed to adjust card data for last card (this is a bug in the selected learning method)") + let adjust_card = Box::new(move |res, method_data, difficult| { + let res: Array = engine.call_fn(&mut Scope::new(), &ast1, "adjust_card", (res, method_data, difficult)).with_context(|| "failed to adjust card data for last card (this is a bug in the selected learning method)")?; + let method_data = res.get(0).ok_or(anyhow!("no method data provided from card adjustment (this is a bug in the selected learning method)"))?; + let difficult = res.get(1).ok_or(anyhow!("no difficulty boolean provided from card adjustment (this is a bug in the selected learning method)"))?.as_bool().map_err(|_| anyhow!("invalid difficulty boolean provided from card adjustment (this is a bug in the selected learning method)"))?; + + Ok((method_data.clone(), difficult)) }); let get_default_metadata = Box::new(move || { engine.call_fn(&mut Scope::new(), &ast2, "get_default_metadata", ()).with_context(|| "failed to get default metadata for a new card (this is a bug in the selected learning method)") }); - // Assemble all that into a method - Ok(Method { - name: method_name.to_string(), - // TODO - responses: Vec::new(), - get_weight, - adjust_card, - get_default_metadata, - }) + // Iterate through all literal constants and find `RESPONSES` + let mut responses = None; + for (name, _, value) in ast3.iter_literal_variables(true, false) { + if name == "RESPONSES" { + let value = value.into_typed_array().map_err(|_| anyhow!("required constant `RESPONSES` in method script was not an array of strings"))?; + responses = Some(value); + } + } + + if let Some(responses) = responses { + // Assemble all that into a method + Ok(Method { + name: method_name.to_string(), + responses, + get_weight, + adjust_card, + get_default_metadata, + }) + } else { + bail!("method script did not define required constant `RESPONSES`"); + } } /// Determines if the given method name is inbuilt. This may be unwittingly provided a full method script as well. fn is_inbuilt(method: &str) -> bool { @@ -112,6 +128,7 @@ impl<'e> Method<'e> { } /// A representation of a method that has not yet been created. +#[derive(Clone, Debug)] pub enum RawMethod { /// An inbuilt method, with the name attached. Inbuilt(String), diff --git a/src/methods/speed-v1.rhai b/src/methods/speed-v1.rhai index b165d0a..43c7dcc 100644 --- a/src/methods/speed-v1.rhai +++ b/src/methods/speed-v1.rhai @@ -1,16 +1,16 @@ const RESPONSES = ["y", "n"]; -fn get_weight(card) { - return card.method_data.weight; +fn get_weight(data, difficult) { + return data.weight; } -fn adjust_card(res, card) { +fn adjust_card(res, data, difficult) { if res == "y" { - card.method_data.weight -= 0.5; + data.weight -= 0.5; } else { - card.method_data.weight += 0.5; + data.weight += 0.5; } - return [card, false]; + return [data, false]; } fn get_default_metadata() { return #{ weight: 1.0 }; diff --git a/src/set.rs b/src/set.rs index 572ee3a..cb39b45 100644 --- a/src/set.rs +++ b/src/set.rs @@ -6,7 +6,7 @@ use anyhow::Result; use uuid::Uuid; /// A single key-value pair that represents an element in the set. -#[derive(Serialize, Deserialize, Clone)] // Only internal cloning +#[derive(Serialize, Deserialize)] pub struct Card { /// The prompt the user will be given for this card. pub question: String, @@ -92,10 +92,16 @@ impl Set { let set = serde_json::from_str(&json)?; Ok(set) } + /// Resets all cards in a learn back to the default metadata values prescribed by the learning method. + pub(crate) fn reset_learn(&mut self, default_data: Dynamic) { + for card in self.cards.values_mut() { + card.method_data = default_data.clone(); + } + } /// Resets all test progress for this set. This is irreversible! /// /// This will not change whether or not cards are starred. - pub fn reset_test(&mut self) { + pub(crate) fn reset_test(&mut self) { for card in self.cards.values_mut() { card.seen_in_test = false; }