this repo has no description
1
fork

Configure Feed

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

feat: stable card IDs embedded in source (ATProto TID format)

Cards now carry an `id:` argument in the typst source, generated as a
13-character ATProto-compatible TID (microsecond timestamp + 10-bit
counter). `assign_ids` splices missing IDs into source on first open;
`ensure_card_ids` also remaps sidecar keys from positional ("0","1",...)
to the embedded IDs atomically. All card key lookups in the editor and
review queue now use the embedded ID, with positional fallback.

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

+182 -12
+128 -1
crates/tala-format/src/lib.rs
··· 1 1 use std::collections::HashMap; 2 2 use std::ops::Range; 3 + use std::sync::atomic::{AtomicU64, Ordering}; 3 4 4 5 use typst_syntax::ast; 5 6 use typst_syntax::{LinkedNode, SyntaxKind}; 6 7 8 + static TID_COUNTER: AtomicU64 = AtomicU64::new(0); 9 + 10 + /// Generate a TID-format ID compatible with ATProto record keys. 11 + /// 13 base32 characters encoding a microsecond timestamp + 10-bit monotonic counter. 12 + pub fn generate_tid() -> String { 13 + use std::time::{SystemTime, UNIX_EPOCH}; 14 + let micros = SystemTime::now() 15 + .duration_since(UNIX_EPOCH) 16 + .unwrap_or_default() 17 + .as_micros() as u64; 18 + let clock = TID_COUNTER.fetch_add(1, Ordering::Relaxed) & 0x3FF; 19 + let n = (micros << 10) | clock; 20 + const BASE32: &[u8] = b"234567abcdefghijklmnopqrstuvwxyz"; 21 + let mut out = [0u8; 13]; 22 + let mut v = n; 23 + for i in (0..13).rev() { 24 + out[i] = BASE32[(v & 0x1F) as usize]; 25 + v >>= 5; 26 + } 27 + String::from_utf8(out.to_vec()).unwrap() 28 + } 29 + 7 30 // ── Public types ───────────────────────────────────────────────────────────── 8 31 9 32 #[derive(Debug, Clone)] 10 33 pub struct CardEntry { 34 + /// Stable ID from the `id:` argument, if present in source. 35 + pub id: Option<String>, 11 36 pub tags: Vec<String>, 12 37 pub kind: CardKind, 13 38 /// Byte range of the full `#card[...][...]` call in the source. ··· 190 215 }; 191 216 192 217 Some(CardEntry { 218 + id: named.get("id").cloned(), 193 219 tags, 194 220 span, 195 221 kind: CardKind::FrontBack { ··· 202 228 203 229 fn parse_cloze(node: &LinkedNode<'_>, span: Range<usize>) -> Option<CardEntry> { 204 230 let args = args_of(node)?; 205 - let (_, tags, content_blocks) = collect_args(&args); 231 + let (named, tags, content_blocks) = collect_args(&args); 206 232 207 233 let body_span = content_blocks.first()?.clone(); 208 234 ··· 214 240 find_blanks(&body_node, &mut blanks); 215 241 216 242 Some(CardEntry { 243 + id: named.get("id").cloned(), 217 244 tags, 218 245 span, 219 246 kind: CardKind::Cloze { body_span, blanks }, ··· 249 276 .map(|c| c.range()) 250 277 } 251 278 279 + // ── ID assignment ───────────────────────────────────────────────────────────── 280 + 281 + struct IdSplice { 282 + pos: usize, 283 + text: String, 284 + } 285 + 286 + /// Assign stable IDs to any `#card` or `#cloze` calls that lack one. 287 + /// 288 + /// Returns `(new_source, ids_in_document_order, changed)`. 289 + /// If all cards already have IDs, returns the original source unchanged. 290 + pub fn assign_ids(source: &str) -> (String, Vec<String>, bool) { 291 + let root = typst_syntax::parse(source); 292 + let linked = LinkedNode::new(&root); 293 + let mut splices: Vec<IdSplice> = Vec::new(); 294 + let mut all_ids: Vec<String> = Vec::new(); 295 + collect_id_splices(&linked, source, &mut splices, &mut all_ids); 296 + 297 + if splices.is_empty() { 298 + return (source.to_owned(), all_ids, false); 299 + } 300 + 301 + // Apply in reverse order to preserve byte positions of earlier splices. 302 + splices.sort_by(|a, b| b.pos.cmp(&a.pos)); 303 + let mut result = source.to_owned(); 304 + for splice in &splices { 305 + result.insert_str(splice.pos, &splice.text); 306 + } 307 + (result, all_ids, true) 308 + } 309 + 310 + fn collect_id_splices( 311 + node: &LinkedNode<'_>, 312 + source: &str, 313 + splices: &mut Vec<IdSplice>, 314 + all_ids: &mut Vec<String>, 315 + ) { 316 + if node.kind() == SyntaxKind::FuncCall { 317 + if let Some(name) = func_ident(node) { 318 + if name == "card" || name == "cloze" { 319 + if let Some(args) = args_of(node) { 320 + let (named, _, _) = collect_args(&args); 321 + if let Some(id) = named.get("id").cloned() { 322 + all_ids.push(id); 323 + } else { 324 + let id = generate_tid(); 325 + let args_start = args.range().start; 326 + let (pos, text) = if source.as_bytes().get(args_start) == Some(&b'(') { 327 + // Has parens: insert inside after `(` 328 + (args_start + 1, format!("id: \"{id}\", ")) 329 + } else { 330 + // No parens: prepend a new arg group before the content block 331 + (args_start, format!("(id: \"{id}\")")) 332 + }; 333 + all_ids.push(id); 334 + splices.push(IdSplice { pos, text }); 335 + } 336 + } 337 + return; // don't recurse into card bodies 338 + } 339 + } 340 + } 341 + for child in node.children() { 342 + collect_id_splices(&child, source, splices, all_ids); 343 + } 344 + } 345 + 252 346 // ── Tests ───────────────────────────────────────────────────────────────────── 253 347 254 348 #[cfg(test)] ··· 311 405 for (i, card) in parse_cards(SAMPLE).iter().enumerate() { 312 406 assert!(!card.span.is_empty(), "card {i} has empty span"); 313 407 } 408 + } 409 + 410 + #[test] 411 + fn assign_ids_inserts_ids() { 412 + let src = "#card[front][back]\n\n#cloze[body #blank[hidden]]\n"; 413 + let (new_src, ids, changed) = assign_ids(src); 414 + assert!(changed); 415 + assert_eq!(ids.len(), 2); 416 + // Both IDs should be 13 chars. 417 + assert!(ids.iter().all(|id| id.len() == 13), "IDs: {ids:?}"); 418 + // New source should parse back with the IDs embedded. 419 + let cards = parse_cards(&new_src); 420 + assert_eq!(cards.len(), 2); 421 + assert_eq!(cards[0].id.as_deref(), Some(ids[0].as_str())); 422 + assert_eq!(cards[1].id.as_deref(), Some(ids[1].as_str())); 423 + // Running again should be a no-op. 424 + let (_, ids2, changed2) = assign_ids(&new_src); 425 + assert!(!changed2); 426 + assert_eq!(ids2, ids); 427 + } 428 + 429 + #[test] 430 + fn assign_ids_preserves_existing() { 431 + let src = "#card(id: \"existingid1\", tags: (\"a\",))[front][back]\n\n#cloze[body]\n"; 432 + let (new_src, ids, changed) = assign_ids(src); 433 + assert!(changed); // cloze has no ID 434 + assert_eq!(ids[0], "existingid1"); 435 + assert_eq!(ids[1].len(), 13); 436 + // Re-running is stable. 437 + let (_, ids2, changed2) = assign_ids(&new_src); 438 + assert!(!changed2); 439 + assert_eq!(ids2[0], "existingid1"); 440 + assert_eq!(ids2[1], ids[1]); 314 441 } 315 442 316 443 #[test]
+29 -10
crates/tala/src/editor.rs
··· 123 123 fn active_card_key(source: &str, active_idx: usize) -> String { 124 124 let segs = make_segments(source); 125 125 let ai = active_idx.min(segs.len().saturating_sub(1)); 126 - segs[..=ai] 126 + let card_pos = segs[..=ai] 127 127 .iter() 128 128 .filter(|s| s.kind == SegKind::Card) 129 129 .count() 130 - .saturating_sub(1) 131 - .to_string() 130 + .saturating_sub(1); 131 + let cards = tala_format::parse_cards(source); 132 + cards.get(card_pos) 133 + .and_then(|c| c.id.clone()) 134 + .unwrap_or_else(|| card_pos.to_string()) 132 135 } 133 136 134 137 fn commit_sidecar_rects(rects: Vec<RectEntry>, card_key: &str) { ··· 932 935 return; 933 936 } 934 937 let ai = idx.min(segs.len().saturating_sub(1)); 935 - let card_idx = segs[..=ai] 938 + let card_pos = segs[..=ai] 936 939 .iter() 937 940 .filter(|s| s.kind == SegKind::Card) 938 941 .count() 939 942 .saturating_sub(1); 943 + let card_id = { 944 + let cards = tala_format::parse_cards(&text); 945 + cards.get(card_pos) 946 + .and_then(|c| c.id.clone()) 947 + .unwrap_or_else(|| card_pos.to_string()) 948 + }; 940 949 match segs.get(ai) { 941 950 Some(Seg { 942 951 kind: SegKind::Card, ··· 953 962 .ok() 954 963 .and_then(|sc| { 955 964 if let Some(CardSchedule::Cloze { rects, .. }) = 956 - sc.cards.get(&card_idx.to_string()) 965 + sc.cards.get(&card_id) 957 966 { 958 967 Some(rects.clone()) 959 968 } else { ··· 1114 1123 }).collect()) 1115 1124 .unwrap_or_default() 1116 1125 }; 1126 + let card_keys_snap: Vec<String> = tala_format::parse_cards(&text_snap) 1127 + .into_iter() 1128 + .enumerate() 1129 + .map(|(i, c)| c.id.unwrap_or_else(|| i.to_string())) 1130 + .collect(); 1117 1131 1118 1132 let filter_tags_snap = editor_selected_tags.read().clone(); 1119 1133 let filter_mode_snap = editor_tag_mode.read().clone(); ··· 1260 1274 let card_sidecar_rects_local: Vec<RectEntry> = if is_active { 1261 1275 active_sidecar_rects.clone() 1262 1276 } else { 1263 - let card_key = segs_snap[..=i] 1277 + let card_pos = segs_snap[..=i] 1264 1278 .iter() 1265 1279 .filter(|s| s.kind == SegKind::Card) 1266 1280 .count() 1267 - .saturating_sub(1) 1268 - .to_string(); 1281 + .saturating_sub(1); 1282 + let card_key = card_keys_snap.get(card_pos) 1283 + .cloned() 1284 + .unwrap_or_else(|| card_pos.to_string()); 1269 1285 all_sidecar_rects.get(&card_key).cloned().unwrap_or_default() 1270 1286 }; 1271 1287 let card_img_names_local: Vec<String> = if is_active { ··· 1588 1604 let segs = make_segments(&cur); 1589 1605 let ai = (*active_idx.read()).min(segs.len().saturating_sub(1)); 1590 1606 let card_key = { 1591 - let card_idx = segs[..=ai] 1607 + let card_pos = segs[..=ai] 1592 1608 .iter() 1593 1609 .filter(|s| s.kind == SegKind::Card) 1594 1610 .count() 1595 1611 .saturating_sub(1); 1596 - card_idx.to_string() 1612 + let cards = tala_format::parse_cards(&cur); 1613 + cards.get(card_pos) 1614 + .and_then(|c| c.id.clone()) 1615 + .unwrap_or_else(|| card_pos.to_string()) 1597 1616 }; 1598 1617 let fragment = segs.get(ai).map(|s| &cur[s.start..s.end]).unwrap_or(""); 1599 1618 let src = extract_img_names(fragment)
+1 -1
crates/tala/src/review.rs
··· 125 125 continue; 126 126 } 127 127 } 128 - let card_id = ci.to_string(); 128 + let card_id = card.id.clone().unwrap_or_else(|| ci.to_string()); 129 129 let card_src = source[card.span.start.saturating_sub(1)..card.span.end].to_string(); 130 130 let card_tags = card.tags.clone(); 131 131 match (&card.kind, sidecar.get(&card_id)) {
+24
crates/tala/src/util.rs
··· 1 + use std::collections::HashMap; 1 2 use std::path::{Path, PathBuf}; 2 3 3 4 use dioxus::prelude::*; ··· 111 112 } 112 113 113 114 115 + /// Assign stable IDs to cards that lack them, migrating the sidecar's 116 + /// positional keys ("0","1",...) to the embedded IDs in one atomic step. 117 + /// Idempotent: no-ops if all cards already have IDs. 118 + pub fn ensure_card_ids(typ_path: &Path) { 119 + let Ok(source) = std::fs::read_to_string(typ_path) else { return }; 120 + let (new_source, ids, changed) = tala_format::assign_ids(&source); 121 + if !changed { return; } 122 + if let Ok(mut sidecar) = tala_srs::Sidecar::load_or_empty_for(typ_path) { 123 + let mut new_cards: HashMap<String, tala_srs::CardSchedule> = HashMap::new(); 124 + for (i, id) in ids.iter().enumerate() { 125 + if let Some(sched) = sidecar.cards.remove(&i.to_string()) { 126 + new_cards.insert(id.clone(), sched); 127 + } 128 + } 129 + new_cards.extend(sidecar.cards.drain()); 130 + sidecar.cards = new_cards; 131 + let _ = sidecar.save_for(typ_path); 132 + } 133 + let _ = std::fs::write(typ_path, new_source); 134 + } 135 + 114 136 /// Directory containing cards.typ and images/. 115 137 static CARD_DIR: GlobalSignal<PathBuf> = Signal::global(|| { 116 138 let dir = std::env::args() ··· 120 142 .unwrap_or_else(|| std::env::current_dir().expect("cwd")); 121 143 let dir = std::fs::canonicalize(&dir).unwrap_or(dir); 122 144 let _ = std::fs::create_dir_all(dir.join("images")); 145 + ensure_card_ids(&dir.join("cards.typ")); 123 146 dir 124 147 }); 125 148 ··· 134 157 pub fn set_card_dir(path: PathBuf) { 135 158 let path = std::fs::canonicalize(&path).unwrap_or(path); 136 159 let _ = std::fs::create_dir_all(path.join("images")); 160 + ensure_card_ids(&path.join("cards.typ")); 137 161 persist_dir(&path); 138 162 *CARD_DIR.write() = path; 139 163 }