use std::{collections::HashMap, path::Path};
use alix::{
assemble::{load_decks, open_store, store_path_for},
card::Card,
config::Config,
deck::{Deck, DeckState},
depth::Depth,
scheduler::{Fsrs, Scheduler},
store::Store,
time::{humanize_ms, now_ms},
};
use anyhow::{Result, bail};
use crate::{
DeckArgs, ResetArgs,
common::{confirm, expand_target},
};
fn state_label(fsrs_state: Option<u8>) -> &'static str {
match fsrs_state {
Some(1) => "learning",
Some(2) => "review",
Some(3) => "relearning",
Some(_) => "new",
None => "-",
}
}
pub(crate) fn stats(args: DeckArgs) -> Result<()> {
let config = Config::load(None)?;
let now = now_ms();
let target = expand_target(&args.target)?;
for path in &target.decks {
let store = target.store_for_deck(path, args.store.as_deref())?;
let deck = Deck::load(path)?;
let review = config
.review
.for_workspace(path.parent().unwrap_or_else(|| Path::new("")));
let scheduler = Fsrs::new(review.retention);
let mut due_now = 0usize;
let mut due_24h = 0usize;
let mut due_now_reconstruct = 0usize;
let mut reviews = 0u32;
let mut passes = 0u32;
for card in &deck.cards {
if let Some(state) = store.get(card.id()) {
if !alix::session::is_retired(card, &store, review.retire_after_days) {
let due = scheduler.due_at(state, Depth::Recall);
if due <= now {
due_now += 1;
} else if due <= now + 86_400_000 {
due_24h += 1;
}
if scheduler.is_due(state, Depth::Reconstruct, now) {
due_now_reconstruct += 1;
}
}
reviews += state.total_reviews;
passes += state.total_passes;
}
}
due_now += alix::session::count_reviewable_virtual(
&store,
&deck.subject,
&scheduler,
now,
review.retire_after_days,
);
let due_now_recall = due_now;
due_24h += alix::session::count_due_soon_virtual(
&store,
&deck.subject,
&scheduler,
now,
86_400_000,
review.retire_after_days,
);
let state = match deck.state(&store) {
DeckState::NotStarted => "not started",
DeckState::Started => "in progress",
DeckState::ExamDue => "exam due",
DeckState::Finished if store.deck_mastered(&deck.subject) => "mastered ✓",
DeckState::Finished => "finished ✓",
};
println!("{} ({} cards)", deck.display_name(), deck.cards.len());
println!(" state: {state}");
println!(" due: {due_now} now, {due_24h} within 24h");
println!(" due now (recall): {due_now_recall}");
println!(" due now (reconstruct): {due_now_reconstruct}");
if reviews > 0 {
println!(
" reviews: {reviews} total, {:.0}% passed",
100.0 * passes as f64 / reviews as f64
);
}
}
Ok(())
}
pub(crate) fn list(args: DeckArgs) -> Result<()> {
let config = Config::load(None)?;
let now = now_ms();
let target = expand_target(&args.target)?;
for path in &target.decks {
let store = target.store_for_deck(path, args.store.as_deref())?;
let deck = Deck::load(path)?;
let review = config
.review
.for_workspace(path.parent().unwrap_or_else(|| Path::new("")));
let scheduler = Fsrs::new(review.retention);
println!("{}", deck.display_name());
for card in &deck.cards {
let (recall_label, recon_label, recognized_mark, due) = match store.get(card.id()) {
Some(state) => {
let due = if alix::session::is_retired(card, &store, review.retire_after_days) {
"resting".to_string()
} else {
let due = scheduler.due_at(state, Depth::Recall);
if due <= now {
"due now".to_string()
} else {
format!("due in {}", humanize_ms(due - now))
}
};
let recall_label = state_label(state.recall.as_ref().map(|f| f.state));
let recon_label = state_label(state.reconstruct.as_ref().map(|f| f.state));
let recognized_mark = if state.recognized_ms.is_some() {
"✓"
} else {
" "
};
(recall_label, recon_label, recognized_mark, due)
}
None => (state_label(None), state_label(None), " ", "-".to_string()),
};
let front: String = card.front.chars().take(60).collect();
println!(" [{recall_label:>10}|{recon_label:>10}]{recognized_mark} {front:<60} {due}");
}
}
Ok(())
}
fn select_reset_ids(cards: &[Card], card: Option<&str>) -> Vec<(u64, String)> {
let by_id = card.and_then(|c| c.parse::<u64>().ok());
let needle = card.map(str::to_lowercase);
cards
.iter()
.filter(|c| match (by_id, &needle) {
(Some(id), _) => c.id() == id,
(None, Some(text)) => c.front.to_lowercase().contains(text),
(None, None) => true,
})
.map(|c| (c.id(), c.front.clone()))
.collect()
}
pub(crate) fn reset(args: ResetArgs) -> Result<()> {
let mut store = open_store(args.store.clone())?;
if args.all {
let n = store.len();
if n == 0 {
println!("No stored progress to reset.");
return Ok(());
}
if !confirm(&format!("Reset progress for all {n} card(s)?"), args.yes)? {
println!("Cancelled.");
return Ok(());
}
store.clear();
store.save()?;
println!("Reset {n} card(s).");
return Ok(());
}
let numeric_id = args.card.as_deref().and_then(|c| c.parse::<u64>().ok());
if let Some(id) = numeric_id.filter(|_| args.target.is_none()) {
return reset_ids(
&mut store,
vec![(id, String::new())],
format!("card {id}"),
args.card.as_deref(),
false,
args.yes,
);
}
let Some(target_path) = &args.target else {
bail!("name a deck, folder, or workspace to reset, or pass `--card <id>` or `--all`");
};
let target = expand_target(target_path)?;
let deck_paths = target.decks.clone();
let mut store = open_store(
args.store
.clone()
.or_else(|| store_path_for(&deck_paths, None))
.or_else(|| target.default_store.clone()),
)?;
let (cards, label, _, _) = load_decks(&deck_paths, &HashMap::new())?;
if args.card.is_none() {
let decks_full: Vec<Deck> = deck_paths
.iter()
.map(Deck::load)
.collect::<Result<Vec<_>, _>>()?;
let present: Vec<(u64, String)> = decks_full
.iter()
.flat_map(|deck| &deck.cards)
.filter(|c| store.get(c.id()).is_some())
.map(|c| (c.id(), c.front.clone()))
.collect();
let mastered = decks_full
.iter()
.any(|deck| store.deck_mastered(&deck.subject));
let virtual_ids: Vec<u64> = decks_full
.iter()
.flat_map(|deck| store.virtual_cards_for(&deck.subject))
.map(|vc| vc.id)
.collect();
if present.is_empty() && !mastered && virtual_ids.is_empty() {
println!("No stored progress to reset in {label}.");
return Ok(());
}
let n = present.len();
if !confirm(
&format!("Reset progress for {n} card(s) in {label}?"),
args.yes,
)? {
println!("Cancelled.");
return Ok(());
}
let wiped = alix::library::reset_decks(&mut store, decks_full.iter())?;
println!("Reset {wiped} card(s).");
return Ok(());
}
let targets = select_reset_ids(&cards, args.card.as_deref());
reset_ids(
&mut store,
targets,
label,
args.card.as_deref(),
false,
args.yes,
)
}
fn reset_ids(
store: &mut Store,
targets: Vec<(u64, String)>,
scope: String,
card_query: Option<&str>,
from_picker: bool,
yes: bool,
) -> Result<()> {
let present: Vec<(u64, String)> = targets
.into_iter()
.filter(|(id, _)| store.get(*id).is_some())
.collect();
if present.is_empty() {
match card_query {
Some(query) => println!("No stored progress matching {query:?}."),
None => println!("No stored progress to reset in {scope}."),
}
return Ok(());
}
let n = present.len();
if !from_picker {
let what = if card_query.is_some() {
let fronts: Vec<String> = present
.iter()
.map(|(_, f)| f.chars().take(60).collect())
.filter(|f: &String| !f.is_empty())
.collect();
if fronts.is_empty() {
scope
} else {
fronts.join("; ")
}
} else {
format!("{n} card(s) in {scope}")
};
if !confirm(&format!("Reset progress for {what}?"), yes)? {
println!("Cancelled.");
return Ok(());
}
}
for (id, _) in &present {
store.remove(*id);
}
store.save()?;
println!("Reset {n} card(s).");
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
fn card(front: &str, back: &str) -> Card {
Card::plain(Arc::from("d.txt"), front.into(), vec![back.into()], None, 1)
}
#[test]
fn reset_selects_all_without_a_filter() {
let cards = vec![card("A", "1"), card("B", "2")];
assert_eq!(2, select_reset_ids(&cards, None).len());
}
#[test]
fn reset_matches_front_substring_case_insensitively() {
let cards = vec![
card("Capital of Japan?", "Tokyo"),
card("Largest planet?", "Jupiter"),
];
let got = select_reset_ids(&cards, Some("japan"));
assert_eq!(1, got.len());
assert_eq!("Capital of Japan?", got[0].1);
}
#[test]
fn reset_matches_a_numeric_id_exactly() {
let cards = vec![card("A", "1"), card("B", "2")];
let id = cards[1].id();
assert_eq!(
vec![(id, "B".to_string())],
select_reset_ids(&cards, Some(&id.to_string()))
);
}
#[test]
fn reset_front_match_resets_all_cards_sharing_it() {
let cards = vec![
card("verb forms", "a"),
card("verb forms", "b"),
card("noun", "c"),
];
let got = select_reset_ids(&cards, Some("verb forms"));
assert_eq!(2, got.len());
assert_ne!(got[0].0, got[1].0);
}
}