use std::{
collections::{BTreeSet, HashMap, HashSet},
hash::Hasher,
net::SocketAddr,
path::{Path, PathBuf},
sync::{
Arc,
mpsc::{Receiver, TryRecvError},
},
time::Instant,
};
use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use tiny_http::{Header, Method, Request, Response, Server};
use twox_hash::XxHash64;
use crate::{
answer::{Input, Mode, TypedResult, grade_lines_ordered, grade_lines_unordered, mode_name},
ask::{self, CliSession, Exchange, Reply},
augment::{self, AugmentCache},
card::Card,
choice::{self, ChoiceQuestion},
config::{
AiConfig, AskConfig, Bindings, BrowseBindings, ExamConfig, Key, KeyPattern, PickerKeys,
ReviewConfig, Strictness,
},
deck::{self, Deck, DeckState},
depth::{self, Depth, Reveal, depth_name},
exam, picker,
recent::RecentDecks,
render::{self, NoteUnit},
scheduler::{Fsrs, Grade, keypoint_grade},
session::{Session, now_ms},
store::{self, Store},
trace::{self, Delta, Excerpt, Phase, SourceBase, Walk},
};
struct DeckFiles {
paths: HashMap<String, PathBuf>,
snapshots: HashMap<String, String>,
removed: HashMap<String, BTreeSet<usize>>,
}
impl DeckFiles {
fn new(paths: HashMap<String, PathBuf>) -> Self {
let snapshots = paths
.iter()
.filter_map(|(subject, path)| {
std::fs::read_to_string(path)
.ok()
.map(|text| (subject.clone(), text))
})
.collect();
Self {
paths,
snapshots,
removed: HashMap::new(),
}
}
fn append_note(&mut self, subject: &str, line: usize, notes: &[String]) -> Result<(), String> {
let path = self
.paths
.get(subject)
.ok_or_else(|| format!("no deck file known for {subject}"))?;
deck::append_note(path, line, notes).map_err(|e| e.to_string())?;
if let Ok(text) = std::fs::read_to_string(path) {
self.snapshots.insert(subject.to_string(), text);
}
Ok(())
}
fn remove_block(&mut self, subject: &str, line: usize) {
let lines = self.removed.entry(subject.to_string()).or_default();
lines.insert(line);
if let (Some(path), Some(original)) = (self.paths.get(subject), self.snapshots.get(subject))
{
let lines: Vec<usize> = lines.iter().copied().collect();
if let Err(e) = deck::rewrite_without_cards(path, original, &lines) {
eprintln!("warning: could not update {}: {e}", path.display());
}
}
}
}
const REVIEW_HTML: &str = include_str!("../assets/serve/review.html");
const THEME_CSS: &str = include_str!("../assets/serve/theme.css");
const THEME_JS: &str = include_str!("../assets/serve/theme.js");
const ALIX_LOGO_JS: &str = include_str!("../assets/serve/alix-logo.js");
const HEAD_HTML: &str = include_str!("../assets/serve/_head.html");
const BRAND_HTML: &str = include_str!("../assets/serve/_brand.html");
static REVIEW_PAGE: std::sync::LazyLock<String> =
std::sync::LazyLock::new(|| compose_page(REVIEW_HTML));
fn compose_page(html: &str) -> String {
html.replace("<!--%head%-->", HEAD_HTML)
.replace("<!--%brand%-->", BRAND_HTML)
}
#[derive(Debug, Serialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
enum NoteUnitDto {
Sentence { text: String },
Code { lines: Vec<String> },
}
impl From<NoteUnit> for NoteUnitDto {
fn from(unit: NoteUnit) -> Self {
match unit {
NoteUnit::Sentence(text) => NoteUnitDto::Sentence { text },
NoteUnit::Code(lines) => NoteUnitDto::Code { lines },
}
}
}
#[derive(Debug, Serialize)]
struct CardDto {
front: String,
context: Vec<String>,
back: Vec<String>,
reshaped: bool,
note: Vec<NoteUnitDto>,
img: Option<String>,
img_back: Option<String>,
at: Option<String>,
citation: Option<ExcerptDto>,
citation_error: Option<String>,
crumb: Option<CrumbDto>,
}
#[derive(Debug, Serialize)]
struct CrumbDto {
regions: Vec<String>,
current: usize,
cells: Vec<Vec<f32>>,
}
#[derive(Debug, Serialize, Default)]
struct DeckTopologyDto {
topologies: Vec<TopologyInfoDto>,
deck_due: usize,
}
#[derive(Debug, Serialize)]
struct TopologyInfoDto {
name: String,
principle: String,
regions: Vec<RegionInfoDto>,
}
#[derive(Debug, Serialize)]
struct RegionInfoDto {
name: String,
cells: Vec<f32>,
due: usize,
}
#[derive(Debug, Serialize)]
struct StateDto {
kind: &'static str,
phase: &'static str,
card: Option<CardDto>,
choices: Option<Vec<String>>,
keypoints: Option<Vec<String>>,
acquire: bool,
mode: &'static str,
depth: &'static str,
input: &'static str,
remaining: usize,
initial: usize,
reviews: usize,
passed: usize,
failed: usize,
exam_due: Vec<String>,
can_restart: bool,
promotable: bool,
label: String,
}
#[derive(Debug, Serialize)]
struct BrowseDto {
phase: &'static str,
label: String,
cards: Vec<CardDto>,
}
#[derive(Debug, Serialize)]
struct DeckListDto {
workspaces: Vec<DeckItemDto>,
recent: Vec<DeckItemDto>,
folders: Vec<DeckItemDto>,
}
#[derive(Debug, Serialize)]
struct DeckItemDto {
name: String,
label: String,
meta: Option<String>,
state: &'static str,
locked: bool,
reviewable: bool,
reviewable_recognize: bool,
reviewable_recall: bool,
reviewable_reconstruct: bool,
mastered: bool,
is_trace: bool,
examable: bool,
has_exam: bool,
recent: bool,
is_workspace: bool,
description: Option<String>,
members: Vec<MemberDto>,
path: Option<String>,
icon: Option<String>,
icon_svg: bool,
has_topology: bool,
badge_depth: Option<&'static str>,
badge_dotted: bool,
new_cards: bool,
last_depth: &'static str,
}
#[derive(Debug, Serialize)]
struct MemberDto {
name: String,
label: String,
meta: Option<String>,
state: &'static str,
locked: bool,
reviewable: bool,
reviewable_recognize: bool,
reviewable_recall: bool,
reviewable_reconstruct: bool,
mastered: bool,
is_trace: bool,
examable: bool,
has_exam: bool,
indent: usize,
tree: String,
has_topology: bool,
badge_depth: Option<&'static str>,
badge_dotted: bool,
new_cards: bool,
last_depth: &'static str,
}
#[derive(Debug, Serialize)]
struct ChooseFeedbackDto {
chosen: usize,
correct: usize,
passed: bool,
}
#[derive(Debug, Serialize)]
struct CheckFeedbackDto {
results: Vec<TypedResult>,
passed: bool,
}
#[derive(Debug, Serialize)]
struct ExchangeDto {
q: String,
a: String,
}
#[derive(Debug, Serialize)]
struct AskDto {
transcript: Vec<ExchangeDto>,
thinking: bool,
status: Option<String>,
error: Option<String>,
}
#[derive(Debug, Serialize)]
struct AskInfoDto {
model: String,
effort: String,
}
#[derive(Serialize)]
struct VersionDto {
version: &'static str,
}
impl AskInfoDto {
fn from(cfg: &AskConfig) -> Self {
let or_default = |s: &Option<String>| s.clone().unwrap_or_else(|| "default".to_string());
Self {
model: or_default(&cfg.model),
effort: or_default(&cfg.effort),
}
}
}
#[derive(Debug, Serialize)]
struct KeyDto {
k: String,
ctrl: bool,
}
fn key_dto(p: &KeyPattern) -> KeyDto {
let k = match p.key {
Key::Char(' ') => " ".to_string(),
Key::Char(c) => c.to_string(),
Key::Enter => "Enter".to_string(),
Key::Tab => "Tab".to_string(),
Key::Esc => "Escape".to_string(),
Key::Backspace => "Backspace".to_string(),
};
KeyDto { k, ctrl: p.ctrl }
}
fn key_list(list: &[KeyPattern]) -> Vec<KeyDto> {
list.iter().map(key_dto).collect()
}
#[derive(Debug, Serialize)]
struct ReviewKeys {
reveal: Vec<KeyDto>,
failed: Vec<KeyDto>,
partly: Vec<KeyDto>,
passed: Vec<KeyDto>,
skip: Vec<KeyDto>,
remove: Vec<KeyDto>,
restart: Vec<KeyDto>,
ask: Vec<KeyDto>,
save_note: Vec<KeyDto>,
}
impl ReviewKeys {
fn from(b: &Bindings) -> Self {
Self {
reveal: key_list(&b.reveal),
failed: key_list(&b.failed),
partly: key_list(&b.partly),
passed: key_list(&b.passed),
skip: key_list(&b.skip),
remove: key_list(&b.remove),
restart: key_list(&b.restart),
ask: key_list(&b.ask),
save_note: key_list(&b.save_note),
}
}
}
#[derive(Debug, Serialize)]
struct PickerKeysDto {
up: Vec<KeyDto>,
down: Vec<KeyDto>,
open: Vec<KeyDto>,
back: Vec<KeyDto>,
filter: Vec<KeyDto>,
mastered: Vec<KeyDto>,
depth: Vec<KeyDto>,
recognize: Vec<KeyDto>,
recall: Vec<KeyDto>,
reconstruct: Vec<KeyDto>,
}
impl PickerKeysDto {
fn from(k: &PickerKeys) -> Self {
Self {
up: key_list(&k.up),
down: key_list(&k.down),
open: key_list(&k.open),
back: key_list(&k.back),
filter: key_list(&k.filter),
mastered: key_list(&k.mastered),
depth: key_list(&k.depth),
recognize: key_list(&k.recognize),
recall: key_list(&k.recall),
reconstruct: key_list(&k.reconstruct),
}
}
}
#[derive(Debug, Serialize)]
struct BrowseKeys {
next: Vec<KeyDto>,
prev: Vec<KeyDto>,
remove: Vec<KeyDto>,
}
impl BrowseKeys {
fn from(b: &BrowseBindings) -> Self {
Self {
next: key_list(&b.next),
prev: key_list(&b.prev),
remove: key_list(&b.remove),
}
}
}
pub struct ReviewOptions {
pub keys: Bindings,
pub picker: PickerKeys,
pub browse: BrowseBindings,
pub ask: AskConfig,
pub exam: ExamConfig,
pub ai: AiConfig,
pub review: ReviewConfig,
pub auth: Option<String>,
}
pub struct SessionBuild {
pub session: Session,
pub label: String,
pub decks: HashMap<String, PathBuf>,
pub links: HashMap<String, Vec<String>>,
pub source_roots: HashMap<String, PathBuf>,
pub source_bases: HashMap<String, SourceBase>,
pub topology_name: Option<String>,
}
pub struct WalkBuild {
pub walk: Walk,
}
pub struct CardsBuild {
pub cards: Vec<Card>,
pub label: String,
pub decks: HashMap<String, PathBuf>,
}
pub enum Launch {
Review(Box<SessionBuild>),
Browse(CardsBuild),
Picker,
}
struct Reviewing {
session: Session,
label: String,
files: DeckFiles,
images: HashMap<String, PathBuf>,
ask: Ask,
links: HashMap<String, Vec<String>>,
source_roots: HashMap<String, PathBuf>,
source_bases: HashMap<String, SourceBase>,
augment: AugmentCache,
present_seq: u64,
original_fronts: HashMap<u64, String>,
topology_name: Option<String>,
}
struct Pending {
rx: Receiver<Reply>,
purpose: Purpose,
card: Card,
}
enum Purpose {
Question(String),
Condense,
}
struct Ask {
cli: CliSession,
transcript: Vec<Exchange>,
subject: Option<u64>,
pending: Option<Pending>,
}
impl Ask {
fn new() -> Self {
Self {
cli: CliSession::new(),
transcript: Vec::new(),
subject: None,
pending: None,
}
}
fn align(&mut self, subject: Option<u64>) {
if self.subject != subject {
self.transcript.clear();
self.subject = subject;
}
}
fn dto(&self, status: Option<String>, error: Option<String>) -> AskDto {
AskDto {
transcript: self
.transcript
.iter()
.map(|(q, a)| ExchangeDto {
q: q.clone(),
a: a.clone(),
})
.collect(),
thinking: self.pending.is_some(),
status,
error,
}
}
fn start(
&mut self,
cfg: &AskConfig,
card: &Card,
links: &[String],
root: Option<&Path>,
frozen: Option<&str>,
question: Option<String>,
) -> bool {
if self.pending.is_some() {
return false;
}
self.align(Some(card.id()));
if question.is_none() && self.transcript.is_empty() {
return false;
}
let run_cfg = match root {
Some(r) => ask::with_source_root(cfg, r),
None => cfg.clone(),
};
let args = self.cli.args_in(run_cfg.cwd.as_deref());
let keeps_session = crate::backend::backend_for(&run_cfg)
.map(|b| b.supports_session())
.unwrap_or(false);
let (prompt, purpose) = match question {
Some(q) => {
let prompt = if keeps_session {
ask::question_prompt(card, links, &q, !self.cli.started, root, frozen)
} else {
ask::question_prompt_with_history(
card,
links,
&self.transcript,
&q,
root,
frozen,
)
};
(prompt, Purpose::Question(q))
}
None => (
ask::condense_prompt(card, &self.transcript),
Purpose::Condense,
),
};
let rx = ask::spawn(run_cfg, prompt, args);
self.pending = Some(Pending {
rx,
purpose,
card: card.clone(),
});
true
}
fn poll(
&mut self,
save: impl FnOnce(&Card, &[String]) -> Result<(), String>,
) -> (Option<String>, Option<String>) {
let reply = match &self.pending {
None => return (None, None),
Some(p) => match p.rx.try_recv() {
Ok(reply) => reply,
Err(TryRecvError::Empty) => return (None, None),
Err(TryRecvError::Disconnected) => {
Reply::Error("the ask helper exited unexpectedly".to_string())
}
},
};
let pending = self.pending.take().expect("pending was present");
match (reply, pending.purpose) {
(Reply::Answer(answer), Purpose::Question(question)) => {
self.cli.started = true; self.transcript.push((question, answer));
(None, None)
}
(Reply::Answer(text), Purpose::Condense) => {
self.cli.started = true;
let notes = ask::extract_note_lines(&text);
if notes.is_empty() {
return (Some("nothing to save".to_string()), None);
}
match save(&pending.card, ¬es) {
Ok(()) => (Some("note saved".to_string()), None),
Err(e) => (None, Some(e)),
}
}
(Reply::Error(e), _) => {
self.cli = CliSession::new();
(None, Some(e))
}
}
}
}
impl Reviewing {
fn new(build: SessionBuild) -> Self {
let images = collect_images(build.session.cards());
Self {
session: build.session,
label: build.label,
files: DeckFiles::new(build.decks),
images,
ask: Ask::new(),
links: build.links,
source_roots: build.source_roots,
source_bases: build.source_bases,
augment: AugmentCache::open(Path::new("")),
present_seq: now_ms(),
original_fronts: HashMap::new(),
topology_name: build.topology_name,
}
}
fn open_augment(&mut self, store_path: &Path) {
self.augment = AugmentCache::open(augment::augment_path_for(store_path));
}
fn rotate_variant(&mut self) {
let Some(id) = self.session.current().map(|c| c.id()) else {
return;
};
if self.augment.variants(id).is_none() {
return;
}
if !self.original_fronts.contains_key(&id)
&& let Some(card) = self.session.current()
{
self.original_fronts.insert(id, card.front.clone());
}
let original = self.original_fronts.get(&id).cloned().unwrap_or_default();
let seed = self.present_seq;
self.present_seq = self.present_seq.wrapping_add(1);
if let Some(chosen) = self.augment.pick_front(id, &original, seed)
&& let Some(card) = self.session.current_mut()
{
card.front = chosen;
}
}
fn align_transcript(&mut self) {
self.ask.align(self.session.current().map(|c| c.id()));
}
fn ask_dto(&self, status: Option<String>, error: Option<String>) -> AskDto {
self.ask.dto(status, error)
}
fn start_ask(&mut self, cfg: &AskConfig, question: Option<String>) -> bool {
let Some(card) = self.session.current().cloned() else {
return false;
};
let links = self.links.get(&*card.subject).cloned().unwrap_or_default();
let root = self.source_roots.get(&*card.subject).map(|deck_root| {
card.origin
.as_deref()
.map(PathBuf::from)
.unwrap_or_else(|| deck_root.clone())
});
let frozen = root.as_ref().and_then(|_| {
let at = card.at.as_deref()?;
let base = self.source_bases.get(&*card.subject)?;
trace::frozen_excerpt_block(at, card.at_origin.as_deref(), base)
});
let live_root = root.as_deref().filter(|r| r.exists());
self.ask
.start(cfg, &card, &links, live_root, frozen.as_deref(), question)
}
fn poll_ask(&mut self) -> (Option<String>, Option<String>) {
self.align_transcript();
let Self {
ask,
files,
session,
..
} = self;
ask.poll(|card, notes| {
files.append_note(&card.subject, card.line, notes)?;
if let Some(cur) = session.current_mut()
&& cur.id() == card.id()
{
cur.append_note(notes);
}
Ok(())
})
}
}
struct Examining {
sitting: exam::Sitting,
deck_path: PathBuf,
}
#[derive(Serialize)]
struct ExamDto {
phase: &'static str,
deck: String,
strictness: &'static str,
total: usize,
current: usize,
question: Option<String>,
answer: String,
on_last: bool,
grades: Vec<ExamGradeDto>,
passed: Option<bool>,
gaps: Vec<String>,
can_remediate: bool,
remediated_count: Option<usize>,
is_trace: bool,
unlocks: Vec<String>,
thinking: bool,
error: Option<String>,
elapsed: Option<u64>,
}
#[derive(Serialize)]
struct ExamGradeDto {
question: String,
points: Vec<String>,
answer: String,
verdict: &'static str,
feedback: String,
missed: Vec<String>,
}
fn exam_phase_name(phase: &exam::Phase) -> &'static str {
match phase {
exam::Phase::Generating => "generating",
exam::Phase::Answering => "answering",
exam::Phase::Grading => "grading",
exam::Phase::Results => "results",
exam::Phase::Remediating => "remediating",
exam::Phase::Remediated => "remediated",
}
}
fn strictness_name(s: Strictness) -> &'static str {
match s {
Strictness::Strict => "strict",
Strictness::Balanced => "balanced",
Strictness::Lenient => "lenient",
}
}
fn exam_dto(ex: &Examining, decks_dir: &Path) -> ExamDto {
let s = &ex.sitting;
let result = s.result();
let grades = result
.map(|r| {
s.questions()
.iter()
.zip(s.answers())
.zip(&r.grades)
.map(|((q, a), g)| ExamGradeDto {
question: q.prompt.clone(),
points: q.points.clone(),
answer: a.clone(),
verdict: g.verdict.label(),
feedback: g.feedback.clone(),
missed: g.missed.clone(),
})
.collect()
})
.unwrap_or_default();
let passed = result.map(|r| r.passed);
let unlocks = if passed == Some(true) {
deck::dependents(&ex.deck_path, decks_dir)
} else {
Vec::new()
};
ExamDto {
phase: exam_phase_name(s.phase()),
deck: s.subject().to_string(),
strictness: strictness_name(s.strictness()),
total: s.total(),
current: s.current_index(),
question: s.question().map(|q| q.prompt.clone()),
answer: s.answer().to_string(),
on_last: s.on_last(),
grades,
passed,
gaps: s.gaps(),
can_remediate: s.can_remediate(),
remediated_count: s.remediated_count(),
is_trace: s.kind() == exam::SittingKind::Trace,
unlocks,
thinking: s.thinking(),
error: s.error().map(str::to_string),
elapsed: s.elapsed_secs(),
}
}
struct Augmenting {
deck: String,
cards: Vec<Card>,
deck_ids: HashSet<u64>,
cache: AugmentCache,
pending: Option<AugmentPending>,
error: Option<String>,
}
struct AugmentPending {
rx: Receiver<Result<augment::Outcome, String>>,
target: &'static str,
started: Instant,
}
#[derive(Serialize)]
struct AugmentDto {
deck: String,
cards: usize,
rows: Vec<AugmentRowDto>,
busy: Option<&'static str>,
elapsed: Option<u64>,
error: Option<String>,
}
#[derive(Serialize)]
struct AugmentRowDto {
kind: &'static str,
label: &'static str,
covered: usize,
eligible: usize,
items: Vec<String>,
busy: bool,
}
impl Augmenting {
fn open(deck: String, cards: Vec<Card>, cache_path: PathBuf) -> Self {
let deck_ids = cards.iter().map(Card::id).collect();
Self {
deck,
cards,
deck_ids,
cache: AugmentCache::open(cache_path),
pending: None,
error: None,
}
}
fn dto(&self) -> AugmentDto {
let s = self.cache.summarize(&self.cards);
let busy = self.pending.as_ref().map(|p| p.target);
let card_row =
|kind: &'static str, label: &'static str, c: augment::Coverage| AugmentRowDto {
kind,
label,
covered: c.covered,
eligible: c.eligible,
items: Vec::new(),
busy: busy == Some(kind),
};
let rows = vec![
card_row("choices", "Choices", s.choices),
card_row("notes", "Notes", s.notes),
card_row("questions", "Questions", s.questions),
card_row("keypoints", "Key points", s.keypoints),
card_row("format", "Formatting", s.format),
AugmentRowDto {
kind: "topology",
label: "Topology",
covered: 0,
eligible: 0,
items: s.topologies,
busy: busy == Some("topology"),
},
];
AugmentDto {
deck: self.deck.clone(),
cards: self.cards.len(),
rows,
busy,
elapsed: self.pending.as_ref().map(|p| p.started.elapsed().as_secs()),
error: self.error.clone(),
}
}
fn generate(
&mut self,
target: &str,
guidance: Option<String>,
ai: &AiConfig,
ask: &AskConfig,
) -> bool {
if self.pending.is_some() {
return false;
}
let (job, tgt): (augment::Job, &'static str) = match target {
"choices" => {
let items = self.cache.missing_choices(&self.cards);
if items.is_empty() {
return false;
}
(
augment::Job::Choices {
items,
count: ai.distractor_count,
},
"choices",
)
}
"notes" => {
let items = self.cache.missing_notes(&self.cards);
if items.is_empty() {
return false;
}
(augment::Job::Notes { items }, "notes")
}
"questions" => {
let items = self.cache.missing_questions(&self.cards);
if items.is_empty() {
return false;
}
(
augment::Job::Questions {
items,
count: ai.variant_count,
},
"questions",
)
}
"keypoints" => {
let items = self.cache.missing_keypoints(&self.cards);
if items.is_empty() {
return false;
}
(
augment::Job::Keypoints {
items,
count: ai.keypoint_count,
},
"keypoints",
)
}
"format" => {
let items = self.cache.missing_format(&self.cards);
if items.is_empty() {
return false;
}
(augment::Job::Format { items }, "format")
}
"topology" => (
augment::Job::Topology {
items: self
.cards
.iter()
.map(augment::WarmItem::from_card)
.collect(),
},
"topology",
),
_ => return false,
};
self.error = None;
let rx = augment::spawn(job, guidance, augment::run_config(ai, ask));
self.pending = Some(AugmentPending {
rx,
target: tgt,
started: Instant::now(),
});
true
}
fn poll(&mut self) {
let Some(p) = self.pending.as_ref() else {
return;
};
let outcome = match p.rx.try_recv() {
Ok(reply) => reply,
Err(TryRecvError::Empty) => return,
Err(TryRecvError::Disconnected) => {
Err("the augment helper exited unexpectedly".to_string())
}
};
self.pending = None;
match outcome {
Ok(o) => {
self.apply(o);
self.save();
}
Err(e) => self.error = Some(e),
}
}
fn apply(&mut self, outcome: augment::Outcome) {
match outcome {
augment::Outcome::Choices(map) => {
for (id, v) in map {
self.cache.set_distractors(id, v);
}
}
augment::Outcome::Notes(map) => {
for (id, v) in map {
self.cache.set_note(id, v);
}
}
augment::Outcome::Questions(map) => {
for (id, v) in map {
self.cache.set_variants(id, v);
}
}
augment::Outcome::Keypoints(map) => {
for (id, v) in map {
self.cache.set_keypoints(id, v);
}
}
augment::Outcome::Topology(t) => self.cache.add_topology(t),
augment::Outcome::Format(map) => {
for (id, v) in map {
self.cache.set_format(id, v);
}
}
}
}
fn remove(&mut self, target: &str, topology: Option<&str>) -> bool {
match target {
"choices" => self.cache.clear_distractors(&self.deck_ids),
"notes" => self.cache.clear_notes(&self.deck_ids),
"questions" => self.cache.clear_variants(&self.deck_ids),
"keypoints" => self.cache.clear_keypoints(&self.deck_ids),
"format" => self.cache.clear_format(&self.deck_ids),
"topology" => {
let Some(name) = topology else {
return false;
};
self.cache.remove_topology(name, &self.deck_ids);
}
"all" => self.cache.clear_all(&self.deck_ids),
_ => return false,
}
self.error = None;
self.save();
true
}
fn save(&mut self) {
if let Err(e) = self.cache.save() {
self.error = Some(format!("could not save augmentations: {e}"));
}
}
}
#[expect(clippy::too_many_arguments)] pub fn run_review(
initial: Launch,
mut store: Store,
mut recent: RecentDecks,
decks_dir: PathBuf,
addr: SocketAddr,
opts: ReviewOptions,
mut build: impl FnMut(
Vec<PathBuf>,
Option<&str>,
Option<&str>,
Option<Depth>,
&Store,
&mut RecentDecks,
) -> Result<SessionBuild>,
mut build_walk: impl FnMut(&[PathBuf]) -> Result<Option<WalkBuild>>,
mut build_browse: impl FnMut(Vec<PathBuf>, &mut RecentDecks) -> Result<CardsBuild>,
mut store_for: impl FnMut(&[PathBuf]) -> Result<Store>,
) -> Result<()> {
let ReviewOptions {
keys: bindings,
picker: picker_keys,
browse: browse_bindings,
ask: ask_cfg,
exam: exam_cfg,
ai: ai_cfg,
review: review_cfg,
auth,
} = opts;
let keys = ReviewKeys::from(&bindings);
let picker_keys = PickerKeysDto::from(&picker_keys);
let browse_keys = BrowseKeys::from(&browse_bindings);
let ask_info = AskInfoDto::from(&ask_cfg);
let (mut reviewing, mut browsing) = match initial {
Launch::Review(b) => (Some(Reviewing::new(*b)), None),
Launch::Browse(b) => (None, Some(Browsing::new(b))),
Launch::Picker => (None, None),
};
if let Some(r) = reviewing.as_mut() {
r.open_augment(store.path());
r.rotate_variant();
}
let mut examining: Option<Examining> = None;
let mut augmenting: Option<Augmenting> = None;
let mut walking: Option<Walking> = None;
let mut launcher_icons: HashMap<String, PathBuf> = HashMap::new();
let server = Server::http(addr).map_err(|e| anyhow!("cannot start server on {addr}: {e}"))?;
for mut request in server.incoming_requests() {
let method = request.method().clone();
let path = request_path(&request);
if !is_authorized(
&path,
header_value(&request, "Authorization"),
query_param(request.url(), "token").as_deref(),
auth.as_deref(),
) {
respond_status(request, 401);
continue;
}
match (&method, path.as_str()) {
(Method::Get, "/") => respond_html(request, &REVIEW_PAGE),
(Method::Get, "/theme.css") => {
respond_asset(request, THEME_CSS, "text/css; charset=utf-8")
}
(Method::Get, "/theme.js") => {
respond_asset(request, THEME_JS, "application/javascript; charset=utf-8")
}
(Method::Get, "/alix-logo.js") => respond_asset(
request,
ALIX_LOGO_JS,
"application/javascript; charset=utf-8",
),
(Method::Get, "/api/keys") => respond_json(request, &keys),
(Method::Get, "/api/version") => respond_json(
request,
&VersionDto {
version: env!("CARGO_PKG_VERSION"),
},
),
(Method::Get, "/api/browse-keys") => respond_json(request, &browse_keys),
(Method::Get, "/api/picker-keys") => respond_json(request, &picker_keys),
(Method::Get, "/api/ask-info") => respond_json(request, &ask_info),
(Method::Get, "/api/decks") => {
let catalog = deck_catalog(
&decks_dir,
&recent,
&store,
true,
&mut launcher_icons,
review_cfg,
);
respond_json(request, &catalog)
}
(Method::Get, key) if key.starts_with("/img/") => {
let name = &key["/img/".len()..];
if let Some(r) = &reviewing {
serve_image(request, &r.images, name)
} else if let Some(b) = &browsing {
serve_image(request, &b.images, name)
} else {
serve_image(request, &launcher_icons, name)
}
}
(Method::Get, "/api/state") => {
if let Some(b) = &browsing {
respond_json(request, &browse_payload(Some(b)))
} else {
if let Some(r) = reviewing.as_mut() {
r.session.poll(&store, now_ms());
}
respond_json(request, &review_state(reviewing.as_ref(), &store))
}
}
(Method::Post, "/api/select") => {
match read_selection(&mut request, &decks_dir, &recent) {
Some(sel) => {
let (topology, region, depth) =
(sel.topology.clone(), sel.region.clone(), sel.depth);
let paths = vec![sel.deck];
if let Err(e) = store_for(&paths).map(|s| store = s) {
eprintln!("warning: could not open the progress store: {e}");
respond_status(request, 400);
continue;
}
match build_walk(&paths) {
Ok(Some(wb)) => {
let w = Walking::new(wb.walk, None);
let dto = walk_dto(&w);
walking = Some(w);
reviewing = None;
examining = None;
respond_json(request, &dto);
}
Ok(None) => match build(
paths,
topology.as_deref(),
region.as_deref(),
depth,
&store,
&mut recent,
) {
Ok(b) => {
let resolved = b.session.depth();
let subject = b.decks.keys().next().cloned();
let mut r = Reviewing::new(b);
r.open_augment(store.path());
r.rotate_variant();
if let Some(subject) = subject {
store.set_last_depth(&subject, resolved);
if let Err(e) = store.save() {
eprintln!("warning: could not save progress: {e}");
}
}
reviewing = Some(r);
walking = None;
respond_json(
request,
&review_state(reviewing.as_ref(), &store),
);
}
Err(e) => {
eprintln!("warning: could not load the selected decks: {e}");
respond_status(request, 400);
}
},
Err(e) => {
eprintln!("warning: could not load the selected trace: {e}");
respond_status(request, 400);
}
}
}
None => respond_status(request, 400),
}
}
(Method::Post, "/api/browse") => {
match read_selection(&mut request, &decks_dir, &recent) {
Some(sel) => {
let paths = vec![sel.deck];
if let Err(e) = store_for(&paths).map(|s| store = s) {
eprintln!("warning: could not open the progress store: {e}");
respond_status(request, 400);
continue;
}
match build_browse(paths, &mut recent) {
Ok(b) => {
browsing = Some(Browsing::new(b));
reviewing = None;
walking = None;
examining = None;
respond_json(request, &browse_payload(browsing.as_ref()));
}
Err(e) => {
eprintln!("warning: could not load the selected decks: {e}");
respond_status(request, 400);
}
}
}
None => respond_status(request, 400),
}
}
(Method::Post, "/api/deck-topology") => {
let dto = match read_selection(&mut request, &decks_dir, &recent) {
Some(sel) => {
match (
Deck::load(&sel.deck),
store_for(std::slice::from_ref(&sel.deck)),
) {
(Ok(deck), Ok(s)) => {
let augment =
AugmentCache::open(augment::augment_path_for(s.path()));
deck_topology_dto(&augment, &s, &deck, review_cfg)
}
_ => DeckTopologyDto::default(),
}
}
None => DeckTopologyDto::default(),
};
respond_json(request, &dto);
}
(Method::Post, "/api/deselect") => {
reviewing = None;
walking = None;
browsing = None;
if let Ok(s) = store_for(&[]) {
store = s;
}
respond_json(request, &review_state(reviewing.as_ref(), &store));
}
(Method::Post, "/api/grade") => {
let Some(r) = reviewing.as_mut() else {
respond_status(request, 409);
continue;
};
match read_grade(&mut request) {
Some(grade) => {
let now = now_ms();
r.session.grade(&mut store, grade, now);
if let Some(subject) = r.files.paths.keys().next() {
store::note_badges(&mut store, subject, r.session.cards(), now);
}
if let Err(e) = store.save() {
eprintln!("warning: could not save progress: {e}");
}
r.rotate_variant(); respond_json(request, &review_state(reviewing.as_ref(), &store));
}
None => respond_status(request, 400),
}
}
(Method::Post, "/api/skip") => {
let Some(r) = reviewing.as_mut() else {
respond_status(request, 409);
continue;
};
r.session.skip(&store, now_ms());
r.rotate_variant(); respond_json(request, &review_state(reviewing.as_ref(), &store));
}
(Method::Post, "/api/acquire") => {
let Some(r) = reviewing.as_mut() else {
respond_status(request, 409);
continue;
};
r.session.acquire_current(&mut store, now_ms());
if let Err(e) = store.save() {
eprintln!("warning: could not save progress: {e}");
}
r.rotate_variant(); respond_json(request, &review_state(reviewing.as_ref(), &store));
}
(Method::Post, "/api/check") => {
let Some(r) = reviewing.as_ref() else {
respond_status(request, 409);
continue;
};
#[derive(Deserialize)]
struct Body {
lines: Vec<String>,
#[serde(default)]
ordered: bool,
}
let body: Option<Body> = serde_json::from_reader(request.as_reader()).ok();
let result = body.and_then(|body| {
let card = r.session.current()?;
let results: Vec<TypedResult> = if body.ordered {
grade_lines_ordered(&body.lines, &card.back)
} else {
grade_lines_unordered(&body.lines, &card.back)
};
let passed = results.iter().all(|r| r.passed);
Some(CheckFeedbackDto { results, passed })
});
match result {
Some(f) => respond_json(request, &f),
None => respond_status(request, 400),
}
}
(Method::Post, "/api/choose") => {
let Some(r) = reviewing.as_ref() else {
respond_status(request, 409);
continue;
};
let picked = read_index(&mut request).and_then(|chosen| {
let card = r.session.current()?.clone();
let correct = current_question(r, &store, &card)?.correct;
Some((chosen, correct))
});
match picked {
Some((chosen, correct)) => respond_json(
request,
&ChooseFeedbackDto {
chosen,
correct,
passed: chosen == correct,
},
),
None => respond_status(request, 400),
}
}
(Method::Post, "/api/remove") => {
let Some(r) = reviewing.as_mut() else {
respond_status(request, 409);
continue;
};
let dropped = r.session.remove_current(&store, now_ms());
if let Some(first) = dropped.first() {
let subject = first.subject.to_string();
let line = first.line;
for card in &dropped {
store.remove(card.id());
}
let _ = store.save();
r.files.remove_block(&subject, line);
}
respond_json(request, &review_state(reviewing.as_ref(), &store));
}
(Method::Post, "/api/promote") => {
let Some(r) = reviewing.as_mut() else {
respond_status(request, 409);
continue;
};
if !r.session.current_is_virtual(&store) {
respond_status(request, 400);
continue;
}
let Some(id) = r.session.current_id() else {
respond_status(request, 400);
continue;
};
let Some(subject) = r.session.current().map(|c| c.subject.to_string()) else {
respond_status(request, 400);
continue;
};
let Some(path) = r.files.paths.get(&subject).cloned() else {
respond_status(request, 400);
continue;
};
if store::promote_virtual(&mut store, id, &path).is_err() {
respond_status(request, 400);
continue;
}
r.session.poll(&store, now_ms());
respond_json(request, &review_state(reviewing.as_ref(), &store));
}
(Method::Post, "/api/restart") => {
let Some(r) = reviewing.as_mut() else {
respond_status(request, 409);
continue;
};
r.session.restart(&store, now_ms());
r.rotate_variant(); respond_json(request, &review_state(reviewing.as_ref(), &store));
}
(Method::Post, "/api/ask") => {
#[derive(Deserialize)]
struct Body {
question: String,
}
let body: Option<Body> = serde_json::from_reader(request.as_reader()).ok();
let question = body.map(|b| b.question).filter(|q| !q.trim().is_empty());
let Some(r) = reviewing.as_mut() else {
respond_status(request, 409);
continue;
};
if let Some(q) = question {
r.start_ask(&ask_cfg, Some(q));
}
respond_json(request, &r.ask_dto(None, None));
}
(Method::Post, "/api/ask/note") => {
let Some(r) = reviewing.as_mut() else {
respond_status(request, 409);
continue;
};
r.start_ask(&ask_cfg, None);
respond_json(request, &r.ask_dto(None, None));
}
(Method::Get, "/api/ask") => {
let Some(r) = reviewing.as_mut() else {
respond_status(request, 409);
continue;
};
let (status, error) = r.poll_ask();
respond_json(request, &r.ask_dto(status, error));
}
(Method::Post, "/api/exam/start") => {
#[derive(Deserialize)]
struct Body {
deck: String,
}
let body: Option<Body> = serde_json::from_reader(request.as_reader()).ok();
let mut known: HashMap<String, PathBuf> = HashMap::new();
for e in picker::catalog(&decks_dir, &recent) {
for m in &e.members {
known.insert(m.name.clone(), m.path.clone());
}
known.insert(e.name, e.path);
}
let Some(path) = body.and_then(|b| known.get(&b.deck).cloned()) else {
respond_status(request, 400);
continue;
};
if let Ok(s) = store_for(std::slice::from_ref(&path)) {
store = s;
}
match Deck::load(&path) {
Ok(deck)
if deck.has_exam()
&& !deck::is_locked(&deck, Some(decks_dir.as_path()), &store) =>
{
let strictness =
deck.settings.exam_strictness.unwrap_or(exam_cfg.strictness);
let sitting = if deck.is_trace() {
match trace::Trace::from_deck(&deck) {
Ok(t) => {
if let Some(ms) = exam::cooldown_remaining_ms(
&store,
&deck.subject,
exam_cfg.retry_cooldown_secs,
now_ms(),
) {
#[derive(Serialize)]
struct Cooldown {
cooldown_ms: u64,
}
respond_json(request, &Cooldown { cooldown_ms: ms });
continue;
}
exam::Sitting::start_trace(
t.description.clone(),
t.compression_rubric(),
deck.subject.clone(),
strictness,
exam_cfg.clone(),
ask_cfg.clone(),
)
}
Err(_) => {
respond_status(request, 409);
continue;
}
}
} else {
exam::Sitting::start(
&deck,
strictness,
exam_cfg.clone(),
ask_cfg.clone(),
)
};
let ex = Examining {
sitting,
deck_path: path,
};
let dto = exam_dto(&ex, &decks_dir);
examining = Some(ex);
respond_json(request, &dto);
}
_ => respond_status(request, 409),
}
}
(Method::Get, "/api/exam") => {
let Some(ex) = examining.as_mut() else {
respond_status(request, 409);
continue;
};
let parent = ex.deck_path.parent().unwrap_or_else(|| Path::new(""));
let retire_after_days = review_cfg.for_workspace(parent).retire_after_days;
ex.sitting.poll(&mut store, now_ms(), retire_after_days);
respond_json(request, &exam_dto(ex, &decks_dir));
}
(Method::Post, "/api/exam/answer") => {
#[derive(Deserialize)]
struct Body {
text: String,
goto: Option<usize>,
}
let body: Option<Body> = serde_json::from_reader(request.as_reader()).ok();
let Some(ex) = examining.as_mut() else {
respond_status(request, 409);
continue;
};
if let Some(b) = body {
ex.sitting.set_answer(b.text);
if let Some(i) = b.goto {
ex.sitting.goto(i);
}
}
respond_json(request, &exam_dto(ex, &decks_dir));
}
(Method::Post, "/api/exam/grade") => {
#[derive(Deserialize)]
struct Body {
text: String,
}
let body: Option<Body> = serde_json::from_reader(request.as_reader()).ok();
let Some(ex) = examining.as_mut() else {
respond_status(request, 409);
continue;
};
if let Some(b) = body {
ex.sitting.set_answer(b.text);
}
ex.sitting.submit();
respond_json(request, &exam_dto(ex, &decks_dir));
}
(Method::Post, "/api/exam/remediate") => {
let Some(ex) = examining.as_mut() else {
respond_status(request, 409);
continue;
};
ex.sitting.remediate();
respond_json(request, &exam_dto(ex, &decks_dir));
}
(Method::Post, "/api/exam/close") => {
examining = None;
if let Ok(s) = store_for(&[]) {
store = s;
}
respond_json(request, &review_state(reviewing.as_ref(), &store));
}
(Method::Post, "/api/augment/open") => {
#[derive(Deserialize)]
struct Body {
deck: String,
}
let body: Option<Body> = serde_json::from_reader(request.as_reader()).ok();
let mut known: HashMap<String, PathBuf> = HashMap::new();
for e in picker::catalog(&decks_dir, &recent) {
for m in &e.members {
known.insert(m.name.clone(), m.path.clone());
}
known.insert(e.name, e.path);
}
let Some((name, path)) =
body.and_then(|b| known.get(&b.deck).cloned().map(|p| (b.deck, p)))
else {
respond_status(request, 400);
continue;
};
if let Ok(s) = store_for(std::slice::from_ref(&path)) {
store = s;
}
match Deck::load(&path) {
Ok(deck) => {
let aug = Augmenting::open(
name,
deck.cards,
augment::augment_path_for(store.path()),
);
let dto = aug.dto();
augmenting = Some(aug);
respond_json(request, &dto);
}
Err(_) => respond_status(request, 409),
}
}
(Method::Post, "/api/augment/generate") => {
#[derive(Deserialize)]
struct Body {
target: String,
with: Option<String>,
}
let body: Option<Body> = serde_json::from_reader(request.as_reader()).ok();
let Some(aug) = augmenting.as_mut() else {
respond_status(request, 409);
continue;
};
if let Some(b) = body {
let guidance = b
.with
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
aug.generate(&b.target, guidance, &ai_cfg, &ask_cfg);
}
respond_json(request, &aug.dto());
}
(Method::Get, "/api/augment") => {
let Some(aug) = augmenting.as_mut() else {
respond_status(request, 409);
continue;
};
aug.poll();
respond_json(request, &aug.dto());
}
(Method::Post, "/api/augment/remove") => {
#[derive(Deserialize)]
struct Body {
target: String,
topology: Option<String>,
}
let body: Option<Body> = serde_json::from_reader(request.as_reader()).ok();
let Some(aug) = augmenting.as_mut() else {
respond_status(request, 409);
continue;
};
if let Some(b) = body {
aug.remove(&b.target, b.topology.as_deref());
}
respond_json(request, &aug.dto());
}
(Method::Post, "/api/augment/close") => {
augmenting = None;
if let Ok(s) = store_for(&[]) {
store = s;
}
respond_json(request, &review_state(reviewing.as_ref(), &store));
}
(Method::Get, "/api/walk") => {
let Some(w) = walking.as_mut() else {
respond_status(request, 409);
continue;
};
w.poll();
respond_json(request, &walk_dto(w));
}
(Method::Post, "/api/walk/predict") => {
let Some(w) = walking.as_mut() else {
respond_status(request, 409);
continue;
};
#[derive(Deserialize)]
struct Body {
text: String,
}
let body: Option<Body> = serde_json::from_reader(request.as_reader()).ok();
if let Some(b) = body {
w.walk.predict(b.text);
w.start_grade();
}
respond_json(request, &walk_dto(w));
}
(Method::Post, "/api/walk/grade") => {
let self_delta = read_delta(&mut request);
let Some(w) = walking.as_mut() else {
respond_status(request, 409);
continue;
};
let delta = w.grade_result.as_ref().map(|(d, _)| *d).or(self_delta);
match delta {
Some(delta) => {
w.walk.grade(&mut store, delta, now_ms());
if let Err(e) = store.save() {
eprintln!("warning: could not save progress: {e}");
}
w.clear_grade();
respond_json(request, &walk_dto(w));
}
None => respond_status(request, 400),
}
}
(Method::Post, "/api/walk/restart") => {
let Some(w) = walking.as_mut() else {
respond_status(request, 409);
continue;
};
let fresh = Walk::new(w.walk.trace().clone());
let grade = w.grade.take();
*w = Walking::new(fresh, grade);
respond_json(request, &walk_dto(w));
}
(Method::Post, "/api/walk/ask") => {
#[derive(Deserialize)]
struct Body {
question: String,
}
let body: Option<Body> = serde_json::from_reader(request.as_reader()).ok();
let question = body.map(|b| b.question).filter(|q| !q.trim().is_empty());
let Some(w) = walking.as_mut() else {
respond_status(request, 409);
continue;
};
if let Some(q) = question {
w.start_ask(&ask_cfg, Some(q));
}
respond_json(request, &w.ask_dto(None, None));
}
(Method::Post, "/api/walk/ask/note") => {
let Some(w) = walking.as_mut() else {
respond_status(request, 409);
continue;
};
w.start_ask(&ask_cfg, None);
respond_json(request, &w.ask_dto(None, None));
}
(Method::Get, "/api/walk/ask") => {
let Some(w) = walking.as_mut() else {
respond_status(request, 409);
continue;
};
let (status, error) = w.poll_ask();
respond_json(request, &w.ask_dto(status, error));
}
(Method::Post, "/api/walk/leave") => {
walking = None;
if let Ok(s) = store_for(&[]) {
store = s;
}
respond_status(request, 204);
}
_ => respond_status(request, 404),
}
}
Ok(())
}
struct Walking {
walk: Walk,
grade: Option<AskConfig>,
pending: Option<Receiver<Result<(Delta, String), String>>>,
grade_result: Option<(Delta, String)>,
grade_error: Option<String>,
ask: Ask,
}
impl Walking {
fn new(walk: Walk, grade: Option<AskConfig>) -> Self {
Walking {
walk,
grade,
pending: None,
grade_result: None,
grade_error: None,
ask: Ask::new(),
}
}
fn checkpoint_card(&self) -> Option<Card> {
let trace = self.walk.trace();
let cp = self.walk.checkpoint()?;
let mut note = String::new();
if let Ok(ex) = trace.excerpt(cp) {
note.push_str("Source excerpt:\n");
for (n, line) in &ex.lines {
note.push_str(&format!("{n}: {line}\n"));
}
}
if let Some(insight) = &cp.note {
if !note.is_empty() {
note.push('\n');
}
note.push_str(insight);
}
Some(Card::plain(
Arc::from(trace.subject.as_str()),
cp.prompt.clone(),
cp.points.clone(),
(!note.is_empty()).then_some(note),
cp.line,
))
}
fn start_ask(&mut self, cfg: &AskConfig, question: Option<String>) -> bool {
let Some(card) = self.checkpoint_card() else {
return false;
};
let root = cfg
.source_access
.then(|| self.walk.trace().origin.clone())
.flatten();
let frozen = root.as_ref().and_then(|_| {
let c = self.walk.checkpoint()?;
self.walk.trace().frozen_block(c)
});
let live_root = root.as_deref().filter(|r| r.exists());
self.ask
.start(cfg, &card, &[], live_root, frozen.as_deref(), question)
}
fn poll_ask(&mut self) -> (Option<String>, Option<String>) {
self.ask.align(self.walk.checkpoint().map(|c| c.card_id));
let deck_path = self.walk.trace().deck_path.clone();
self.ask.poll(|card, notes| {
crate::deck::append_note(&deck_path, card.line, notes).map_err(|e| e.to_string())
})
}
fn ask_dto(&self, status: Option<String>, error: Option<String>) -> AskDto {
self.ask.dto(status, error)
}
fn start_grade(&mut self) {
self.clear_grade();
let Some(ask_cfg) = self.grade.as_ref() else {
return;
};
let Some(checkpoint) = self.walk.checkpoint() else {
return;
};
let prediction = self
.walk
.prediction(self.walk.current_index())
.unwrap_or("")
.to_string();
let rx = trace::spawn_grade(checkpoint.clone(), prediction, ask_cfg.clone());
self.pending = Some(rx);
}
fn poll(&mut self) {
let Some(rx) = &self.pending else { return };
match rx.try_recv() {
Ok(Ok((delta, feedback))) => {
self.grade_result = Some((delta, feedback));
self.pending = None;
}
Ok(Err(e)) => {
self.grade_error = Some(e);
self.pending = None;
}
Err(TryRecvError::Empty) => {}
Err(TryRecvError::Disconnected) => {
self.grade_error = Some("the grading thread ended unexpectedly".to_string());
self.pending = None;
}
}
}
fn clear_grade(&mut self) {
self.pending = None;
self.grade_result = None;
self.grade_error = None;
}
}
#[derive(Serialize)]
struct HopDto {
prompt: String,
delta: Option<&'static str>,
current: bool,
}
#[derive(Debug, Serialize)]
struct ExcerptDto {
path: String,
lines: Vec<LineDto>,
truncated: bool,
}
#[derive(Debug, Serialize)]
struct LineDto {
n: usize,
text: String,
}
#[derive(Serialize)]
struct SummaryDto {
passed: usize,
partly: usize,
failed: usize,
weak: Vec<usize>,
total: usize,
}
#[derive(Serialize)]
struct WalkDto {
kind: &'static str,
phase: &'static str,
description: String,
source: Option<String>,
total: usize,
current: usize,
path: Vec<HopDto>,
prompt: Option<String>,
givens: Vec<String>,
locator: Option<String>,
prediction: Option<String>,
excerpt: Option<ExcerptDto>,
excerpt_error: Option<String>,
points: Vec<String>,
note: Option<String>,
auto_grade: bool,
thinking: bool,
verdict: Option<&'static str>,
feedback: Option<String>,
grade_error: Option<String>,
summary: Option<SummaryDto>,
}
fn walk_phase_name(phase: Phase) -> &'static str {
match phase {
Phase::Predict => "predict",
Phase::Reveal => "reveal",
Phase::Done => "done",
}
}
fn delta_name(delta: Delta) -> &'static str {
match delta {
Delta::Passed => "passed",
Delta::Partial => "partly",
Delta::Failed => "failed",
}
}
fn excerpt_dto(excerpt: &Excerpt) -> ExcerptDto {
ExcerptDto {
path: excerpt.path.display().to_string(),
lines: excerpt
.lines
.iter()
.map(|(n, text)| LineDto {
n: *n,
text: text.clone(),
})
.collect(),
truncated: excerpt.truncated,
}
}
fn walk_dto(w: &Walking) -> WalkDto {
let walk = &w.walk;
let trace = walk.trace();
let phase = walk.phase();
let on_a_hop = matches!(phase, Phase::Predict | Phase::Reveal);
let path = trace
.checkpoints
.iter()
.enumerate()
.map(|(i, c)| HopDto {
prompt: c.prompt.clone(),
delta: walk.delta(i).map(delta_name),
current: on_a_hop && i == walk.current_index(),
})
.collect();
let mut dto = WalkDto {
kind: "walk",
phase: walk_phase_name(phase),
description: trace.description.clone(),
source: trace.source.clone(),
total: walk.total(),
current: walk.current_index() + 1,
path,
prompt: None,
givens: Vec::new(),
locator: None,
prediction: None,
excerpt: None,
excerpt_error: None,
points: Vec::new(),
note: None,
auto_grade: w.grade.is_some(),
thinking: w.pending.is_some(),
verdict: w.grade_result.as_ref().map(|(d, _)| d.label()),
feedback: w.grade_result.as_ref().map(|(_, f)| f.clone()),
grade_error: w.grade_error.clone(),
summary: None,
};
match phase {
Phase::Predict => {
if let Some(c) = walk.checkpoint() {
dto.prompt = Some(c.prompt.clone());
dto.givens = c.givens.clone();
dto.locator = c.locator.clone();
}
}
Phase::Reveal => {
if let Some(c) = walk.checkpoint() {
dto.prompt = Some(c.prompt.clone());
dto.givens = c.givens.clone();
dto.locator = c.locator.clone();
dto.points = c.points.clone();
dto.note = c.note.clone();
match trace.excerpt(c) {
Ok(ex) => {
let (ex, label) = trace::relabel_for_display(ex, c.at_origin.as_deref());
if let Some(label) = label {
dto.locator = Some(label);
}
dto.excerpt = Some(excerpt_dto(&ex));
}
Err(e) => dto.excerpt_error = Some(format!("{e:#}")),
}
}
dto.prediction = walk
.prediction(walk.current_index())
.map(str::to_string)
.filter(|p| !p.is_empty());
}
Phase::Done => {
let s = walk.summary();
dto.summary = Some(SummaryDto {
passed: s.passed,
partly: s.partly,
failed: s.failed,
weak: s.weak.iter().map(|i| i + 1).collect(),
total: walk.total(),
});
}
}
dto
}
fn read_delta(request: &mut Request) -> Option<Delta> {
#[derive(Deserialize)]
struct Body {
delta: String,
}
let body: Body = serde_json::from_reader(request.as_reader()).ok()?;
Delta::from_key(body.delta.chars().next()?)
}
struct Browsing {
cards: Vec<Card>,
label: String,
images: HashMap<String, PathBuf>,
}
impl Browsing {
fn new(build: CardsBuild) -> Self {
let images = collect_images(&build.cards);
Self {
cards: build.cards,
label: build.label,
images,
}
}
}
fn browse_payload(browsing: Option<&Browsing>) -> BrowseDto {
match browsing {
Some(b) => BrowseDto {
phase: "browse",
label: b.label.clone(),
cards: b.cards.iter().map(card_dto).collect(),
},
None => BrowseDto {
phase: "select",
label: "select decks".to_string(),
cards: Vec::new(),
},
}
}
fn request_path(request: &Request) -> String {
request.url().split('?').next().unwrap_or("").to_string()
}
fn is_authorized(
path: &str,
auth_header: Option<&str>,
query_token: Option<&str>,
token: Option<&str>,
) -> bool {
let Some(token) = token else { return true };
if !path.starts_with("/api/") {
return true;
}
let presented = auth_header
.and_then(|h| h.strip_prefix("Bearer "))
.or(query_token);
presented.is_some_and(|p| ct_eq(p.as_bytes(), token.as_bytes()))
}
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
fn header_value<'a>(request: &'a Request, name: &'static str) -> Option<&'a str> {
request
.headers()
.iter()
.find(|h| h.field.equiv(name))
.map(|h| h.value.as_str())
}
fn query_param(url: &str, key: &str) -> Option<String> {
let (_, query) = url.split_once('?')?;
query.split('&').find_map(|pair| {
let (k, v) = pair.split_once('=')?;
(k == key).then(|| v.to_string())
})
}
fn current_question(r: &Reviewing, store: &Store, card: &Card) -> Option<ChoiceQuestion> {
let ai = r.augment.distractors(card.id());
if store.get(card.id()).is_none() {
return choice::recognition_question(card, r.session.cards(), card.id(), ai);
}
if r.session.depth() != Depth::Recognize {
return None;
}
match card.reveal.unwrap_or_default() {
Reveal::Line => choice::line_question(card, 0, card.id(), ai),
Reveal::Flip | Reveal::Cloze => choice::build(card, r.session.cards(), card.id(), ai),
}
}
fn review_state(reviewing: Option<&Reviewing>, store: &Store) -> StateDto {
let Some(r) = reviewing else {
return StateDto {
kind: "review",
phase: "select",
card: None,
choices: None,
keypoints: None,
acquire: false,
mode: mode_name(Mode::default()),
depth: depth_name(Depth::default()),
input: input_name(Input::default()),
remaining: 0,
initial: 0,
reviews: 0,
passed: 0,
failed: 0,
exam_due: Vec::new(),
can_restart: false,
promotable: false,
label: "select decks".to_string(),
};
};
let session = &r.session;
let card = session.current();
let depth = session.depth();
let mode = card
.map(|c| depth::check_for(c.reveal.unwrap_or_default(), depth, c))
.unwrap_or_default();
let acquire = session.current_unseen(store);
let choices = card
.and_then(|c| current_question(r, store, c))
.map(|q| q.options);
let keypoints = if !acquire && mode == Mode::Explain {
card.map(|c| {
r.augment
.keypoints(c.id())
.map(<[String]>::to_vec)
.unwrap_or_else(|| c.back.clone())
})
} else {
None
};
let finished = session.is_finished();
let exam_due = if finished {
let mut due: Vec<String> = r
.files
.paths
.iter()
.filter_map(|(subject, path)| {
Deck::load(path)
.ok()
.filter(|d| d.state(store) == DeckState::ExamDue)
.map(|_| subject.clone())
})
.collect();
due.sort();
due
} else {
Vec::new()
};
let card_with_citation = card.map(|c| {
let mut dto = card_dto(c);
if let Some(name) = &r.topology_name
&& let Some((topo, regions, current)) = r
.augment
.topologies()
.iter()
.filter(|t| t.name == *name)
.find_map(|t| t.region_path(c.id()).map(|(rg, cur)| (t, rg, cur)))
{
dto.crumb = Some(CrumbDto {
regions: regions.into_iter().map(str::to_string).collect(),
current,
cells: topo
.regions
.iter()
.map(|reg| crate::session::card_strengths(®.cards, store, now_ms()))
.collect(),
});
}
if let Some(locator) = c.at.as_deref() {
dto.at = Some(locator.to_string());
if let Some(base) = r.source_bases.get(&*c.subject) {
match base.excerpt(locator) {
Ok(ex) => {
let (ex, label) = trace::relabel_for_display(ex, c.at_origin.as_deref());
if let Some(label) = label {
dto.at = Some(label);
}
dto.citation = Some(excerpt_dto(&ex));
}
Err(e) => dto.citation_error = Some(format!("{e:#}")),
}
}
}
dto
});
StateDto {
kind: "review",
phase: if finished { "done" } else { "review" },
card: card_with_citation,
choices,
keypoints,
acquire,
mode: mode_name(mode),
depth: depth_name(depth),
input: input_name(card.and_then(|c| c.input).unwrap_or_default()),
remaining: session.remaining(),
initial: session.initial_size,
reviews: session.stats.reviews,
passed: session.stats.passed,
failed: session.stats.failed,
exam_due,
can_restart: session.has_due_now(store, now_ms()),
promotable: session.current_is_virtual(store),
label: r.label.clone(),
}
}
fn state_name(s: DeckState) -> &'static str {
match s {
DeckState::NotStarted => "new",
DeckState::Started => "started",
DeckState::Finished => "finished",
DeckState::ExamDue => "examdue",
}
}
fn deck_item_dto(
e: &picker::DeckEntry,
store: &Store,
decks_dir: &Path,
with_lock: bool,
augment: &AugmentCache,
review: ReviewConfig,
) -> DeckItemDto {
let recent = e.last_used_ms.is_some();
match Deck::load(&e.path) {
Ok(deck) => {
let s = picker::deck_status(&deck, store, Some(decks_dir), with_lock, review);
let deck_ids: HashSet<u64> = deck.cards.iter().map(|c| c.id()).collect();
let last_depth = depth_name(store.last_depth(&deck.subject).unwrap_or_default());
DeckItemDto {
name: e.name.clone(),
label: e.label.clone(),
meta: Some(s.badge),
state: state_name(s.state),
locked: s.locked,
reviewable: s.reviewable,
reviewable_recognize: s.reviewable_recognize,
reviewable_recall: s.reviewable_recall,
reviewable_reconstruct: s.reviewable_reconstruct,
mastered: s.mastered,
is_trace: s.is_trace,
examable: s.examable,
has_exam: s.has_exam,
recent,
is_workspace: false,
description: None,
members: Vec::new(),
path: e.path_hint.clone(),
icon: None,
icon_svg: false,
has_topology: augment.has_topology_for(&deck_ids),
badge_depth: s.badge_depth.map(depth_name),
badge_dotted: s.badge_dotted,
new_cards: s.new_cards,
last_depth,
}
}
Err(_) => DeckItemDto {
name: e.name.clone(),
label: e.label.clone(),
meta: None,
state: "new",
locked: false,
reviewable: true,
reviewable_recognize: true,
reviewable_recall: true,
reviewable_reconstruct: true,
mastered: false,
is_trace: false,
examable: false,
has_exam: false,
recent,
is_workspace: false,
description: None,
members: Vec::new(),
path: e.path_hint.clone(),
icon: None,
icon_svg: false,
has_topology: false,
badge_depth: None,
badge_dotted: false,
new_cards: false,
last_depth: depth_name(Depth::default()),
},
}
}
fn workspace_members(
e: &picker::DeckEntry,
decks_dir: &Path,
with_lock: bool,
review: ReviewConfig,
) -> Vec<MemberDto> {
let review = review.for_workspace(&e.path);
let store = if crate::workspace::is_workspace(&e.path) {
Store::open(crate::workspace::store_path(&e.path)).ok()
} else {
crate::store::default_store_path().and_then(|p| Store::open(p).ok())
};
let paths: Vec<PathBuf> = e.members.iter().map(|m| m.path.clone()).collect();
let augment = store
.as_ref()
.map(|s| AugmentCache::open(augment::augment_path_for(s.path())));
let loaded: Vec<(Option<picker::DeckStatus>, bool, &'static str)> = paths
.iter()
.map(|p| {
let deck = Deck::load(p).ok();
let status = match (store.as_ref(), deck.as_ref()) {
(Some(st), Some(d)) => Some(picker::deck_status(
d,
st,
Some(decks_dir),
with_lock,
review,
)),
_ => None,
};
let has_topology = match (augment.as_ref(), deck.as_ref()) {
(Some(a), Some(d)) => {
let ids: HashSet<u64> = d.cards.iter().map(|c| c.id()).collect();
a.has_topology_for(&ids)
}
_ => false,
};
let last_depth = match (store.as_ref(), deck.as_ref()) {
(Some(st), Some(d)) => st.last_depth(&d.subject).unwrap_or_default(),
_ => Depth::default(),
};
(status, has_topology, depth_name(last_depth))
})
.collect();
let parent = picker::member_parents(&paths, decks_dir);
let key: Vec<(bool, String)> = e
.members
.iter()
.enumerate()
.map(|(i, m)| {
let blocked = loaded[i]
.0
.as_ref()
.is_some_and(|s| s.locked || (with_lock && !s.reviewable));
(blocked, m.label.clone())
})
.collect();
picker::dependency_forest(&parent, &key)
.into_iter()
.map(|(i, prefix)| {
let m = &e.members[i];
let indent = prefix.chars().count() / 3;
let has_topology = loaded[i].1;
let last_depth = loaded[i].2;
match &loaded[i].0 {
Some(s) => MemberDto {
name: m.name.clone(),
label: m.label.clone(),
meta: Some(s.badge.clone()),
state: state_name(s.state),
locked: s.locked,
reviewable: s.reviewable,
reviewable_recognize: s.reviewable_recognize,
reviewable_recall: s.reviewable_recall,
reviewable_reconstruct: s.reviewable_reconstruct,
mastered: s.mastered,
is_trace: s.is_trace,
examable: s.examable,
has_exam: s.has_exam,
indent,
tree: prefix.clone(),
has_topology,
badge_depth: s.badge_depth.map(depth_name),
badge_dotted: s.badge_dotted,
new_cards: s.new_cards,
last_depth,
},
None => MemberDto {
name: m.name.clone(),
label: m.label.clone(),
meta: None,
state: "new",
locked: false,
reviewable: true,
reviewable_recognize: true,
reviewable_recall: true,
reviewable_reconstruct: true,
mastered: false,
is_trace: false,
examable: false,
has_exam: false,
indent,
tree: prefix.clone(),
has_topology,
badge_depth: None,
badge_dotted: false,
new_cards: false,
last_depth,
},
}
})
.collect()
}
fn icon_field(icon: Option<&Path>, icons: &mut HashMap<String, PathBuf>) -> (Option<String>, bool) {
match icon {
Some(path) => {
let key = img_key(path);
icons.insert(key.clone(), path.to_path_buf());
let is_svg = path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("svg"));
(Some(format!("/img/{key}")), is_svg)
}
None => (None, false),
}
}
fn deck_catalog(
decks_dir: &Path,
recent: &RecentDecks,
store: &Store,
with_lock: bool,
icons: &mut HashMap<String, PathBuf>,
review: ReviewConfig,
) -> DeckListDto {
let mut workspaces = Vec::new();
let mut recent_decks = Vec::new();
let mut folders = Vec::new();
let augment = AugmentCache::open(augment::augment_path_for(store.path()));
for e in picker::catalog(decks_dir, recent) {
if e.is_workspace {
let is_ws = crate::workspace::is_workspace(&e.path);
let members = workspace_members(&e, decks_dir, with_lock, review);
let meta = if is_ws {
match picker::workspace_last_progress(&e.path) {
Some(when) => format!("{} decks · {when}", members.len()),
None => format!("{} decks", members.len()),
}
} else {
format!("{} decks", members.len())
};
let (icon, icon_svg) = icon_field(e.icon.as_deref(), icons);
let dto = DeckItemDto {
meta: Some(meta),
state: if is_ws { "workspace" } else { "folder" },
locked: false,
reviewable: true,
reviewable_recognize: true,
reviewable_recall: true,
reviewable_reconstruct: true,
mastered: false,
is_trace: false,
examable: false,
has_exam: false,
recent: e.last_used_ms.is_some(),
is_workspace: true,
description: e.description,
members,
path: e.path_hint,
name: e.name,
label: e.label,
icon,
icon_svg,
has_topology: false,
badge_depth: None,
badge_dotted: false,
new_cards: false,
last_depth: depth_name(Depth::default()),
};
if is_ws {
workspaces.push(dto);
} else {
folders.push(dto);
}
continue;
}
if e.path.parent().is_some_and(crate::workspace::is_workspace) {
continue;
}
recent_decks.push(deck_item_dto(
&e, store, decks_dir, with_lock, &augment, review,
));
}
DeckListDto {
workspaces,
recent: recent_decks,
folders,
}
}
struct Selection {
deck: PathBuf,
topology: Option<String>,
region: Option<String>,
depth: Option<Depth>,
}
fn read_selection(
request: &mut Request,
decks_dir: &Path,
recent: &RecentDecks,
) -> Option<Selection> {
#[derive(Deserialize)]
struct Body {
deck: String,
#[serde(default)]
topology: Option<String>,
#[serde(default)]
region: Option<String>,
#[serde(default)]
depth: Option<Depth>,
}
let body: Body = serde_json::from_reader(request.as_reader()).ok()?;
if body.deck.is_empty() {
return None;
}
let mut known: HashMap<String, PathBuf> = HashMap::new();
for e in picker::catalog(decks_dir, recent) {
for m in &e.members {
known.insert(m.name.clone(), m.path.clone());
}
known.insert(e.name, e.path);
}
Some(Selection {
deck: resolve_name(&body.deck, &known)?,
topology: body.topology,
region: body.region,
depth: body.depth,
})
}
fn resolve_name(name: &str, known: &HashMap<String, PathBuf>) -> Option<PathBuf> {
known.get(name).cloned()
}
fn deck_topology_dto(
augment: &AugmentCache,
store: &Store,
deck: &Deck,
review: ReviewConfig,
) -> DeckTopologyDto {
let parent = deck.path.parent().unwrap_or_else(|| Path::new(""));
let review = review.for_workspace(parent);
let by_id: HashMap<u64, &Card> = deck.cards.iter().map(|c| (c.id(), c)).collect();
let deck_ids: HashSet<u64> = by_id.keys().copied().collect();
let scheduler = Fsrs::new(review.retention);
let now = now_ms();
let due_of = |ids: &[u64]| {
let cards: Vec<&Card> = ids.iter().filter_map(|id| by_id.get(id).copied()).collect();
crate::session::count_reviewable(
&cards,
store,
&scheduler,
Depth::Recall,
now,
review.retire_after_days,
)
};
let deck_due = deck
.cards
.iter()
.filter(|c| {
crate::session::is_reviewable(
c,
store,
&scheduler,
Depth::Recall,
now,
review.retire_after_days,
)
})
.count()
+ crate::session::count_reviewable_virtual(
store,
&deck.subject,
&scheduler,
now,
review.retire_after_days,
);
let topologies = augment
.topologies_for(&deck_ids)
.into_iter()
.map(|t| TopologyInfoDto {
name: t.name.clone(),
principle: t.principle.clone(),
regions: t
.regions
.iter()
.map(|r| RegionInfoDto {
name: r.name.clone(),
cells: crate::session::card_strengths(&r.cards, store, now),
due: due_of(&r.cards),
})
.collect(),
})
.collect();
DeckTopologyDto {
topologies,
deck_due,
}
}
fn card_dto(card: &Card) -> CardDto {
let img_url = |p: &PathBuf| format!("/img/{}", img_key(p));
CardDto {
front: card.front.clone(),
context: card.context.clone(),
back: card.back_for_display().to_vec(),
reshaped: card.display_back.is_some(),
note: render::note_units(card)
.into_iter()
.map(NoteUnitDto::from)
.collect(),
img: card.image.as_ref().map(img_url),
img_back: card.image_back.as_ref().map(img_url),
at: None,
citation: None,
citation_error: None,
crumb: None,
}
}
fn img_key(path: &Path) -> String {
let mut hasher = XxHash64::default();
hasher.write(path.to_string_lossy().as_bytes());
format!("{:016x}", hasher.finish())
}
fn collect_images(cards: &[Card]) -> HashMap<String, PathBuf> {
let mut images = HashMap::new();
for card in cards {
for path in [&card.image, &card.image_back].into_iter().flatten() {
images.insert(img_key(path), path.clone());
}
}
images
}
fn content_type(path: &Path) -> &'static str {
match path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("svg") => "image/svg+xml",
_ => "application/octet-stream",
}
}
fn input_name(input: Input) -> &'static str {
match input {
Input::Type => "type",
Input::Draw => "draw",
}
}
fn read_grade(request: &mut Request) -> Option<Grade> {
#[derive(Deserialize)]
struct Body {
grade: Option<String>,
covered: Option<usize>,
total: Option<usize>,
}
let body: Body = serde_json::from_reader(request.as_reader()).ok()?;
if let Some(g) = body.grade.as_deref() {
return match g {
"failed" => Some(Grade::Fail),
"partly" => Some(Grade::Partial),
"passed" => Some(Grade::Pass),
_ => None,
};
}
match (body.covered, body.total) {
(Some(covered), Some(total)) => Some(keypoint_grade(covered, total)),
_ => None,
}
}
fn read_index(request: &mut Request) -> Option<usize> {
#[derive(Deserialize)]
struct Body {
index: usize,
}
let body: Body = serde_json::from_reader(request.as_reader()).ok()?;
Some(body.index)
}
fn cache_header(policy: &'static [u8]) -> Header {
Header::from_bytes(&b"Cache-Control"[..], policy).unwrap()
}
fn respond_json<T: Serialize>(request: Request, value: &T) {
let body = serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string());
let header = Header::from_bytes(
&b"Content-Type"[..],
&b"application/json; charset=utf-8"[..],
)
.unwrap();
let _ = request.respond(
Response::from_string(body)
.with_header(header)
.with_header(cache_header(b"no-store")),
);
}
fn respond_html(request: Request, html: &str) {
let header =
Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]).unwrap();
let _ = request.respond(
Response::from_string(html.to_string())
.with_header(header)
.with_header(cache_header(b"no-cache")),
);
}
fn respond_asset(request: Request, body: &str, content_type: &str) {
let header = Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes()).unwrap();
let _ = request.respond(
Response::from_string(body.to_string())
.with_header(header)
.with_header(cache_header(b"no-cache")),
);
}
fn respond_status(request: Request, code: u16) {
let _ = request.respond(Response::from_string(String::new()).with_status_code(code));
}
fn respond_bytes(request: Request, bytes: Vec<u8>, content_type: &str) {
let header = Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes()).unwrap();
let _ = request.respond(Response::from_data(bytes).with_header(header));
}
fn serve_image(request: Request, images: &HashMap<String, PathBuf>, key: &str) {
match images.get(key) {
Some(path) => match std::fs::read(path) {
Ok(bytes) => respond_bytes(request, bytes, content_type(path)),
Err(_) => respond_status(request, 404),
},
None => respond_status(request, 404),
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
#[test]
fn unconfigured_token_leaves_everything_open() {
assert!(is_authorized("/api/decks", None, None, None));
assert!(is_authorized("/", None, None, None));
}
#[test]
fn token_guards_only_the_api() {
let t = Some("secret");
assert!(is_authorized("/", None, None, t));
assert!(is_authorized("/img/deadbeef", None, None, t));
assert!(is_authorized("/theme.css", None, None, t));
assert!(!is_authorized("/api/decks", None, None, t));
assert!(!is_authorized("/api/decks", Some("Bearer wrong"), None, t));
assert!(is_authorized("/api/decks", Some("Bearer secret"), None, t));
assert!(is_authorized("/api/decks", None, Some("secret"), t));
}
#[test]
fn icon_field_registers_an_svg_and_flags_it() {
let mut icons = HashMap::new();
let (url, is_svg) = icon_field(Some(Path::new("/ws/assets/icon.svg")), &mut icons);
let url = url.unwrap();
assert!(url.starts_with("/img/"));
assert!(is_svg);
assert_eq!(icons.len(), 1);
assert!(icons.values().any(|p| p.ends_with("assets/icon.svg")));
let (none, flag) = icon_field(None, &mut icons);
assert!(none.is_none() && !flag);
assert_eq!(icons.len(), 1);
}
#[test]
fn card_dto_structures_the_note() {
let note = "Intro here.\n```\nfn main() {}\n```";
let card = Card::plain(
Arc::from("s.txt"),
"the front".to_string(),
vec!["the back".to_string()],
Some(note.to_string()),
1,
);
let dto = card_dto(&card);
assert_eq!(dto.front, "the front");
assert_eq!(dto.back, vec!["the back".to_string()]);
assert_eq!(dto.note.len(), 2);
match &dto.note[0] {
NoteUnitDto::Sentence { text } => assert_eq!(text, "Intro here."),
other => panic!("expected a sentence, got {other:?}"),
}
match &dto.note[1] {
NoteUnitDto::Code { lines } => assert_eq!(lines, &vec!["fn main() {}".to_string()]),
other => panic!("expected a code block, got {other:?}"),
}
}
#[test]
fn card_dto_exposes_image_urls_and_registry_matches() {
let mut card = Card::plain(
Arc::from("s.txt"),
"q".to_string(),
vec!["a".to_string()],
None,
1,
);
card.image = Some(PathBuf::from("/imgs/moon.png"));
card.image_back = Some(PathBuf::from("/imgs/tab.png"));
let dto = card_dto(&card);
let img = dto.img.expect("front image url");
let img_back = dto.img_back.expect("back image url");
assert!(img.starts_with("/img/"));
assert!(img_back.starts_with("/img/") && img_back != img);
let images = collect_images(std::slice::from_ref(&card));
assert_eq!(
images.get(img.strip_prefix("/img/").unwrap()),
Some(&PathBuf::from("/imgs/moon.png"))
);
assert_eq!(
images.get(img_back.strip_prefix("/img/").unwrap()),
Some(&PathBuf::from("/imgs/tab.png"))
);
}
#[test]
fn plain_card_has_no_image_urls() {
let card = Card::plain(
Arc::from("s.txt"),
"q".to_string(),
vec!["a".to_string()],
None,
1,
);
let dto = card_dto(&card);
assert!(dto.img.is_none() && dto.img_back.is_none());
assert!(collect_images(std::slice::from_ref(&card)).is_empty());
}
#[test]
fn content_type_by_extension() {
assert_eq!(content_type(Path::new("a.png")), "image/png");
assert_eq!(content_type(Path::new("a.JPG")), "image/jpeg");
assert_eq!(content_type(Path::new("a.jpeg")), "image/jpeg");
assert_eq!(content_type(Path::new("a.svg")), "image/svg+xml");
assert_eq!(content_type(Path::new("a.bin")), "application/octet-stream");
}
#[test]
fn resolve_name_rejects_unknown_deck() {
let mut known = HashMap::new();
known.insert("a.txt".to_string(), PathBuf::from("/decks/a.txt"));
assert_eq!(
resolve_name("a.txt", &known),
Some(PathBuf::from("/decks/a.txt"))
);
assert_eq!(resolve_name("../etc/passwd", &known), None);
}
#[test]
fn browse_payload_select_phase_has_no_cards() {
let dto = browse_payload(None);
assert_eq!(dto.phase, "select");
assert!(dto.cards.is_empty());
}
#[test]
fn review_state_select_phase_has_no_card() {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(dir.path().join("p.json")).unwrap();
let dto = review_state(None, &store);
assert_eq!(dto.phase, "select");
assert_eq!(dto.kind, "review");
assert!(dto.card.is_none());
let json = serde_json::to_value(&dto).unwrap();
assert!(json.get("finished").is_none());
}
#[test]
fn finished_review_uses_the_done_phase_not_a_finished_flag() {
let dir = tempfile::tempdir().unwrap();
let (mut r, _card, _deck) = one_card_reviewing(dir.path());
let mut store = Store::open(dir.path().join("graded.json")).unwrap();
r.session.grade(&mut store, Grade::Pass, now_ms());
assert!(r.session.is_finished());
let dto = review_state(Some(&r), &store);
assert_eq!(dto.phase, "done");
assert_eq!(dto.kind, "review");
}
fn reviewing_at(deck: PathBuf, cards: Vec<Card>, store: &Store, depth: Depth) -> Reviewing {
let session = Session::new(
cards,
store,
Box::new(Fsrs::default()),
crate::session::SessionOptions {
depth,
..Default::default()
},
now_ms(),
);
let mut decks = HashMap::new();
decks.insert("d.txt".to_string(), deck);
Reviewing::new(SessionBuild {
session,
label: "d.txt".to_string(),
decks,
links: HashMap::new(),
source_roots: HashMap::new(),
source_bases: HashMap::new(),
topology_name: None,
})
}
#[test]
fn state_reports_the_sessions_depth_and_typeline_mode() {
let dir = tempfile::tempdir().unwrap();
let deck = dir.path().join("d.txt");
let text = "# steps\n% reveal: line\n\tfirst\n\tsecond\n";
std::fs::write(&deck, text).unwrap();
let cards = crate::parser::parse_str("d.txt", text).unwrap();
let mut store = Store::open(dir.path().join("p.json")).unwrap();
store.get_or_insert(cards[0].id(), 0); let r = reviewing_at(deck, cards, &store, Depth::Reconstruct);
let dto = review_state(Some(&r), &store);
assert_eq!(
"reconstruct", dto.depth,
"the DTO reports the session's depth"
);
assert_eq!(
"typeline", dto.mode,
"reconstruct + `% reveal: line` types the next line"
);
}
#[test]
fn recognize_state_offers_gap_options_for_a_cloze_card() {
let dir = tempfile::tempdir().unwrap();
let deck = dir.path().join("d.txt");
let text = "# where\n% reveal: cloze\n\tThe {{cat}} sat here\n# a\n\tdog\n# b\n\tfish\n# c\n\tbird\n";
std::fs::write(&deck, text).unwrap();
let cards = crate::parser::parse_str("d.txt", text).unwrap();
assert_eq!(vec!["cat".to_string()], cards[0].back); let mut store = Store::open(dir.path().join("p.json")).unwrap();
store.get_or_insert(cards[0].id(), 0); let r = reviewing_at(deck, cards, &store, Depth::Recognize);
let dto = review_state(Some(&r), &store);
let opts = dto
.choices
.expect("a Recognize cloze card offers gap-filler options");
assert_eq!(choice::NUM_OPTIONS, opts.len());
assert!(
opts.contains(&"cat".to_string()),
"the gap text is an option"
);
}
#[test]
fn an_already_recognized_card_skips_the_acquire_mc() {
let dir = tempfile::tempdir().unwrap();
let deck = dir.path().join("d.txt");
let text = "# q\n\tanswer\n";
std::fs::write(&deck, text).unwrap();
let cards = crate::parser::parse_str("d.txt", text).unwrap();
let mut store = Store::open(dir.path().join("p.json")).unwrap();
let state = store.get_or_insert(cards[0].id(), 0);
state.recognized_ms = Some(500); let r = reviewing_at(deck, cards, &store, Depth::Recall);
let dto = review_state(Some(&r), &store);
assert!(!dto.acquire, "a recognized card isn't acquired cold");
assert!(
dto.choices.is_none(),
"no recognition MC for an already-recognized card"
);
assert_eq!("recall", dto.depth);
}
#[test]
fn grade_names_map_to_grades() {
assert!(matches!(Grade::Fail, Grade::Fail));
assert_eq!(mode_name(Mode::LineByLine), "line");
assert_eq!(mode_name(Mode::Flip), "flip");
assert_eq!(mode_name(Mode::Explain), "explain");
}
#[test]
fn input_name_matches_clap_value_names() {
assert_eq!(input_name(Input::Type), "type");
assert_eq!(input_name(Input::Draw), "draw");
}
fn one_card_reviewing(dir: &Path) -> (Reviewing, Card, PathBuf) {
let deck = dir.join("d.txt");
std::fs::write(&deck, "# front\n\tback\n").unwrap();
let store = Store::open(dir.join("p.json")).unwrap();
let card = Card::plain(
Arc::from("d.txt"),
"front".to_string(),
vec!["back".to_string()],
None,
1,
);
let session = Session::new(
vec![card.clone()],
&store,
Box::new(Fsrs::default()),
crate::session::SessionOptions::default(),
now_ms(),
);
let mut decks = HashMap::new();
decks.insert("d.txt".to_string(), deck.clone());
let reviewing = Reviewing::new(SessionBuild {
session,
label: "d.txt".to_string(),
decks,
links: HashMap::new(),
source_roots: HashMap::new(),
source_bases: HashMap::new(),
topology_name: None,
});
(reviewing, card, deck)
}
#[test]
fn poll_ask_records_answer_in_transcript() {
let dir = tempfile::tempdir().unwrap();
let (mut r, card, _deck) = one_card_reviewing(dir.path());
let (tx, rx) = std::sync::mpsc::channel();
r.ask.pending = Some(Pending {
rx,
purpose: Purpose::Question("why is s1 invalid?".to_string()),
card,
});
assert_eq!((None, None), r.poll_ask());
assert!(r.ask_dto(None, None).thinking);
tx.send(Reply::Answer("because ownership moved".to_string()))
.unwrap();
assert_eq!((None, None), r.poll_ask());
assert!(r.ask.pending.is_none());
assert_eq!(1, r.ask.transcript.len());
assert_eq!("why is s1 invalid?", r.ask.transcript[0].0);
assert_eq!("because ownership moved", r.ask.transcript[0].1);
assert!(r.ask.cli.started); }
#[test]
fn ask_transcript_resets_when_the_card_changes() {
let dir = tempfile::tempdir().unwrap();
let (mut r, card, _deck) = one_card_reviewing(dir.path());
r.ask
.transcript
.push(("old q".to_string(), "old a".to_string()));
r.ask.subject = Some(card.id().wrapping_add(1)); r.ask.cli.started = true;
r.align_transcript();
assert!(r.ask.transcript.is_empty());
assert_eq!(Some(card.id()), r.ask.subject);
assert!(r.ask.cli.started);
}
#[test]
fn poll_ask_condense_appends_note_to_deck() {
let dir = tempfile::tempdir().unwrap();
let (mut r, card, deck) = one_card_reviewing(dir.path());
r.ask.transcript.push(("q".to_string(), "a".to_string()));
let (tx, rx) = std::sync::mpsc::channel();
r.ask.pending = Some(Pending {
rx,
purpose: Purpose::Condense,
card,
});
tx.send(Reply::Answer("- key insight to reread".to_string()))
.unwrap();
let (status, error) = r.poll_ask();
assert_eq!(Some("note saved".to_string()), status);
assert!(error.is_none());
let text = std::fs::read_to_string(&deck).unwrap();
assert!(text.contains("key insight to reread"), "deck:\n{text}");
}
#[test]
fn poll_ask_error_resets_session() {
let dir = tempfile::tempdir().unwrap();
let (mut r, card, _deck) = one_card_reviewing(dir.path());
r.ask.cli.started = true;
let (tx, rx) = std::sync::mpsc::channel();
r.ask.pending = Some(Pending {
rx,
purpose: Purpose::Question("q".to_string()),
card,
});
tx.send(Reply::Error("not logged in".to_string())).unwrap();
let (status, error) = r.poll_ask();
assert_eq!(Some("not logged in".to_string()), error);
assert!(status.is_none());
assert!(r.ask.pending.is_none());
assert!(!r.ask.cli.started); assert!(r.ask.transcript.is_empty());
}
fn walk_deck(dir: &Path) -> crate::trace::Trace {
std::fs::write(dir.join("source.txt"), "first\nsecond\nthird\n").unwrap();
let path = dir.join("t.txt");
std::fs::write(
&path,
"% trace: how it works\n\
% source: source.txt\n\
# Predict the first hop\n\
\t% given: line — the input line\n\
\tit reads the first line\n\
\t% at: 1\n\
# Predict the second hop\n\
\tit reads line two\n\
\t% at: 2\n",
)
.unwrap();
crate::trace::Trace::from_deck(&Deck::load(&path).unwrap()).unwrap()
}
#[test]
fn walk_dto_tracks_phase_excerpt_and_rail() {
let dir = tempfile::tempdir().unwrap();
let trace = walk_deck(dir.path());
let mut store = Store::open(dir.path().join("p.json")).unwrap();
let walk = Walk::new(trace);
let mut w = Walking::new(walk, None);
let d = walk_dto(&w);
assert_eq!("walk", d.kind);
assert_eq!("predict", d.phase);
assert_eq!(1, d.current);
assert_eq!(2, d.total);
assert_eq!(Some("Predict the first hop".to_string()), d.prompt);
assert_eq!(vec!["line — the input line".to_string()], d.givens);
assert!(d.excerpt.is_none());
assert!(!d.auto_grade);
assert!(d.path[0].current && d.path[0].delta.is_none());
w.walk.predict("my guess".to_string());
let d = walk_dto(&w);
assert_eq!("reveal", d.phase);
assert_eq!(Some("my guess".to_string()), d.prediction);
let ex = d.excerpt.expect("reveal reads the source");
assert_eq!(
vec![(1, "first".to_string())],
ex.lines
.iter()
.map(|l| (l.n, l.text.clone()))
.collect::<Vec<_>>()
);
assert_eq!(vec!["it reads the first line".to_string()], d.points);
w.walk.grade(&mut store, Delta::Passed, 1000);
let d = walk_dto(&w);
assert_eq!("predict", d.phase);
assert_eq!(2, d.current);
assert_eq!(Some("passed"), d.path[0].delta);
assert!(d.path[1].current);
w.walk.predict(String::new());
w.walk.grade(&mut store, Delta::Failed, 1001);
let d = walk_dto(&w);
assert_eq!("done", d.phase);
let s = d.summary.expect("done has a summary");
assert_eq!((1, 0, 1), (s.passed, s.partly, s.failed));
assert_eq!(vec![2], s.weak); }
#[test]
fn walk_dto_surfaces_a_live_grade_and_clears_it() {
let dir = tempfile::tempdir().unwrap();
let trace = walk_deck(dir.path());
let walk = Walk::new(trace);
let mut w = Walking::new(walk, Some(AskConfig::default()));
w.walk.predict("g".to_string());
w.grade_result = Some((Delta::Partial, "right idea, missed a detail".to_string()));
let d = walk_dto(&w);
assert!(d.auto_grade);
assert_eq!(Some("Partly"), d.verdict);
assert_eq!(Some("right idea, missed a detail".to_string()), d.feedback);
w.clear_grade();
let d = walk_dto(&w);
assert!(d.verdict.is_none() && d.feedback.is_none() && !d.thinking);
}
#[test]
fn walk_ask_condense_appends_a_note_to_the_checkpoint() {
let dir = tempfile::tempdir().unwrap();
let trace = walk_deck(dir.path());
let deck_path = trace.deck_path.clone();
let walk = Walk::new(trace);
let mut w = Walking::new(walk, None);
w.walk.predict("guess".to_string());
let card = w.checkpoint_card().expect("a checkpoint card");
let (tx, rx) = std::sync::mpsc::channel();
w.ask.pending = Some(Pending {
rx,
purpose: Purpose::Condense,
card,
});
tx.send(Reply::Answer(
"- the read lock is released first".to_string(),
))
.unwrap();
let (status, error) = w.poll_ask();
assert_eq!(Some("note saved".to_string()), status);
assert!(error.is_none());
let text = std::fs::read_to_string(&deck_path).unwrap();
assert!(
text.contains("the read lock is released first"),
"deck:\n{text}"
);
}
fn aug_card(front: &str, back: &str) -> Card {
Card::plain(
Arc::from("d.txt"),
front.to_string(),
vec![back.to_string()],
None,
1,
)
}
#[test]
fn augmenting_reports_coverage_and_removal_persists() {
let dir = tempfile::tempdir().unwrap();
let cache_path = dir.path().join("augment.json");
let cards = vec![aug_card("Q1", "a"), aug_card("Q2", "b")];
let mut seed = AugmentCache::open(&cache_path);
seed.set_distractors(cards[0].id(), vec!["x".into()]);
seed.set_note(cards[1].id(), "n".into());
seed.save().unwrap();
let mut aug = Augmenting::open("d.txt".into(), cards.clone(), cache_path.clone());
let dto = aug.dto();
assert_eq!(2, dto.cards);
assert!(dto.busy.is_none());
let choices = dto.rows.iter().find(|r| r.kind == "choices").unwrap();
assert_eq!((1, 2), (choices.covered, choices.eligible));
let topo = dto.rows.iter().find(|r| r.kind == "topology").unwrap();
assert!(topo.items.is_empty());
assert!(aug.remove("choices", None));
assert_eq!(
0,
aug.dto()
.rows
.iter()
.find(|r| r.kind == "choices")
.unwrap()
.covered
);
let reloaded = AugmentCache::open(&cache_path);
assert_eq!(None, reloaded.distractors(cards[0].id()));
assert_eq!(Some("n"), reloaded.note(cards[1].id()));
assert!(!aug.remove("bogus", None)); }
#[test]
fn augmenting_generate_is_a_noop_when_a_target_is_fully_covered() {
let dir = tempfile::tempdir().unwrap();
let cache_path = dir.path().join("augment.json");
let cards = vec![aug_card("Q", "a")];
let mut seed = AugmentCache::open(&cache_path);
seed.set_distractors(cards[0].id(), vec!["x".into()]);
seed.save().unwrap();
let mut aug = Augmenting::open("d.txt".into(), cards, cache_path);
let started = aug.generate("choices", None, &AiConfig::default(), &AskConfig::default());
assert!(!started);
assert!(aug.dto().busy.is_none());
}
#[test]
fn deck_topology_dto_deck_due_includes_a_due_virtual_card() {
let dir = tempfile::tempdir().unwrap();
let deck_path = dir.path().join("rust.txt");
std::fs::write(&deck_path, "# q1\n\ta1\n").unwrap();
let deck = Deck::load(&deck_path).unwrap();
let mut store = Store::open(dir.path().join("progress.json")).unwrap();
let now = now_ms();
store.get_or_insert(deck.cards[0].id(), now).recall = Some(crate::store::FsrsState {
state: 2,
scheduled_days: 30,
due_ms: now + 30 * 86_400_000,
..Default::default()
});
let augment = AugmentCache::open(augment::augment_path_for(store.path()));
let before = deck_topology_dto(&augment, &store, &deck, ReviewConfig::default());
assert_eq!(0, before.deck_due);
let vtext = "# virtual front\n\tvirtual back\n".to_string();
let vid = crate::parser::parse_str(&deck.subject, &vtext).unwrap()[0].id();
store.insert_virtual(crate::store::VirtualCard {
id: vid,
kind: crate::store::VirtualKind::Remediation,
parent: deck.subject.clone(),
text: vtext,
created_ms: 0,
});
store.get_or_insert(vid, 0);
let after = deck_topology_dto(&augment, &store, &deck, ReviewConfig::default());
assert_eq!(1, after.deck_due);
}
}