mod catalog;
mod dto;
mod jobs;
mod respond;
use std::{
collections::HashMap,
net::SocketAddr,
path::{Path, PathBuf},
sync::Arc,
time::Instant,
};
use anyhow::{Result, anyhow};
use catalog::*;
use dto::*;
use jobs::*;
use respond::*;
use serde::Deserialize;
use tiny_http::{Method, Server};
pub use crate::assemble::SelectOptions;
use crate::{
assemble::{self, CardsBuild, SessionBuild},
augment::{self, AugmentCache},
config::{
AiConfig, Audience, Bindings, BrowseBindings, ExamConfig, GenerateDeckConfig, PickerKeys,
},
deck::{self, Deck},
doctor, exam, generate, import,
recent::RecentDecks,
review,
session::now_ms,
share,
store::{self, Store},
trace::{self, Walk},
};
const REVIEW_HTML: &str = include_str!("../../assets/web/review.html");
const KIDS_HTML: &str = include_str!("../../assets/web/kids/kids.html");
const THEME_CSS: &str = include_str!("../../assets/web/theme.css");
const THEME_JS: &str = include_str!("../../assets/web/theme.js");
const ALIX_LOGO_JS: &str = include_str!("../../assets/web/alix-logo.js");
const HEAD_HTML: &str = include_str!("../../assets/web/_head.html");
const BRAND_HTML: &str = include_str!("../../assets/web/_brand.html");
const PLEX_SANS_400: &[u8] = include_bytes!("../../assets/web/fonts/ibm-plex-sans-400.woff2");
const PLEX_SANS_500: &[u8] = include_bytes!("../../assets/web/fonts/ibm-plex-sans-500.woff2");
const PLEX_SANS_600: &[u8] = include_bytes!("../../assets/web/fonts/ibm-plex-sans-600.woff2");
const PLEX_SANS_700: &[u8] = include_bytes!("../../assets/web/fonts/ibm-plex-sans-700.woff2");
const PLEX_MONO_400: &[u8] = include_bytes!("../../assets/web/fonts/ibm-plex-mono-400.woff2");
const PLEX_MONO_500: &[u8] = include_bytes!("../../assets/web/fonts/ibm-plex-mono-500.woff2");
const PLEX_MONO_600: &[u8] = include_bytes!("../../assets/web/fonts/ibm-plex-mono-600.woff2");
const PLEX_MONO_700: &[u8] = include_bytes!("../../assets/web/fonts/ibm-plex-mono-700.woff2");
const BALOO2_400: &[u8] = include_bytes!("../../assets/web/kids/fonts/baloo2-400.woff2");
const BALOO2_500: &[u8] = include_bytes!("../../assets/web/kids/fonts/baloo2-500.woff2");
const BALOO2_600: &[u8] = include_bytes!("../../assets/web/kids/fonts/baloo2-600.woff2");
const BALOO2_700: &[u8] = include_bytes!("../../assets/web/kids/fonts/baloo2-700.woff2");
const BALOO2_800: &[u8] = include_bytes!("../../assets/web/kids/fonts/baloo2-800.woff2");
fn font_bytes(name: &str) -> Option<&'static [u8]> {
match name {
"ibm-plex-sans-400.woff2" => Some(PLEX_SANS_400),
"ibm-plex-sans-500.woff2" => Some(PLEX_SANS_500),
"ibm-plex-sans-600.woff2" => Some(PLEX_SANS_600),
"ibm-plex-sans-700.woff2" => Some(PLEX_SANS_700),
"ibm-plex-mono-400.woff2" => Some(PLEX_MONO_400),
"ibm-plex-mono-500.woff2" => Some(PLEX_MONO_500),
"ibm-plex-mono-600.woff2" => Some(PLEX_MONO_600),
"ibm-plex-mono-700.woff2" => Some(PLEX_MONO_700),
"baloo2-400.woff2" => Some(BALOO2_400),
"baloo2-500.woff2" => Some(BALOO2_500),
"baloo2-600.woff2" => Some(BALOO2_600),
"baloo2-700.woff2" => Some(BALOO2_700),
"baloo2-800.woff2" => Some(BALOO2_800),
_ => None,
}
}
static REVIEW_PAGE: std::sync::LazyLock<String> =
std::sync::LazyLock::new(|| compose_page(REVIEW_HTML));
static KIDS_PAGE: std::sync::LazyLock<String> =
std::sync::LazyLock::new(|| compose_page(KIDS_HTML));
fn compose_page(html: &str) -> String {
html.replace("<!--%head%-->", HEAD_HTML)
.replace("<!--%brand%-->", BRAND_HTML)
}
fn app_page(audience: Audience) -> &'static str {
match audience {
Audience::Adult => &REVIEW_PAGE,
Audience::Kids => &KIDS_PAGE,
}
}
pub struct ReviewOptions {
pub keys: Bindings,
pub picker: PickerKeys,
pub browse: BrowseBindings,
pub exam: ExamConfig,
pub ai: AiConfig,
pub generate: GenerateDeckConfig,
pub audience: Audience,
pub auth: Option<String>,
pub config_path: Option<PathBuf>,
pub pair: PairInfo,
pub scoped: bool,
pub cfg: assemble::AssembleConfig,
}
pub struct PairInfo {
pub url: String,
pub lan: bool,
}
pub fn bind(addr: SocketAddr) -> Result<Server> {
Server::http(addr).map_err(|e| {
anyhow!(
"cannot start the server on {addr}: {e} — is another alix using this port? try --port"
)
})
}
pub fn run_review(
mut store: Store,
mut recent: RecentDecks,
mut decks_dir: PathBuf,
server: Arc<Server>,
opts: ReviewOptions,
) -> Result<()> {
let ReviewOptions {
keys: bindings,
picker: picker_keys,
browse: browse_bindings,
exam: exam_cfg,
ai: ai_cfg,
generate: generate_cfg,
audience,
auth,
config_path,
pair,
scoped,
cfg,
} = opts;
let ask_cfg = cfg.ask.clone();
let review_cfg = cfg.review;
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): (Option<Reviewing>, Option<Browsing>) = (None, None);
let mut examining: Option<Examining> = None;
let mut augmenting: Option<Augmenting> = None;
let mut generating: Option<Generating> = None;
let mut sharing: Option<Sharing> = None;
let mut receiving: Option<Receiving> = None;
let mut walking: Option<Walking> = None;
let mut launcher_icons: HashMap<String, PathBuf> = HashMap::new();
let http_log = std::env::var_os("ALIX_HTTP_LOG").is_some();
for mut request in server.incoming_requests() {
let method = request.method().clone();
let path = request_path(&request);
if http_log {
eprintln!("[http] {method} {path}");
}
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, app_page(audience)),
(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, key) if key.starts_with("/fonts/") => {
let name = &key["/fonts/".len()..];
match font_bytes(name) {
Some(bytes) => respond_font(request, bytes),
None => respond_status(request, 404),
}
}
(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/doctor") => {
let (cfg, _) = doctor::check_config(config_path.as_deref());
let rows = vec![
cfg,
doctor::check_store(Some(store.path().to_path_buf())),
doctor::check_decks(&decks_dir),
doctor::check_binary(
"backend",
&ask_cfg.command,
"the AI features (tutor, exam, generate)",
"install it and log in — or switch `[ask] backend` in the config",
),
doctor::check_binary(
"share",
"wormhole",
"sharing (`alix share`/`receive`)",
"install magic-wormhole (e.g. `pipx install magic-wormhole`, or your package manager)",
),
]
.into_iter()
.map(DoctorRowDto::from)
.collect();
respond_json(request, &DoctorDto { rows })
}
(Method::Get, "/api/pair") => {
let svg = if pair.lan {
crate::qr::svg(&pair.url)
} else {
None
};
respond_json(
request,
&PairDto {
url: pair.url.clone(),
svg,
lan: pair.lan,
},
)
}
(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 = decks_list_dto(
scoped,
config_path.as_deref(),
&mut decks_dir,
&recent,
&store,
&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 opts = sel.opts;
let paths = vec![sel.deck];
if let Err(e) = assemble::store_for(&paths, cfg.instance_store.as_deref())
.map(|s| store = s)
{
eprintln!("warning: could not open the progress store: {e}");
respond_status(request, 400);
continue;
}
let recorded_paths = paths.clone();
match assemble::select(paths, &mut store, &cfg, &opts) {
Ok(assemble::Selected::Walk(wb)) => {
let w = Walking::new(wb.walk, wb.grade);
let dto = walk_dto(&w);
walking = Some(w);
reviewing = None;
examining = None;
respond_json(request, &dto);
}
Ok(assemble::Selected::Review(b)) => {
if !b.session.is_finished() {
recent.record(&recorded_paths, now_ms());
let _ = recent.save();
}
let mut r = Reviewing::new(b);
r.open_augment(store.path());
r.rotate_variant();
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);
}
}
}
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) = assemble::store_for(&paths, cfg.instance_store.as_deref())
.map(|s| store = s)
{
eprintln!("warning: could not open the progress store: {e}");
respond_status(request, 400);
continue;
}
let recorded_paths = paths.clone();
match assemble::browse(paths) {
Ok(b) => {
recent.record(&recorded_paths, now_ms());
let _ = recent.save();
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),
assemble::store_for(
std::slice::from_ref(&sel.deck),
cfg.instance_store.as_deref(),
),
) {
(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/reset") => {
#[derive(Deserialize)]
struct Body {
deck: String,
}
let Some(body) = serde_json::from_reader::<_, Body>(request.as_reader()).ok()
else {
respond_status(request, 400);
continue;
};
let paths = match resolve_row(&body.deck, &decks_dir, &recent) {
Resolved::One(p) => vec![p],
Resolved::Many { files, .. } => files,
Resolved::Ambiguous | Resolved::Unknown => {
respond_status(request, 400);
continue;
}
};
let name = body.deck;
let decks: Vec<Deck> = match paths.iter().map(Deck::load).collect() {
Ok(d) => d,
Err(_) => {
respond_status(request, 400);
continue;
}
};
let cleared = assemble::store_for(&paths, cfg.instance_store.as_deref())
.and_then(|mut s| crate::library::reset_decks(&mut s, decks.iter()));
match cleared {
Ok(n) => {
if let Ok(s) = assemble::store_for(&[], cfg.instance_store.as_deref()) {
store = s;
}
respond_json(
request,
&ResetDto {
deck: name,
cards_cleared: n,
},
);
}
Err(_) => respond_status(request, 400),
}
}
(Method::Post, "/api/workspace/deadline") => {
fn deserialize_some<'de, D>(
deserializer: D,
) -> Result<Option<Option<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<String>::deserialize(deserializer).map(Some)
}
#[derive(Deserialize)]
struct Body {
name: String,
#[serde(default, deserialize_with = "deserialize_some")]
date: Option<Option<String>>,
}
let Some(body) = serde_json::from_reader::<_, Body>(request.as_reader()).ok()
else {
respond_status(request, 400);
continue;
};
let date = match body.date {
None => {
respond_status(request, 400);
continue;
}
Some(None) => None,
Some(Some(s)) => match chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d") {
Ok(d) => Some(d),
Err(_) => {
respond_status(request, 400);
continue;
}
},
};
let dir = match resolve_row(&body.name, &decks_dir, &recent) {
Resolved::Many { dir, .. } if crate::workspace::is_workspace(&dir) => dir,
_ => {
respond_status(request, 400);
continue;
}
};
if let Err(e) = crate::workspace::set_deadline(&dir, date) {
eprintln!("workspace deadline write failed: {e:#}");
respond_status(request, 500);
continue;
}
let catalog = decks_list_dto(
scoped,
config_path.as_deref(),
&mut decks_dir,
&recent,
&store,
&mut launcher_icons,
review_cfg,
);
respond_json(request, &catalog);
}
(Method::Post, "/api/import") => {
#[derive(Deserialize)]
struct Body {
name: String,
text: String,
dest: Option<String>,
}
let Some(b) = serde_json::from_reader::<_, Body>(request.as_reader()).ok() else {
respond_status(request, 400);
continue;
};
let Some(dir) = resolve_dest(b.dest.as_deref(), &decks_dir, &recent) else {
respond_status(request, 400);
continue;
};
let lower_name = b.name.to_ascii_lowercase();
let text = if lower_name.ends_with(".tsv") {
match import::tsv_to_deck(&b.text) {
Ok(t) => t,
Err(_) => {
respond_status(request, 400);
continue;
}
}
} else if lower_name.ends_with(".txt") {
b.text
} else {
respond_status(request, 400);
continue;
};
let place_name = normalize_txt_extension(&b.name, &lower_name);
match crate::library::place_deck(&dir, &place_name, &text) {
Ok(p) if p.parse_error.is_none() => {
let deck = p
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
respond_json(
request,
&ImportDto {
deck,
cards: p.cards,
},
);
}
Ok(p) => {
std::fs::remove_file(&p.path).ok();
respond_status(request, 400);
}
Err(_) => respond_status(request, 400),
}
}
(Method::Post, "/api/generate") => {
#[derive(Deserialize)]
struct Body {
url: String,
guidance: Option<String>,
dest: Option<String>,
}
if let Some(g) = generating.as_mut() {
g.poll();
}
if generating.as_ref().is_some_and(|g| g.outcome.is_none()) {
respond_status(request, 409); continue;
}
let body: Option<Body> = serde_json::from_reader(request.as_reader()).ok();
let Some(b) =
body.filter(|b| b.url.starts_with("http://") || b.url.starts_with("https://"))
else {
respond_status(request, 400); continue;
};
let Some(dest) = resolve_dest(b.dest.as_deref(), &decks_dir, &recent) else {
respond_status(request, 400);
continue;
};
let name = generate::deck_name(&b.url);
let stem = name.strip_suffix(".txt").unwrap_or(&name);
let file = format!("{stem}.txt");
if dest.join(&file).exists() {
respond_json(
request,
&GenerateDto {
phase: "error",
deck: None,
cards: None,
elapsed: Some(0),
error: Some(format!(
"{file} already exists — rename it or generate into another destination"
)),
},
);
continue;
}
let mut cfg = generate_cfg.clone();
if let Some(g) = b
.guidance
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
{
cfg.extra = Some(g);
}
let g = Generating {
rx: generate::spawn(b.url.clone(), cfg, ask_cfg.clone()),
url: b.url,
dest,
started: Instant::now(),
outcome: None,
};
let dto = g.dto();
generating = Some(g);
respond_json(request, &dto);
}
(Method::Get, "/api/generate") => {
let Some(g) = generating.as_mut() else {
respond_status(request, 409);
continue;
};
g.poll();
respond_json(request, &g.dto());
}
(Method::Post, "/api/generate/close") => {
generating = None; respond_status(request, 200);
}
(Method::Post, "/api/share") => {
#[derive(Deserialize)]
struct Body {
deck: Option<String>,
}
if let Some(s) = sharing.as_mut() {
s.poll();
}
if sharing.as_ref().is_some_and(|s| s.outcome.is_none()) {
respond_status(request, 409); continue;
}
let body: Option<Body> = serde_json::from_reader(request.as_reader()).ok();
let path = match body.and_then(|b| b.deck) {
None => Some(decks_dir.clone()),
Some(name) => resolved_path(resolve_row(&name, &decks_dir, &recent)),
};
let Some(path) = path else {
respond_status(request, 400);
continue;
};
let started = tempfile::tempdir()
.map_err(|e| anyhow!("{e}"))
.and_then(|tmp| {
let to_send = stage_for_share(&path, &tmp)?;
let job = share::send_spawn(&to_send)?;
Ok(Sharing {
job,
_stage: tmp,
code: None,
started: Instant::now(),
outcome: None,
})
});
match started {
Ok(s) => {
let dto = s.dto();
sharing = Some(s);
respond_json(request, &dto);
}
Err(e) => respond_json(
request,
&ShareDto {
phase: "error",
code: None,
elapsed: Some(0),
error: Some(format!("{e:#}")),
},
),
}
}
(Method::Get, "/api/share") => {
let Some(s) = sharing.as_mut() else {
respond_status(request, 409);
continue;
};
s.poll();
respond_json(request, &s.dto());
}
(Method::Post, "/api/share/close") => {
if let Some(s) = sharing.take() {
s.job.cancel();
}
respond_status(request, 200);
}
(Method::Get, "/api/share/zip") => {
let name = query_param(request.url(), "deck");
let path = match &name {
None => Some(decks_dir.clone()),
Some(n) => resolved_path(resolve_row(n, &decks_dir, &recent)),
};
let Some(path) = path else {
respond_status(request, 400);
continue;
};
let zipped = tempfile::tempdir().ok().and_then(|tmp| {
let staged = stage_for_share(&path, &tmp).ok()?;
let out = tmp.path().join("share.zip");
share::zip_to(&staged, &out).ok()?;
std::fs::read(&out).ok()
});
match zipped {
Some(bytes) => {
let stem = name
.as_deref()
.map(|n| n.rsplit('/').next().unwrap_or(n))
.unwrap_or("shared-decks");
respond_download(request, bytes, "application/zip", &format!("{stem}.zip"));
}
None => respond_status(request, 400),
}
}
(Method::Post, "/api/receive") => {
#[derive(Deserialize)]
struct Body {
code: String,
dest: Option<String>,
}
if let Some(r) = receiving.as_mut() {
r.poll();
}
if receiving.as_ref().is_some_and(|r| r.outcome.is_none()) {
respond_status(request, 409); continue;
}
let body: Option<Body> = serde_json::from_reader(request.as_reader()).ok();
let Some(b) = body else {
respond_status(request, 400);
continue;
};
let Some(dest) = resolve_dest(b.dest.as_deref(), &decks_dir, &recent) else {
respond_status(request, 400);
continue;
};
let started = tempfile::tempdir()
.map_err(|e| anyhow!("{e}"))
.and_then(|tmp| {
let job = share::receive_spawn(&b.code, tmp.path())?;
Ok(Receiving {
job,
tmp,
dest,
started: Instant::now(),
outcome: None,
})
});
match started {
Ok(r) => {
let dto = r.dto();
receiving = Some(r);
respond_json(request, &dto);
}
Err(e) => respond_json(
request,
&ReceiveDto {
phase: "error",
landed: None,
stripped: Vec::new(),
elapsed: Some(0),
error: Some(format!("{e:#}")),
},
),
}
}
(Method::Get, "/api/receive") => {
let Some(r) = receiving.as_mut() else {
respond_status(request, 409);
continue;
};
r.poll();
respond_json(request, &r.dto());
}
(Method::Post, "/api/receive/close") => {
if let Some(r) = receiving.take() {
r.job.cancel();
}
respond_status(request, 200);
}
(Method::Post, "/api/receive/zip") => {
const MAX_ZIP: usize = 50 * 1024 * 1024;
if request.body_length().is_some_and(|l| l > MAX_ZIP) {
respond_status(request, 400);
continue;
}
let Some(dest) = resolve_dest(
query_param(request.url(), "dest").as_deref(),
&decks_dir,
&recent,
) else {
respond_status(request, 400);
continue;
};
let Some(bytes) = read_capped(request.as_reader(), MAX_ZIP) else {
respond_status(request, 400);
continue;
};
let landed = tempfile::tempdir().ok().and_then(|tmp| {
let zip_path = tmp.path().join("got.zip");
std::fs::write(&zip_path, &bytes).ok()?;
let scratch = tmp.path().join("out");
std::fs::create_dir_all(&scratch).ok()?;
share::unzip_to(&zip_path, &scratch).ok()?;
share::land_received(&scratch, &dest).ok()
});
match landed {
Some((landed, stripped)) => respond_json(
request,
&ReceiveDto {
phase: "done",
landed: Some(landed),
stripped,
elapsed: Some(0),
error: None,
},
),
None => respond_status(request, 400),
}
}
(Method::Post, "/api/deselect") => {
reviewing = None;
walking = None;
browsing = None;
if let Ok(s) = assemble::store_for(&[], cfg.instance_store.as_deref()) {
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>,
}
let body: Option<Body> = serde_json::from_reader(request.as_reader()).ok();
let result = body.and_then(|body| review::check_typed(&r.session, &body.lines));
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| review::choose(&r.session, &store, &r.augment, chosen));
match picked {
Some(f) => respond_json(request, &f),
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, audience, AskAction::Question(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, audience, AskAction::Condense);
respond_json(request, &r.ask_dto(None, None));
}
(Method::Post, "/api/ask/card/draft") => {
if audience == Audience::Kids {
respond_status(request, 403);
continue;
}
let Some(r) = reviewing.as_mut() else {
respond_status(request, 409);
continue;
};
r.start_ask(&ask_cfg, audience, AskAction::DraftCard);
respond_json(request, &r.ask_dto(None, None));
}
(Method::Post, "/api/ask/card/create") => {
if audience == Audience::Kids {
respond_status(request, 403);
continue;
}
let Some(r) = reviewing.as_mut() else {
respond_status(request, 409);
continue;
};
let Some(req) =
serde_json::from_reader::<_, CreateCardReq>(request.as_reader()).ok()
else {
respond_status(request, 400);
continue;
};
let Some(card) = r.session.current() else {
respond_status(request, 409);
continue;
};
let subject = card.subject.to_string();
let deck_ids: std::collections::HashSet<u64> =
r.session.cards().iter().map(|c| c.id()).collect();
let now = now_ms();
match store::mint_tutor_card(
&mut store, &subject, &req.front, &req.back, now, &deck_ids,
) {
Ok(id) => {
if let Err(e) = store.save() {
eprintln!("warning: could not save progress: {e}");
}
respond_json(request, &CreateCardResp { id: id.to_string() });
}
Err(store::MintError::Duplicate | store::MintError::Malformed(_)) => {
respond_status(request, 422);
}
}
}
(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 Some(body) = serde_json::from_reader::<_, Body>(request.as_reader()).ok()
else {
respond_status(request, 400);
continue;
};
let Some(path) = resolved_path(resolve_row(&body.deck, &decks_dir, &recent)) else {
respond_status(request, 400);
continue;
};
if let Ok(s) =
assemble::store_for(std::slice::from_ref(&path), cfg.instance_store.as_deref())
{
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(),
) {
respond_json(request, &cooldown_dto(&deck.subject, 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 {
if exam::ensure_backend_can_examine(&deck, &ask_cfg).is_err() {
respond_status(request, 409);
continue;
}
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) = assemble::store_for(&[], cfg.instance_store.as_deref()) {
store = s;
}
respond_json(request, &review_state(reviewing.as_ref(), &store));
}
(Method::Post, "/api/augment/open") => {
#[derive(Deserialize)]
struct Body {
deck: String,
}
let Some(body) = serde_json::from_reader::<_, Body>(request.as_reader()).ok()
else {
respond_status(request, 400);
continue;
};
let (files, workspace_dir) = match resolve_row(&body.deck, &decks_dir, &recent) {
Resolved::One(p) => (vec![p], None),
Resolved::Many { dir, files } => (files, Some(dir)),
_ => {
respond_status(request, 400);
continue;
}
};
let name = body.deck;
if let Ok(s) = assemble::store_for(&files, cfg.instance_store.as_deref()) {
store = s;
}
let mut cards = Vec::new();
let loaded = files
.iter()
.try_for_each(|f| Deck::load(f).map(|d| cards.extend(d.cards)).map(|_| ()));
match loaded {
Ok(()) => {
let aug = Augmenting::open(
name,
cards,
augment::augment_path_for(store.path()),
workspace_dir,
);
let dto = aug.dto();
augmenting = Some(aug);
respond_json(request, &dto);
}
Err(_) => respond_status(request, 409),
}
}
(Method::Post, "/api/augment/generate") => {
#[derive(Deserialize)]
struct TargetBody {
target: String,
with: Option<String>,
}
#[derive(Deserialize)]
struct Body {
targets: Vec<TargetBody>,
}
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 targets = b
.targets
.into_iter()
.map(|t| {
let guidance = t
.with
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
(t.target, guidance)
})
.collect();
aug.generate_batch(targets, &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(&ai_cfg, &ask_cfg);
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) = assemble::store_for(&[], cfg.instance_store.as_deref()) {
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, audience, 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, audience, 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) = assemble::store_for(&[], cfg.instance_store.as_deref()) {
store = s;
}
respond_json(request, &review_state(reviewing.as_ref(), &store));
}
_ => respond_status(request, 404),
}
}
Ok(())
}
fn decks_list_dto(
scoped: bool,
config_path: Option<&Path>,
decks_dir: &mut PathBuf,
recent: &RecentDecks,
store: &Store,
launcher_icons: &mut HashMap<String, PathBuf>,
review_cfg: crate::config::ReviewConfig,
) -> DeckListDto {
let dir = effective_decks_dir(scoped, config_path, decks_dir);
if dir != *decks_dir {
*decks_dir = dir;
}
deck_catalog(decks_dir, recent, store, true, launcher_icons, review_cfg)
}
#[cfg(test)]
mod contract;
#[cfg(test)]
mod tests;