this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: multi-file decks, tags, FSRS review loop

tala-format:
- Add tags: Vec<String> to CardEntry, parsed from tags: ("a", "b") arg
- collect_args now returns tags separately from named string args

tala-srs:
- Sidecar is now per-.typ-file: path_for(), load_or_empty_for(), save_for()
(old deck-dir helpers kept as deprecated for backward compat)
- Add Grade enum (Again/Hard/Good/Easy) and next_schedule() using FSRS
- Date arithmetic implemented without external dep (Gregorian epoch days)
- Add today_str() and is_due() helpers

tala-cli:
- check/review now accept a file or directory (recursive .typ scan)
- review supports --tag filter
- Full FSRS review loop: grading → next_schedule → sidecar write → git commit
- ImgCloze cards skipped in CLI (require tala-ui for rendering)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+462 -73
+1
Cargo.lock
··· 4416 4416 name = "tala-srs" 4417 4417 version = "0.1.0" 4418 4418 dependencies = [ 4419 + "burn", 4419 4420 "fsrs", 4420 4421 "serde", 4421 4422 "serde_json",
+1
Cargo.toml
··· 15 15 serde = { version = "1.0.228", features = ["derive"] } 16 16 serde_json = "1.0.149" 17 17 fsrs = "5.2.0" 18 + burn = { version = "0.17.1", features = ["ndarray"], default-features = false } 18 19 clap = { version = "4.6.0", features = ["derive"] } 19 20 git2 = "0.20.4" 20 21 image = { version = "0.25", default-features = false, features = ["png"] }
+264 -56
crates/tala-cli/src/main.rs
··· 1 + use std::io::{self, BufRead, Write}; 1 2 use std::path::{Path, PathBuf}; 2 3 3 4 use clap::{Parser, Subcommand}; 4 - use tala_format::{parse_cards, CardKind}; 5 - use tala_srs::Sidecar; 5 + use tala_format::{CardEntry, CardKind, parse_cards}; 6 + use tala_srs::{CardSchedule, Grade, Schedule, Sidecar, is_due, next_schedule, today_str}; 6 7 7 8 #[derive(Parser)] 8 9 #[command(name = "tala", about = "Tala flashcard tools")] ··· 13 14 14 15 #[derive(Subcommand)] 15 16 enum Command { 16 - /// Parse a deck directory and report card stats, missing IDs, and orphaned 17 - /// sidecar entries. 17 + /// Parse card files and report stats, duplicate IDs, and sidecar mismatches. 18 18 Check { 19 - /// Path to the deck directory (must contain cards.typ). 20 - deck: PathBuf, 19 + /// Path to a `.typ` file or a directory (scanned recursively). 20 + path: PathBuf, 21 21 }, 22 - /// Run an interactive review session for the due cards in a deck. 22 + /// Run an interactive review session for due cards. 23 23 Review { 24 - deck: PathBuf, 24 + /// Path to a `.typ` file or a directory (scanned recursively). 25 + path: PathBuf, 26 + /// Only review cards with this tag. 27 + #[arg(long)] 28 + tag: Option<String>, 25 29 }, 26 30 } 27 31 28 32 fn main() { 29 33 let cli = Cli::parse(); 30 34 let result = match cli.command { 31 - Command::Check { deck } => cmd_check(&deck), 32 - Command::Review { deck } => cmd_review(&deck), 35 + Command::Check { path } => cmd_check(&path), 36 + Command::Review { path, tag } => cmd_review(&path, tag.as_deref()), 33 37 }; 34 38 if let Err(e) = result { 35 39 eprintln!("error: {e}"); ··· 37 41 } 38 42 } 39 43 44 + // ── Deck loading ────────────────────────────────────────────────────────────── 45 + 46 + struct DeckFile { 47 + typ_path: PathBuf, 48 + cards: Vec<CardEntry>, 49 + sidecar: Sidecar, 50 + } 51 + 52 + fn load_deck_files(path: &Path) -> Result<Vec<DeckFile>, Box<dyn std::error::Error>> { 53 + let typ_paths = if path.is_file() { 54 + vec![path.to_owned()] 55 + } else { 56 + find_typ_files(path) 57 + }; 58 + 59 + let mut files = Vec::new(); 60 + for typ_path in typ_paths { 61 + let source = std::fs::read_to_string(&typ_path) 62 + .map_err(|e| format!("{}: {e}", typ_path.display()))?; 63 + let cards = parse_cards(&source); 64 + let sidecar = Sidecar::load_or_empty_for(&typ_path)?; 65 + files.push(DeckFile { typ_path, cards, sidecar }); 66 + } 67 + Ok(files) 68 + } 69 + 70 + fn find_typ_files(dir: &Path) -> Vec<PathBuf> { 71 + let mut out = Vec::new(); 72 + if let Ok(entries) = std::fs::read_dir(dir) { 73 + let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect(); 74 + paths.sort(); 75 + for p in paths { 76 + if p.is_dir() { 77 + out.extend(find_typ_files(&p)); 78 + } else if p.extension().map_or(false, |e| e == "typ") { 79 + // Skip sidecar-style .srs.json files that happen to end in .typ (shouldn't exist). 80 + out.push(p); 81 + } 82 + } 83 + } 84 + out 85 + } 86 + 40 87 // ── check ───────────────────────────────────────────────────────────────────── 41 88 42 - fn cmd_check(deck: &Path) -> Result<(), Box<dyn std::error::Error>> { 43 - let source_path = deck.join("cards.typ"); 44 - let source = std::fs::read_to_string(&source_path) 45 - .map_err(|_| format!("could not read {}", source_path.display()))?; 89 + fn cmd_check(path: &Path) -> Result<(), Box<dyn std::error::Error>> { 90 + let files = load_deck_files(path)?; 46 91 47 - let cards = parse_cards(&source); 48 - let ids: Vec<String> = cards.iter().map(|c| c.id.clone()).collect(); 92 + let mut total = 0; 93 + for df in &files { 94 + let ids: Vec<String> = df.cards.iter().map(|c| c.id.clone()).collect(); 95 + let n_fb = df.cards.iter().filter(|c| matches!(c.kind, CardKind::FrontBack { .. })).count(); 96 + let n_cloze = df.cards.iter().filter(|c| matches!(c.kind, CardKind::Cloze { .. })).count(); 97 + let n_img = df.cards.iter().filter(|c| matches!(c.kind, CardKind::ImgCloze { .. })).count(); 49 98 50 - println!("{} cards found", cards.len()); 99 + println!("{}", df.typ_path.display()); 100 + println!(" {} cards (front/back: {n_fb} cloze: {n_cloze} img-cloze: {n_img})", 101 + df.cards.len()); 51 102 52 - // Per-type breakdown 53 - let n_fb = cards.iter().filter(|c| matches!(c.kind, CardKind::FrontBack { .. })).count(); 54 - let n_cloze = cards.iter().filter(|c| matches!(c.kind, CardKind::Cloze { .. })).count(); 55 - let n_img = cards.iter().filter(|c| matches!(c.kind, CardKind::ImgCloze { .. })).count(); 56 - println!(" front/back: {n_fb} cloze: {n_cloze} img-cloze: {n_img}"); 103 + // Duplicate IDs within file. 104 + let mut seen = std::collections::HashSet::new(); 105 + let dupes: Vec<_> = ids.iter().filter(|id| !seen.insert(*id)).collect(); 106 + if !dupes.is_empty() { 107 + eprintln!(" duplicate IDs: {}", dupes.iter().map(|s| s.as_str()).collect::<Vec<&str>>().join(", ")); 108 + } 57 109 58 - // Duplicate IDs 59 - let mut seen = std::collections::HashSet::new(); 60 - let mut dupes = Vec::new(); 61 - for id in &ids { 62 - if !seen.insert(id) { 63 - dupes.push(id.as_str()); 110 + let orphans = df.sidecar.orphaned(&ids); 111 + let missing = df.sidecar.missing(&ids); 112 + if orphans.is_empty() && missing.is_empty() { 113 + println!(" sidecar: ok"); 114 + } else { 115 + if !orphans.is_empty() { 116 + println!(" sidecar orphans: {}", orphans.join(", ")); 117 + } 118 + if !missing.is_empty() { 119 + println!(" sidecar missing: {}", missing.join(", ")); 120 + } 64 121 } 122 + 123 + total += df.cards.len(); 65 124 } 66 - if !dupes.is_empty() { 67 - eprintln!("duplicate IDs: {}", dupes.join(", ")); 125 + 126 + if files.len() > 1 { 127 + println!("─────\ntotal: {total} cards across {} files", files.len()); 68 128 } 129 + Ok(()) 130 + } 69 131 70 - // Sidecar cross-check 71 - let sidecar = Sidecar::load_or_empty(deck)?; 72 - let orphans = sidecar.orphaned(&ids); 73 - let missing = sidecar.missing(&ids); 132 + // ── review ──────────────────────────────────────────────────────────────────── 74 133 75 - if orphans.is_empty() && missing.is_empty() { 76 - println!("sidecar: ok"); 77 - } else { 78 - if !orphans.is_empty() { 79 - println!("sidecar orphans (in JSON, not in .typ): {}", orphans.join(", ")); 134 + fn cmd_review(path: &Path, tag_filter: Option<&str>) -> Result<(), Box<dyn std::error::Error>> { 135 + let mut files = load_deck_files(path)?; 136 + 137 + // Collect all due reviewable items across files (owned, no refs into `files`). 138 + struct Item { 139 + file_idx: usize, 140 + card_id: String, 141 + kind: CardKind, 142 + sub_id: String, // "forward", "b0", "rect-0" 143 + current: Option<Schedule>, 144 + } 145 + 146 + let mut queue: Vec<Item> = Vec::new(); 147 + for (fi, df) in files.iter().enumerate() { 148 + for card in &df.cards { 149 + if let Some(tag) = tag_filter { 150 + if !card.tags.iter().any(|t| t == tag) { 151 + continue; 152 + } 153 + } 154 + match (&card.kind, df.sidecar.get(&card.id)) { 155 + (CardKind::FrontBack { .. }, sched) => { 156 + let fwd = match sched { 157 + Some(CardSchedule::FrontBack { forward_schedule, .. }) => forward_schedule.clone(), 158 + _ => None, 159 + }; 160 + if fwd.as_ref().map_or(true, is_due) { 161 + queue.push(Item { file_idx: fi, card_id: card.id.clone(), 162 + kind: card.kind.clone(), sub_id: "forward".into(), current: fwd }); 163 + } 164 + // TODO: reverse side for dir: bi 165 + } 166 + (CardKind::Cloze { blanks, .. }, sched) => { 167 + let blank_schedules = match sched { 168 + Some(CardSchedule::Cloze { blanks: b }) => b.clone(), 169 + _ => std::collections::HashMap::new(), 170 + }; 171 + for blank in blanks { 172 + let key = format!("b{}", blank.index); 173 + let current = blank_schedules.get(&key).cloned(); 174 + if current.as_ref().map_or(true, is_due) { 175 + queue.push(Item { file_idx: fi, card_id: card.id.clone(), 176 + kind: card.kind.clone(), sub_id: key, current }); 177 + } 178 + } 179 + } 180 + (CardKind::ImgCloze { .. }, _) => { 181 + // ImgCloze review requires the UI; skip in CLI. 182 + } 183 + } 80 184 } 81 - if !missing.is_empty() { 82 - println!("sidecar missing (in .typ, not in JSON): {}", missing.join(", ")); 185 + } 186 + 187 + if queue.is_empty() { 188 + println!("No cards due."); 189 + return Ok(()); 190 + } 191 + 192 + println!("{} cards due. Grade with 1 (again) 2 (hard) 3 (good) 4 (easy). Ctrl-C to stop.\n", 193 + queue.len()); 194 + 195 + let stdin = io::stdin(); 196 + let mut grades: Vec<(usize, String, String, Grade)> = Vec::new(); // (file_idx, card_id, sub_id, grade) 197 + 198 + for (i, item) in queue.iter().enumerate() { 199 + println!("[{}/{}] {} — {}", i + 1, queue.len(), item.card_id, item.sub_id); 200 + print_card_preview(&item.kind); 201 + 202 + let grade = loop { 203 + print!("Grade (1-4): "); 204 + io::stdout().flush()?; 205 + let mut line = String::new(); 206 + stdin.lock().read_line(&mut line)?; 207 + match line.trim().parse::<u8>().ok().and_then(Grade::from_u8) { 208 + Some(g) => break g, 209 + None => eprintln!(" Enter 1, 2, 3, or 4."), 210 + } 211 + }; 212 + 213 + let days_elapsed = item.current.as_ref().map_or(0, |s| { 214 + days_between(&s.due, &today_str()).max(0) as u32 215 + }); 216 + let new_sched = next_schedule(item.current.as_ref(), days_elapsed, grade); 217 + println!(" → due {}\n", new_sched.due); 218 + 219 + grades.push((item.file_idx, item.card_id.clone(), item.sub_id.clone(), grade)); 220 + 221 + // Write the new schedule into the in-memory sidecar. 222 + let df = &mut files[item.file_idx]; 223 + apply_schedule(&mut df.sidecar, &item.card_id, &item.sub_id, new_sched, &item.kind); 224 + } 225 + 226 + if grades.is_empty() { 227 + return Ok(()); 228 + } 229 + 230 + // Save all modified sidecars. 231 + let mut saved = std::collections::HashSet::new(); 232 + for (fi, _, _, _) in &grades { 233 + if saved.insert(*fi) { 234 + files[*fi].sidecar.save_for(&files[*fi].typ_path)?; 83 235 } 84 236 } 85 237 238 + // Git commit. 239 + let repo = git2::Repository::discover(path)?; 240 + for fi in saved { 241 + let sidecar_path = Sidecar::path_for(&files[fi].typ_path); 242 + git_add_and_commit(&repo, &sidecar_path, 243 + &format!("review session {}", today_str()))?; 244 + } 245 + 246 + println!("Session saved ({} cards reviewed).", grades.len()); 86 247 Ok(()) 87 248 } 88 249 89 - // ── review ──────────────────────────────────────────────────────────────────── 250 + fn apply_schedule(sidecar: &mut Sidecar, card_id: &str, sub_id: &str, sched: Schedule, kind: &CardKind) { 251 + match kind { 252 + CardKind::FrontBack { .. } => { 253 + let entry = sidecar.cards.entry(card_id.to_owned()) 254 + .or_insert(CardSchedule::FrontBack { forward_schedule: None, reverse_schedule: None }); 255 + if let CardSchedule::FrontBack { forward_schedule, .. } = entry { 256 + if sub_id == "forward" { *forward_schedule = Some(sched); } 257 + } 258 + } 259 + CardKind::Cloze { .. } => { 260 + let entry = sidecar.cards.entry(card_id.to_owned()) 261 + .or_insert(CardSchedule::Cloze { blanks: std::collections::HashMap::new() }); 262 + if let CardSchedule::Cloze { blanks } = entry { 263 + blanks.insert(sub_id.to_owned(), sched); 264 + } 265 + } 266 + CardKind::ImgCloze { .. } => {} 267 + } 268 + } 90 269 91 - fn cmd_review(deck: &Path) -> Result<(), Box<dyn std::error::Error>> { 92 - // Stub — full review loop comes in step 5. 93 - let source_path = deck.join("cards.typ"); 94 - let source = std::fs::read_to_string(&source_path) 95 - .map_err(|_| format!("could not read {}", source_path.display()))?; 270 + fn print_card_preview(kind: &CardKind) { 271 + match kind { 272 + CardKind::FrontBack { .. } => println!(" [front/back — render with tala-ui]"), 273 + CardKind::Cloze { .. } => println!(" [cloze — render with tala-ui]"), 274 + CardKind::ImgCloze { src } => println!(" [img-cloze: {src}]"), 275 + } 276 + } 96 277 97 - let cards = parse_cards(&source); 98 - let sidecar = Sidecar::load_or_empty(deck)?; 278 + fn days_between(from: &str, to: &str) -> i64 { 279 + // Lexicographic YYYY-MM-DD comparison; parse to epoch days for arithmetic. 280 + fn to_epoch(s: &str) -> i64 { 281 + let parts: Vec<i64> = s.splitn(3, '-') 282 + .filter_map(|p| p.parse().ok()) 283 + .collect(); 284 + if parts.len() != 3 { return 0; } 285 + let (y, m, d) = (parts[0] as i32, parts[1] as u32, parts[2] as u32); 286 + let (y2, m2) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) }; 287 + let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400; 288 + let yoe = y2 - era * 400; 289 + let doy = (153 * m2 as i64 + 2) / 5 + d as i64 - 1; 290 + let doe = yoe as i64 * 365 + yoe as i64 / 4 - yoe as i64 / 100 + doy; 291 + era as i64 * 146097 + doe - 719468 292 + } 293 + to_epoch(to) - to_epoch(from) 294 + } 99 295 100 - let due: Vec<_> = cards.iter().filter(|c| { 101 - match sidecar.get(&c.id) { 102 - None => true, // unseen = due 103 - Some(_sched) => true, // TODO: compare due date to today 104 - } 105 - }).collect(); 296 + // ── Git helpers ─────────────────────────────────────────────────────────────── 106 297 107 - println!("{} cards due (review loop not yet implemented)", due.len()); 298 + fn git_add_and_commit( 299 + repo: &git2::Repository, 300 + file: &Path, 301 + message: &str, 302 + ) -> Result<(), Box<dyn std::error::Error>> { 303 + let mut index = repo.index()?; 304 + let workdir = repo.workdir().ok_or("bare repo")?; 305 + let rel = file.strip_prefix(workdir).unwrap_or(file); 306 + index.add_path(rel)?; 307 + index.write()?; 308 + 309 + let tree_id = index.write_tree()?; 310 + let tree = repo.find_tree(tree_id)?; 311 + let sig = repo.signature()?; 312 + let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok()); 313 + let parents: Vec<&git2::Commit> = parent.iter().collect(); 314 + 315 + repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)?; 108 316 Ok(()) 109 317 }
+50 -7
crates/tala-format/src/lib.rs
··· 9 9 #[derive(Debug, Clone)] 10 10 pub struct CardEntry { 11 11 pub id: String, 12 + pub tags: Vec<String>, 12 13 pub kind: CardKind, 13 14 /// Byte range of the full `#card[...][...]` call in the source. 14 15 pub span: Range<usize>, ··· 106 107 fc_node.children().find(|c| c.kind() == SyntaxKind::Args) 107 108 } 108 109 109 - /// Walk an Args node and collect named string args and content-block spans. 110 - fn collect_args(args: &LinkedNode<'_>) -> (HashMap<String, String>, Vec<Range<usize>>) { 110 + /// Walk an Args node and collect named string args, tag arrays, and content-block spans. 111 + fn collect_args( 112 + args: &LinkedNode<'_>, 113 + ) -> (HashMap<String, String>, Vec<String>, Vec<Range<usize>>) { 111 114 let mut named: HashMap<String, String> = HashMap::new(); 115 + let mut tags: Vec<String> = Vec::new(); 112 116 let mut content_blocks: Vec<Range<usize>> = Vec::new(); 113 117 114 118 for child in args.children() { 115 119 match child.kind() { 116 120 SyntaxKind::Named => { 121 + // Check if this is `tags: (...)` — handle separately. 122 + if let Some(key) = named_key(&child) { 123 + if key == "tags" { 124 + tags = parse_named_tags(&child); 125 + continue; 126 + } 127 + } 117 128 if let Some((k, v)) = parse_named_str(&child) { 118 129 named.insert(k, v); 119 130 } ··· 124 135 _ => {} 125 136 } 126 137 } 127 - (named, content_blocks) 138 + (named, tags, content_blocks) 139 + } 140 + 141 + /// Return just the key name of a Named node without consuming the value. 142 + fn named_key(named: &LinkedNode<'_>) -> Option<String> { 143 + named 144 + .children() 145 + .find(|c| c.kind() == SyntaxKind::Ident) 146 + .and_then(|c| c.cast::<ast::Ident>().map(|i| i.as_str().to_owned())) 147 + } 148 + 149 + /// Parse `tags: ("a", "b", ...)` — returns the string items of the array. 150 + fn parse_named_tags(named: &LinkedNode<'_>) -> Vec<String> { 151 + for child in named.children() { 152 + if child.kind() == SyntaxKind::Array { 153 + return child 154 + .children() 155 + .filter(|c| c.kind() == SyntaxKind::Str) 156 + .filter_map(|c| c.cast::<ast::Str>().map(|s| s.get().to_string())) 157 + .collect(); 158 + } 159 + } 160 + Vec::new() 128 161 } 129 162 130 163 /// Extract `key: "string-value"` from a Named node. ··· 151 184 152 185 fn parse_front_back(node: &LinkedNode<'_>, span: Range<usize>) -> Option<CardEntry> { 153 186 let args = args_of(node)?; 154 - let (named, content_blocks) = collect_args(&args); 187 + let (named, tags, content_blocks) = collect_args(&args); 155 188 156 189 let id = named.get("id")?.clone(); 157 190 if content_blocks.len() < 2 { ··· 164 197 165 198 Some(CardEntry { 166 199 id, 200 + tags, 167 201 span, 168 202 kind: CardKind::FrontBack { 169 203 front_span: content_blocks[0].clone(), ··· 175 209 176 210 fn parse_cloze(node: &LinkedNode<'_>, span: Range<usize>) -> Option<CardEntry> { 177 211 let args = args_of(node)?; 178 - let (named, content_blocks) = collect_args(&args); 212 + let (named, tags, content_blocks) = collect_args(&args); 179 213 180 214 let id = named.get("id")?.clone(); 181 215 let body_span = content_blocks.first()?.clone(); ··· 187 221 188 222 Some(CardEntry { 189 223 id, 224 + tags, 190 225 span, 191 226 kind: CardKind::Cloze { body_span, blanks }, 192 227 }) ··· 194 229 195 230 fn parse_img_cloze(node: &LinkedNode<'_>, span: Range<usize>) -> Option<CardEntry> { 196 231 let args = args_of(node)?; 197 - let (named, _) = collect_args(&args); 232 + let (named, tags, _) = collect_args(&args); 198 233 199 234 let id = named.get("id")?.clone(); 200 235 let src = named.get("src")?.clone(); 201 236 202 237 Some(CardEntry { 203 238 id, 239 + tags, 204 240 span, 205 241 kind: CardKind::ImgCloze { src }, 206 242 }) ··· 242 278 use super::*; 243 279 244 280 const SAMPLE: &str = r#" 245 - #card(id: "fb-001")[ 281 + #card(id: "fb-001", tags: ("circuits", "passive"))[ 246 282 What is the circuit model of an actual capacitor? 247 283 ][ 248 284 Series: ESR + ESL + ideal C ··· 295 331 let cards = parse_cards(SAMPLE); 296 332 let CardKind::ImgCloze { src } = &cards[3].kind else { panic!() }; 297 333 assert_eq!(src, "capacitor-photo"); 334 + } 335 + 336 + #[test] 337 + fn tags_parsed() { 338 + let cards = parse_cards(SAMPLE); 339 + assert_eq!(cards[0].tags, vec!["circuits", "passive"]); 340 + assert!(cards[1].tags.is_empty()); 298 341 } 299 342 300 343 #[test]
+1
crates/tala-srs/Cargo.toml
··· 7 7 serde = { workspace = true } 8 8 serde_json = { workspace = true } 9 9 fsrs = { workspace = true } 10 + burn = { workspace = true }
+145 -10
crates/tala-srs/src/lib.rs
··· 1 1 use std::collections::HashMap; 2 - use std::path::Path; 2 + use std::path::{Path, PathBuf}; 3 3 4 4 use serde::{Deserialize, Serialize}; 5 5 ··· 60 60 Ok(sidecar) 61 61 } 62 62 63 - /// Load from `<deck_dir>/cards.srs.json`, returning an empty sidecar if 64 - /// the file does not yet exist. 65 - pub fn load_or_empty(deck_dir: &Path) -> Result<Self, Error> { 66 - let path = deck_dir.join("cards.srs.json"); 67 - if path.exists() { 68 - Self::load(&path) 69 - } else { 70 - Ok(Self::empty()) 71 - } 63 + /// Derive the sidecar path from a `.typ` file path: 64 + /// `notes/electromagnetism.typ` → `notes/electromagnetism.srs.json` 65 + pub fn path_for(typ_path: &Path) -> PathBuf { 66 + typ_path.with_extension("srs.json") 67 + } 68 + 69 + /// Load the sidecar paired with `typ_path`, or return empty if it doesn't exist. 70 + pub fn load_or_empty_for(typ_path: &Path) -> Result<Self, Error> { 71 + let path = Self::path_for(typ_path); 72 + if path.exists() { Self::load(&path) } else { Ok(Self::empty()) } 72 73 } 73 74 74 75 pub fn save(&self, path: &Path) -> Result<(), Error> { ··· 77 78 Ok(()) 78 79 } 79 80 81 + /// Save to the sidecar path paired with `typ_path`. 82 + pub fn save_for(&self, typ_path: &Path) -> Result<(), Error> { 83 + self.save(&Self::path_for(typ_path)) 84 + } 85 + 86 + // ── Deprecated helpers (kept for tala-cli compat during migration) ───────── 87 + 88 + /// Load from `<deck_dir>/cards.srs.json`, returning an empty sidecar if 89 + /// the file does not yet exist. 90 + #[deprecated(note = "use load_or_empty_for(typ_path) instead")] 91 + pub fn load_or_empty(deck_dir: &Path) -> Result<Self, Error> { 92 + let path = deck_dir.join("cards.srs.json"); 93 + if path.exists() { Self::load(&path) } else { Ok(Self::empty()) } 94 + } 95 + 96 + #[deprecated(note = "use save_for(typ_path) instead")] 80 97 pub fn save_to_deck(&self, deck_dir: &Path) -> Result<(), Error> { 81 98 self.save(&deck_dir.join("cards.srs.json")) 82 99 } ··· 112 129 .map(|s| s.as_str()) 113 130 .collect() 114 131 } 132 + } 133 + 134 + // ── FSRS scheduling ─────────────────────────────────────────────────────────── 135 + 136 + /// Grade values matching the FSRS 1–4 scale. 137 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 138 + pub enum Grade { 139 + Again = 1, 140 + Hard = 2, 141 + Good = 3, 142 + Easy = 4, 143 + } 144 + 145 + impl Grade { 146 + pub fn from_u8(n: u8) -> Option<Self> { 147 + match n { 148 + 1 => Some(Self::Again), 149 + 2 => Some(Self::Hard), 150 + 3 => Some(Self::Good), 151 + 4 => Some(Self::Easy), 152 + _ => None, 153 + } 154 + } 155 + } 156 + 157 + /// Compute the next `Schedule` given the current state and a grade. 158 + /// 159 + /// `current` is `None` for cards that have never been reviewed. 160 + /// `days_elapsed` is the number of whole days since the last review (or 0 for new). 161 + /// Returns the updated schedule with a new due date and memory state. 162 + pub fn next_schedule(current: Option<&Schedule>, days_elapsed: u32, grade: Grade) -> Schedule { 163 + use burn::backend::NdArray; 164 + use fsrs::{DEFAULT_PARAMETERS, FSRS, MemoryState}; 165 + 166 + type B = NdArray<f32>; 167 + let fsrs = FSRS::<B>::new(Some(&DEFAULT_PARAMETERS)).expect("FSRS init"); 168 + 169 + let memory_state = current.map(|s| MemoryState { 170 + stability: s.stability, 171 + difficulty: s.difficulty, 172 + }); 173 + 174 + let next = fsrs 175 + .next_states(memory_state, 0.9, days_elapsed) 176 + .expect("FSRS next_states"); 177 + 178 + let item_state = match grade { 179 + Grade::Again => next.again, 180 + Grade::Hard => next.hard, 181 + Grade::Good => next.good, 182 + Grade::Easy => next.easy, 183 + }; 184 + 185 + let due = { 186 + let days = item_state.interval.round().max(1.0) as i64; 187 + format_date(add_days_to_ymd(today_naive(), days)) 188 + }; 189 + 190 + Schedule { 191 + due, 192 + stability: item_state.memory.stability, 193 + difficulty: item_state.memory.difficulty, 194 + } 195 + } 196 + 197 + /// Returns today's date as `"YYYY-MM-DD"`. 198 + pub fn today_str() -> String { 199 + format_date(today_naive()) 200 + } 201 + 202 + /// Returns true if `schedule.due <= today` (card is due). 203 + pub fn is_due(schedule: &Schedule) -> bool { 204 + schedule.due.as_str() <= today_str().as_str() 205 + } 206 + 207 + fn today_naive() -> (i32, u32, u32) { 208 + // Use std::time to get current UTC date without a date-time crate dep. 209 + use std::time::{SystemTime, UNIX_EPOCH}; 210 + let secs = SystemTime::now() 211 + .duration_since(UNIX_EPOCH) 212 + .unwrap_or_default() 213 + .as_secs() as i64; 214 + unix_secs_to_ymd(secs) 215 + } 216 + 217 + fn unix_secs_to_ymd(secs: i64) -> (i32, u32, u32) { 218 + // Gregorian calendar calculation. 219 + let days = secs / 86400; 220 + // Algorithm from http://howardhinnant.github.io/date_algorithms.html 221 + let z = days + 719468; 222 + let era = if z >= 0 { z } else { z - 146096 } / 146097; 223 + let doe = z - era * 146097; 224 + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; 225 + let y = yoe + era * 400; 226 + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); 227 + let mp = (5 * doy + 2) / 153; 228 + let d = doy - (153 * mp + 2) / 5 + 1; 229 + let m = if mp < 10 { mp + 3 } else { mp - 9 }; 230 + let y = if m <= 2 { y + 1 } else { y }; 231 + (y as i32, m as u32, d as u32) 232 + } 233 + 234 + fn format_date((y, m, d): (i32, u32, u32)) -> String { 235 + format!("{y:04}-{m:02}-{d:02}") 236 + } 237 + 238 + fn add_days_to_ymd((y, m, d): (i32, u32, u32), days: i64) -> (i32, u32, u32) { 239 + let epoch_days = ymd_to_epoch_days(y, m, d) + days; 240 + unix_secs_to_ymd(epoch_days * 86400) 241 + } 242 + 243 + fn ymd_to_epoch_days(y: i32, m: u32, d: u32) -> i64 { 244 + let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) }; 245 + let era = if y >= 0 { y } else { y - 399 } / 400; 246 + let yoe = y - era * 400; 247 + let doy = (153 * m as i64 + 2) / 5 + d as i64 - 1; 248 + let doe = yoe as i64 * 365 + yoe as i64 / 4 - yoe as i64 / 100 + doy; 249 + era as i64 * 146097 + doe - 719468 115 250 } 116 251 117 252 // ── Error ─────────────────────────────────────────────────────────────────────