use std::{
collections::{HashMap, HashSet},
path::{Path, PathBuf},
};
use alix::{
assemble::{load_decks, open_store, store_path_for},
card::Card,
config::Config,
deck::{Deck, DeckState},
depth::Depth,
scheduler::{Fsrs, Scheduler},
state,
store::Store,
time::{humanize_ms, now_ms},
workspace,
};
use anyhow::{Context, 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(args.config.as_deref())?;
let now = now_ms();
let target = expand_target(&args.target, &config)?;
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(&workspace::content_root(path));
let scheduler = Fsrs::new(review.retention, review.acquire_cooldown_ms);
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) = card.id().and_then(|id| store.progress(&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;
}
}
let deck_id = deck.deck_token.as_deref().unwrap_or_default();
due_now += alix::session::count_reviewable_virtual(
&store,
deck_id,
&scheduler,
now,
review.retire_after_days,
);
let due_now_recall = due_now;
due_24h += alix::session::count_due_soon_virtual(
&store,
deck_id,
&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_id) => "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(args.config.as_deref())?;
let now = now_ms();
let target = expand_target(&args.target, &config)?;
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(&workspace::content_root(path));
let scheduler = Fsrs::new(review.retention, review.acquire_cooldown_ms);
println!("{}", deck.display_name());
for card in &deck.cards {
let (recall_label, recon_label, recognized_mark, due) =
match card.id().and_then(|id| store.progress(&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<(String, String)> {
let stamped: Vec<(String, &Card)> = cards.iter().filter_map(|c| Some((c.id()?, c))).collect();
let exact = card.filter(|q| stamped.iter().any(|(id, _)| id == q));
let needle = card.filter(|_| exact.is_none()).map(str::to_lowercase);
stamped
.into_iter()
.filter(|(id, c)| match (exact, &needle) {
(Some(want), _) => id.as_str() == want,
(None, Some(text)) => c.front.to_lowercase().contains(text),
(None, None) => true,
})
.map(|(id, c)| (id, c.front.clone()))
.collect()
}
pub(crate) fn reset(args: ResetArgs) -> Result<()> {
let config = Config::load(args.config.as_deref())?;
if args.orphans {
return reset_orphans(&args, &config);
}
let mut store = open_store(
args.store
.clone()
.or_else(|| config.decks_dir().map(|d| workspace::root_store_path(&d))),
)?;
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 exact_id = args
.card
.as_deref()
.filter(|c| alix::token::parse_prefixed_card_id(c).is_some());
if let Some(id) = exact_id.filter(|_| args.target.is_none()) {
let id = id.to_string();
return reset_ids(
&mut store,
vec![(id.clone(), 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, &config)?;
let deck_paths = target.decks.clone();
let store_path = args
.store
.clone()
.or_else(|| store_path_for(&deck_paths, None))
.or_else(|| target.default_store.clone())
.or_else(alix::store::default_store_path)
.context("cannot determine the data directory")?;
let mut store = state::open_stores(&deck_paths, &store_path)?;
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<(String, String)> = decks_full
.iter()
.flat_map(|deck| &deck.cards)
.filter_map(|c| Some((c.id()?, c)))
.filter(|(id, _)| store.get(id).is_some())
.map(|(id, c)| (id, c.front.clone()))
.collect();
fn deck_id(deck: &Deck) -> &str {
deck.deck_token.as_deref().unwrap_or_default()
}
let mastered = decks_full
.iter()
.any(|deck| store.deck_mastered(deck_id(deck)));
let virtual_ids: Vec<String> = decks_full
.iter()
.flat_map(|deck| store.virtual_cards_for(deck_id(deck)))
.map(|vc| vc.id.clone())
.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_orphans(args: &ResetArgs, config: &Config) -> Result<()> {
let folder_scope = |dir: &Path| -> Result<(Vec<PathBuf>, PathBuf)> {
let store = args
.store
.clone()
.unwrap_or_else(|| workspace::root_store_path(dir));
let (initialized, uninitialized) = workspace::classified_deck_files(dir)
.context("refusing to judge orphans while the target cannot be listed")?;
Ok((
initialized.into_iter().chain(uninitialized).collect(),
store,
))
};
let (deck_paths, store_path) = match &args.target {
Some(target) if target.is_file() => {
let decks = vec![target.clone()];
let store = args
.store
.clone()
.or_else(|| store_path_for(&decks, None))
.or_else(|| config.decks_dir().map(|d| workspace::root_store_path(&d)))
.or_else(alix::store::default_store_path)
.context("cannot determine the data directory")?;
(decks, store)
}
Some(target) if target.is_dir() => folder_scope(target)?,
Some(target) => bail!("`{}` is neither a deck file nor a folder", target.display()),
None => folder_scope(&config.decks_dir().context("cannot determine ~/decks")?)?,
};
let mut known_cards: HashSet<String> = HashSet::new();
let mut known_deck_tokens: HashSet<String> = HashSet::new();
for path in &deck_paths {
let deck = Deck::load(path)
.context("refusing to judge orphans while a deck in the target cannot be read")?;
known_deck_tokens.extend(deck.deck_token.clone());
known_cards.extend(deck.cards.iter().filter_map(Card::id));
}
let mut store = match args.target.as_deref() {
Some(deck) if deck.is_file() => state::open_store(deck, &store_path)?,
_ => state::open_aggregate_store(&store_path)?,
};
let orphans = store.orphans(&known_cards, &known_deck_tokens);
if orphans.is_empty() {
println!("No orphaned progress to reset.");
return Ok(());
}
let n = orphans.len();
if !confirm(
&format!("Reset {n} orphaned key(s) (matching no known card or deck)?"),
args.yes,
)? {
println!("Cancelled.");
return Ok(());
}
let removed = store.prune_orphans(&orphans);
store.save()?;
println!("Reset {removed} orphaned key(s).");
Ok(())
}
fn reset_ids(
store: &mut Store,
targets: Vec<(String, String)>,
scope: String,
card_query: Option<&str>,
from_picker: bool,
yes: bool,
) -> Result<()> {
let present: Vec<(String, 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 {
let mut c = Card::plain(Arc::from("d.md"), front.into(), vec![back.into()], None, 1);
let slug: String = back
.chars()
.filter(|ch| ch.is_ascii_alphanumeric())
.collect::<String>()
.to_ascii_lowercase();
c.token = Some(Arc::from(format!("q{slug}").as_str()));
c
}
#[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_card_id_exactly() {
let cards = vec![card("A", "1"), card("B", "2")];
let id = cards[1].id().unwrap();
assert_eq!(
vec![(id.clone(), "B".to_string())],
select_reset_ids(&cards, Some(&id))
);
}
#[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);
}
}