this repo has no description
1
fork

Configure Feed

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

feat: stats page; fix card preview for multiline #card\n[...] format

Stats page (/stats):
- Counts total review items, new (no schedule), due today from sidecar
- 14-day forecast bar chart, today bar highlighted
- tala-srs: add pub date_offset(days) helper

Editor fixes:
- is_card_frag: recognize #card\n[ and #cloze\n[ (multiline formatted)
- strip_head_whitespace: normalize head\n[ -> head[ before typst render
so content blocks attach correctly; adjusts blank spans accordingly
- format_card_frag: no longer emits \n before first [, preventing
auto-format from producing the broken multiline form going forward

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

+462 -18
+305 -18
crates/tala/src/editor.rs
··· 35 35 /// True if a source fragment looks like a card definition by prefix alone. 36 36 fn is_card_frag(s: &str) -> bool { 37 37 let s = s.trim_start(); 38 - s.starts_with("#card[") 39 - || s.starts_with("#card(") 40 - || s.starts_with("#cloze[") 41 - || s.starts_with("#cloze(") 38 + let tail = if s.starts_with("#card") { 39 + &s[5..] 40 + } else if s.starts_with("#cloze") { 41 + &s[6..] 42 + } else { 43 + return false; 44 + }; 45 + // Allow `#card[`, `#card(`, `#card\n[` (multiline formatted), and bare `#card`. 46 + matches!( 47 + tail.as_bytes().first(), 48 + None | Some(b'[') | Some(b'(') | Some(b'\n') | Some(b'\r') 49 + ) 42 50 } 43 51 44 52 ··· 510 518 ) 511 519 } 512 520 521 + /// Remove whitespace between a card/cloze head and its first `[` content block. 522 + /// Typst markup mode does not attach `[...]` as a content argument when a newline 523 + /// precedes it, so `#card\n[...]` renders incorrectly. Returns the normalized 524 + /// string and the number of characters stripped (used to adjust blank spans). 525 + fn strip_head_whitespace(frag: &str) -> (String, usize) { 526 + let bytes = frag.as_bytes(); 527 + let mut paren_depth = 0i32; 528 + let mut bracket_pos = None; 529 + for (i, &b) in bytes.iter().enumerate() { 530 + match b { 531 + b'(' => paren_depth += 1, 532 + b')' => paren_depth -= 1, 533 + b'[' if paren_depth == 0 => { 534 + bracket_pos = Some(i); 535 + break; 536 + } 537 + _ => {} 538 + } 539 + } 540 + let Some(pos) = bracket_pos else { 541 + return (frag.to_string(), 0); 542 + }; 543 + let head = &frag[..pos]; 544 + let trimmed = head.trim_end(); 545 + let stripped = head.len() - trimmed.len(); 546 + if stripped == 0 { 547 + return (frag.to_string(), 0); 548 + } 549 + (format!("{}{}", trimmed, &frag[pos..]), stripped) 550 + } 551 + 513 552 fn render_preview(source: &str, dir: &Path) -> Result<PreviewData, String> { 514 553 let deck_dir = dir.to_path_buf(); 515 554 ··· 528 567 .map(|b| b.content_span) 529 568 .collect(); 530 569 570 + // Normalize `#card\n[` → `#card[` so typst attaches content blocks correctly. 571 + let (render_source, stripped) = strip_head_whitespace(source); 572 + let render_spans: Vec<std::ops::Range<usize>> = blank_spans 573 + .iter() 574 + .map(|r| r.start.saturating_sub(stripped)..r.end.saturating_sub(stripped)) 575 + .collect(); 576 + 531 577 let result = tala_typst::render( 532 578 &deck_dir, 533 - source, 579 + &render_source, 534 580 tala_typst::Preamble::Authoring, 535 - &blank_spans, 581 + &render_spans, 536 582 ) 537 583 .map_err(|e| e.to_string())?; 538 584 ··· 622 668 }) 623 669 } 624 670 671 + // ── Card autoformat helpers ─────────────────────────────────────────────────── 672 + 673 + /// Find the head boundary and content block spans in a card fragment. 674 + /// 675 + /// Returns `(head_end, blocks)` where `head_end` is the byte index of the first 676 + /// top-level `[` and each element of `blocks` is `(content_start, content_end)`: 677 + /// `content_start` is one past `[` and `content_end` is the index of the matching `]`. 678 + fn parse_card_structure(frag: &str) -> Option<(usize, Vec<(usize, usize)>)> { 679 + let b = frag.as_bytes(); 680 + let len = b.len(); 681 + 682 + // Find head_end: first `[` not inside parentheses. 683 + let mut paren_depth = 0i32; 684 + let mut head_end = None; 685 + for i in 0..len { 686 + match b[i] { 687 + b'(' => paren_depth += 1, 688 + b')' => paren_depth -= 1, 689 + b'[' if paren_depth == 0 => { 690 + head_end = Some(i); 691 + break; 692 + } 693 + _ => {} 694 + } 695 + } 696 + let head_end = head_end?; 697 + 698 + // Collect top-level `[...]` blocks with depth tracking. 699 + let mut blocks = Vec::new(); 700 + let mut i = head_end; 701 + while i < len { 702 + if b[i] == b'[' { 703 + let block_start = i + 1; 704 + let mut depth = 1i32; 705 + i += 1; 706 + while i < len && depth > 0 { 707 + match b[i] { 708 + b'[' => depth += 1, 709 + b']' => depth -= 1, 710 + _ => {} 711 + } 712 + if depth > 0 { 713 + i += 1; 714 + } 715 + } 716 + blocks.push((block_start, i)); // content_end is index of `]` 717 + i += 1; // skip `]` 718 + } else { 719 + i += 1; 720 + } 721 + } 722 + 723 + if blocks.is_empty() { 724 + return None; 725 + } 726 + Some((head_end, blocks)) 727 + } 728 + 729 + /// Reformat a card fragment to multi-line form: 730 + /// `{head}\n[\n {content0}\n][\n {content1}\n]` 731 + /// 732 + /// Returns `None` if already in that form or not a recognised card. 733 + fn format_card_frag(frag: &str) -> Option<String> { 734 + let (head_end, ref blocks) = parse_card_structure(frag)?; 735 + let head = frag[..head_end].trim_end_matches('\n'); 736 + let mut out = head.to_string(); 737 + for &(cs, ce) in blocks { 738 + let content = frag[cs..ce].trim(); 739 + out.push_str(&format!("[\n {}\n]", content)); 740 + } 741 + if out == frag || out.as_str() == frag.trim_end_matches('\n') { 742 + return None; 743 + } 744 + Some(out) 745 + } 746 + 747 + /// Map a cursor position from the original fragment to the formatted fragment. 748 + /// 749 + /// Each block's opening `[` gains `\n ` (3 chars) after it; each closing `]` 750 + /// gains a `\n` before it; a `\n` is inserted after the head. Leading/trailing 751 + /// whitespace stripped from block content shifts the cursor back accordingly. 752 + fn map_cursor_after_format( 753 + frag: &str, 754 + cursor: usize, 755 + head_end: usize, 756 + blocks: &[(usize, usize)], 757 + ) -> usize { 758 + // Effective head end after trimming trailing newlines (mirrors format_card_frag). 759 + let trimmed_head_end = frag[..head_end].trim_end_matches('\n').len(); 760 + if cursor < trimmed_head_end { 761 + return cursor; 762 + } 763 + // Cursor in trailing head whitespace or past head: offset by the net change. 764 + // Trimmed head is trimmed_head_end chars; formatted adds one \n after it. 765 + // So everything from trimmed_head_end onward shifts by (1 - (head_end - trimmed_head_end)). 766 + let head_ws_removed = (head_end - trimmed_head_end) as i64; 767 + // +1 for the \n inserted after head 768 + let mut offset: i64 = 1 - head_ws_removed; 769 + for &(content_start, content_end) in blocks { 770 + let open_bracket = content_start - 1; 771 + if cursor <= open_bracket { 772 + break; 773 + } 774 + // Past `[`: +3 for `\n ` inserted after `[` 775 + offset += 3; 776 + if cursor <= content_start { 777 + break; 778 + } 779 + let raw = &frag[content_start..content_end]; 780 + let leading_ws = raw.len() - raw.trim_start().len(); 781 + if cursor <= content_start + leading_ws { 782 + // Cursor in leading whitespace: snap to start of trimmed content 783 + break; 784 + } 785 + offset -= leading_ws as i64; 786 + let trailing_ws = raw.len() - raw.trim_end().len(); 787 + if cursor <= content_end - trailing_ws { 788 + break; // Cursor within actual content 789 + } 790 + offset -= trailing_ws as i64; 791 + if cursor <= content_end { 792 + break; // Cursor on or before `]` 793 + } 794 + // Past `]`: +1 for `\n` inserted before `]` 795 + offset += 1; 796 + } 797 + (cursor as i64 + offset).max(0) as usize 798 + } 799 + 625 800 #[component] 626 801 pub fn Editor() -> Element { 627 802 // ── Source & save ───────────────────────────────────────────────────────── ··· 629 804 let mut save_status = use_signal(|| SaveStatus::Clean); 630 805 let mut new_card_draft = use_signal(|| String::new()); 631 806 632 - // Auto-save: debounce 1s after last edit. 633 - let _saver = use_resource(move || async move { 634 - let text = source.read().clone(); 635 - tokio::time::sleep(Duration::from_millis(1000)).await; 636 - let path = cards_path(); 637 - match tokio::task::spawn_blocking(move || std::fs::write(&path, &text)).await { 638 - Ok(Ok(())) => save_status.set(SaveStatus::Saved), 639 - Ok(Err(e)) => save_status.set(SaveStatus::Error(e.to_string())), 640 - Err(e) => save_status.set(SaveStatus::Error(e.to_string())), 641 - } 642 - }); 643 - 644 807 // ── Tag filter ──────────────────────────────────────────────────────────── 645 808 let mut editor_selected_tags: Signal<Vec<String>> = use_signal(Vec::new); 646 809 let mut editor_tag_mode = use_signal(|| TagMode::Or); ··· 650 813 let mut active_idx = use_signal(|| 0usize); 651 814 // Indexed by segment. None = text segment (no render attempt). 652 815 let mut previews = use_signal(Vec::<Option<Result<PreviewData, String>>>::new); 816 + 817 + // Auto-save (1s debounce) + auto-format active card on save. 818 + // Sequence: read cursor -> format -> save formatted -> sync DOM -> update source. 819 + // Updating source last avoids cancelling the task before the save completes. 820 + let _saver = use_resource(move || async move { 821 + let text = source.read().clone(); 822 + let ai = *active_idx.read(); 823 + tokio::time::sleep(Duration::from_millis(1000)).await; 824 + 825 + // Check if the active card segment needs formatting. 826 + let segs = make_segments(&text); 827 + let ai = ai.min(segs.len().saturating_sub(1)); 828 + let format_result = segs.get(ai).filter(|s| s.kind == SegKind::Card).and_then(|seg| { 829 + let frag = text[seg.start..seg.end].to_string(); 830 + let formatted = format_card_frag(&frag)?; 831 + let (head_end, blocks) = parse_card_structure(&frag)?; 832 + Some((seg.start, seg.end, frag, formatted, head_end, blocks)) 833 + }); 834 + 835 + let (save_text, dom_update) = if let Some((seg_start, seg_end, frag, formatted, head_end, blocks)) = 836 + format_result 837 + { 838 + // Read cursor before any mutation. 839 + let mut eval = document::eval( 840 + "var t=document.querySelector('.card-row.active textarea');\ 841 + dioxus.send(t?t.selectionStart:0);" 842 + ); 843 + let cursor = eval.recv::<i64>().await.unwrap_or(0).max(0) as usize; 844 + let new_cursor = map_cursor_after_format(&frag, cursor, head_end, &blocks); 845 + 846 + let new_src = format!("{}{}{}", &text[..seg_start], formatted, &text[seg_end..]); 847 + (new_src, Some((ai, new_cursor))) 848 + } else { 849 + (text, None) 850 + }; 851 + 852 + // Save to disk. 853 + let path = cards_path(); 854 + let save_clone = save_text.clone(); 855 + match tokio::task::spawn_blocking(move || std::fs::write(&path, &save_clone)).await { 856 + Ok(Ok(())) => save_status.set(SaveStatus::Saved), 857 + Ok(Err(e)) => save_status.set(SaveStatus::Error(e.to_string())), 858 + Err(e) => save_status.set(SaveStatus::Error(e.to_string())), 859 + } 860 + 861 + // If formatting was applied: sync textarea DOM then update source signal. 862 + // (source.set last — it may restart this resource, but save already completed.) 863 + if let Some((ai, new_cursor)) = dom_update { 864 + let new_segs = make_segments(&save_text); 865 + let new_ai = ai.min(new_segs.len().saturating_sub(1)); 866 + let new_frag = new_segs 867 + .get(new_ai) 868 + .map(|s| save_text[s.start..s.end].to_string()) 869 + .unwrap_or_default(); 870 + let b64 = base64::engine::general_purpose::STANDARD.encode(&new_frag); 871 + document::eval(&format!( 872 + "var t=document.querySelector('.card-row.active textarea');\ 873 + if(t){{t.value=atob('{}');t.setSelectionRange({new_cursor},{new_cursor})}}", 874 + b64 875 + )) 876 + .await 877 + .ok(); 878 + source.set(save_text); 879 + } 880 + }); 653 881 654 882 // Per-segment render resource: each card fragment compiled independently. 655 883 let _render = use_resource(move || async move { ··· 1637 1865 // simulate a hit that catches "vec[1" = 0..5, missing close "]" 1638 1866 let (s, e) = expand_math_selection(eq, 0, 5); // "vec[1" 1639 1867 assert_eq!(&eq[s..e], "vec[1, 2, 3]"); 1868 + } 1869 + 1870 + // ── format_card_frag / map_cursor_after_format ──────────────────────────── 1871 + 1872 + #[test] 1873 + fn format_frontback_single_line() { 1874 + let frag = "#card()[front][back]"; 1875 + let out = format_card_frag(frag).unwrap(); 1876 + assert_eq!(out, "#card()\n[\n front\n][\n back\n]"); 1877 + } 1878 + 1879 + #[test] 1880 + fn format_cloze_single_line() { 1881 + let frag = "#cloze(tags: (\"t\",))[body #blank[hidden]]"; 1882 + let out = format_card_frag(frag).unwrap(); 1883 + assert_eq!(out, "#cloze(tags: (\"t\",))\n[\n body #blank[hidden]\n]"); 1884 + } 1885 + 1886 + #[test] 1887 + fn format_already_formatted_returns_none() { 1888 + let frag = "#card()\n[\n front\n][\n back\n]"; 1889 + assert!(format_card_frag(frag).is_none()); 1890 + } 1891 + 1892 + #[test] 1893 + fn cursor_in_head_unchanged() { 1894 + let frag = "#card()[front][back]"; 1895 + let (head_end, blocks) = parse_card_structure(frag).unwrap(); 1896 + assert_eq!(map_cursor_after_format(frag, 3, head_end, &blocks), 3); 1897 + } 1898 + 1899 + #[test] 1900 + fn cursor_at_open_bracket_maps_to_formatted_bracket() { 1901 + let frag = "#card()[front][back]"; 1902 + let (head_end, blocks) = parse_card_structure(frag).unwrap(); 1903 + // `[` is at index 7; formatted: "#card()\n[" -> index 8 1904 + let nc = map_cursor_after_format(frag, 7, head_end, &blocks); 1905 + let out = format_card_frag(frag).unwrap(); 1906 + assert_eq!(&out[nc..nc + 1], "["); 1907 + } 1908 + 1909 + #[test] 1910 + fn cursor_in_block0_content_maps_correctly() { 1911 + let frag = "#card()[front][back]"; 1912 + // 'o' in "front" is at index 10 1913 + let (head_end, blocks) = parse_card_structure(frag).unwrap(); 1914 + let nc = map_cursor_after_format(frag, 10, head_end, &blocks); 1915 + let out = format_card_frag(frag).unwrap(); 1916 + assert_eq!(&out[nc..nc + 1], "o"); 1917 + } 1918 + 1919 + #[test] 1920 + fn cursor_in_block1_content_maps_correctly() { 1921 + let frag = "#card()[front][back]"; 1922 + // 'c' in "back" is at index 17 1923 + let (head_end, blocks) = parse_card_structure(frag).unwrap(); 1924 + let nc = map_cursor_after_format(frag, 17, head_end, &blocks); 1925 + let out = format_card_frag(frag).unwrap(); 1926 + assert_eq!(&out[nc..nc + 1], "c"); 1640 1927 } 1641 1928 }
+157
crates/tala/src/stats.rs
··· 1 + use dioxus::prelude::*; 2 + use tala_format::{CardKind, Direction, parse_cards}; 3 + use tala_srs::{CardSchedule, Sidecar, date_offset, today_str}; 4 + 5 + use crate::util::cards_path; 6 + 7 + struct DeckStats { 8 + total_items: usize, 9 + new_items: usize, 10 + due_today: usize, 11 + /// Item counts for today through today+13 (index 0 = today, already overdue included). 12 + forecast: [usize; 14], 13 + } 14 + 15 + fn compute_stats() -> DeckStats { 16 + let typ_path = cards_path(); 17 + let source = std::fs::read_to_string(&typ_path).unwrap_or_default(); 18 + let cards = parse_cards(&source); 19 + let sidecar = Sidecar::load_or_empty_for(&typ_path).unwrap_or_else(|_| Sidecar::empty()); 20 + let today = today_str(); 21 + let forecast_dates: [String; 14] = std::array::from_fn(|i| date_offset(i as i64)); 22 + 23 + let mut total_items = 0usize; 24 + let mut new_items = 0usize; 25 + let mut due_today = 0usize; 26 + let mut forecast = [0usize; 14]; 27 + 28 + let mut bucket = |due: &str| { 29 + if due <= today.as_str() { 30 + due_today += 1; 31 + } else { 32 + for (i, d) in forecast_dates.iter().enumerate() { 33 + if due == d.as_str() { 34 + forecast[i] += 1; 35 + break; 36 + } 37 + } 38 + } 39 + }; 40 + 41 + for (idx, card) in cards.iter().enumerate() { 42 + let key = idx.to_string(); 43 + let entry = sidecar.get(&key); 44 + 45 + match &card.kind { 46 + CardKind::FrontBack { dir, .. } => { 47 + let bi = matches!(dir, Direction::Bidirectional); 48 + total_items += if bi { 2 } else { 1 }; 49 + 50 + let (fwd, rev) = match entry { 51 + Some(CardSchedule::FrontBack { forward_schedule, reverse_schedule }) => { 52 + (forward_schedule.as_ref(), reverse_schedule.as_ref()) 53 + } 54 + _ => (None, None), 55 + }; 56 + 57 + match fwd { 58 + None => new_items += 1, 59 + Some(s) => bucket(&s.due), 60 + } 61 + if bi { 62 + match rev { 63 + None => new_items += 1, 64 + Some(s) => bucket(&s.due), 65 + } 66 + } 67 + } 68 + CardKind::Cloze { blanks, .. } => { 69 + let (blank_scheds, rects) = match entry { 70 + Some(CardSchedule::Cloze { blanks, rects }) => { 71 + (Some(blanks), Some(rects.as_slice())) 72 + } 73 + _ => (None, None), 74 + }; 75 + 76 + total_items += blanks.len(); 77 + total_items += rects.map(|r| r.len()).unwrap_or(0); 78 + 79 + for (bi, _) in blanks.iter().enumerate() { 80 + let bkey = format!("b{bi}"); 81 + match blank_scheds.and_then(|m| m.get(&bkey)) { 82 + None => new_items += 1, 83 + Some(s) => bucket(&s.due), 84 + } 85 + } 86 + if let Some(rects) = rects { 87 + for rect in rects { 88 + bucket(&rect.schedule.due); 89 + } 90 + } 91 + } 92 + } 93 + } 94 + 95 + DeckStats { total_items, new_items, due_today, forecast } 96 + } 97 + 98 + #[component] 99 + pub fn Stats() -> Element { 100 + let stats = compute_stats(); 101 + 102 + let max_bar = *stats.forecast.iter().max().unwrap_or(&0); 103 + let max_bar = max_bar.max(stats.due_today).max(1); 104 + 105 + let forecast_labels: [String; 14] = std::array::from_fn(|i| { 106 + if i == 0 { 107 + "today".to_string() 108 + } else { 109 + // "MM/DD" from "YYYY-MM-DD" 110 + let d = date_offset(i as i64); 111 + d[5..].replace('-', "/") 112 + } 113 + }); 114 + 115 + rsx! { 116 + div { id: "stats", 117 + h2 { "Stats" } 118 + div { class: "stats-summary", 119 + div { class: "stat-box", 120 + span { class: "stat-value", "{stats.total_items}" } 121 + span { class: "stat-label", "items" } 122 + } 123 + div { class: "stat-box", 124 + span { class: "stat-value stat-new", "{stats.new_items}" } 125 + span { class: "stat-label", "new" } 126 + } 127 + div { class: "stat-box", 128 + span { class: "stat-value stat-due", "{stats.due_today}" } 129 + span { class: "stat-label", "due today" } 130 + } 131 + } 132 + h3 { "Upcoming" } 133 + div { class: "forecast-chart", 134 + // today bar uses due_today, rest use forecast[i] 135 + {(0usize..14).map(|i| { 136 + let count = if i == 0 { stats.due_today } else { stats.forecast[i] }; 137 + let pct = count * 100 / max_bar; 138 + let label = &forecast_labels[i]; 139 + rsx! { 140 + div { key: "{i}", class: "forecast-col", 141 + span { class: "forecast-count", 142 + if count > 0 { "{count}" } 143 + } 144 + div { class: "forecast-bar-area", 145 + div { 146 + class: if i == 0 { "forecast-bar bar-today" } else { "forecast-bar" }, 147 + style: "height: {pct}%", 148 + } 149 + } 150 + span { class: "forecast-label", "{label}" } 151 + } 152 + } 153 + })} 154 + } 155 + } 156 + } 157 + }