|
| 1 | +use std::collections::HashSet; |
1 | 2 | use std::path::Path; |
2 | 3 |
|
3 | 4 | use anyhow::Result; |
@@ -73,6 +74,23 @@ pub struct MentionInfo { |
73 | 74 | pub snippet: String, |
74 | 75 | } |
75 | 76 |
|
| 77 | +#[derive(Debug, Serialize)] |
| 78 | +pub struct ProjectContext { |
| 79 | + pub name: String, |
| 80 | + pub note: Option<NoteContent>, |
| 81 | + pub child_notes: Vec<NoteListItem>, |
| 82 | + pub active_tasks: Vec<TaskItem>, |
| 83 | + pub team: Vec<String>, |
| 84 | + pub recent_mentions: Vec<MentionInfo>, |
| 85 | + pub total_chars: usize, |
| 86 | +} |
| 87 | + |
| 88 | +#[derive(Debug, Serialize)] |
| 89 | +pub struct TaskItem { |
| 90 | + pub text: String, |
| 91 | + pub source_file: String, |
| 92 | +} |
| 93 | + |
76 | 94 | // --------------------------------------------------------------------------- |
77 | 95 | // Helpers |
78 | 96 | // --------------------------------------------------------------------------- |
@@ -344,6 +362,140 @@ fn get_mention_snippet(params: &ContextParams, file_id: i64, name: &str) -> Stri |
344 | 362 | String::new() |
345 | 363 | } |
346 | 364 |
|
| 365 | +/// Build a project context bundle: note, child notes, tasks, team, recent mentions. |
| 366 | +pub fn context_project(params: &ContextParams, name: &str) -> Result<ProjectContext> { |
| 367 | + let name_md = format!("{}.md", name); |
| 368 | + let name_lower = name_md.to_lowercase(); |
| 369 | + let all_files = params.store.get_all_files()?; |
| 370 | + let project_file = all_files.iter().find(|f| { |
| 371 | + let basename = f.path.rsplit('/').next().unwrap_or(&f.path).to_lowercase(); |
| 372 | + basename == name_lower |
| 373 | + }); |
| 374 | + |
| 375 | + let (note, project_id, project_folder) = if let Some(pf) = project_file { |
| 376 | + let n = context_read(params, &pf.path)?; |
| 377 | + let folder = pf.path.rsplit_once('/').map(|(f, _)| f.to_string()); |
| 378 | + (Some(n), Some(pf.id), folder) |
| 379 | + } else { |
| 380 | + (None, None, None) |
| 381 | + }; |
| 382 | + |
| 383 | + let mut child_ids = HashSet::new(); |
| 384 | + let mut child_notes = Vec::new(); |
| 385 | + |
| 386 | + // Files in same folder |
| 387 | + if let Some(folder) = &project_folder { |
| 388 | + let folder_files = params.store.list_files(Some(folder), &[], 50)?; |
| 389 | + for f in folder_files { |
| 390 | + if Some(f.id) != project_id && child_ids.insert(f.id) { |
| 391 | + let ec = params.store.edge_count_for_file(f.id).unwrap_or(0); |
| 392 | + child_notes.push(NoteListItem { |
| 393 | + path: f.path, |
| 394 | + docid: f.docid, |
| 395 | + tags: f.tags, |
| 396 | + indexed_at: f.indexed_at, |
| 397 | + edge_count: ec, |
| 398 | + }); |
| 399 | + } |
| 400 | + } |
| 401 | + } |
| 402 | + |
| 403 | + // Files linking to project |
| 404 | + if let Some(pid) = project_id { |
| 405 | + let incoming = params.store.get_incoming(pid, Some("wikilink"))?; |
| 406 | + for (fid, _) in &incoming { |
| 407 | + if child_ids.insert(*fid) |
| 408 | + && let Some(f) = params.store.get_file_by_id(*fid).ok().flatten() |
| 409 | + { |
| 410 | + let ec = params.store.edge_count_for_file(*fid).unwrap_or(0); |
| 411 | + child_notes.push(NoteListItem { |
| 412 | + path: f.path, |
| 413 | + docid: f.docid, |
| 414 | + tags: f.tags, |
| 415 | + indexed_at: f.indexed_at, |
| 416 | + edge_count: ec, |
| 417 | + }); |
| 418 | + } |
| 419 | + } |
| 420 | + } |
| 421 | + |
| 422 | + // Active tasks |
| 423 | + let mut active_tasks = Vec::new(); |
| 424 | + let scan_tasks = |path: &str, tasks: &mut Vec<TaskItem>| { |
| 425 | + let full = params.vault_path.join(path); |
| 426 | + if let Ok(content) = std::fs::read_to_string(&full) { |
| 427 | + for line in content.lines() { |
| 428 | + let trimmed = line.trim(); |
| 429 | + if trimmed.starts_with("- [ ] ") { |
| 430 | + tasks.push(TaskItem { |
| 431 | + text: trimmed |
| 432 | + .strip_prefix("- [ ] ") |
| 433 | + .unwrap_or(trimmed) |
| 434 | + .to_string(), |
| 435 | + source_file: path.to_string(), |
| 436 | + }); |
| 437 | + } |
| 438 | + } |
| 439 | + } |
| 440 | + }; |
| 441 | + if let Some(n) = ¬e { |
| 442 | + scan_tasks(&n.path, &mut active_tasks); |
| 443 | + } |
| 444 | + for child in &child_notes { |
| 445 | + scan_tasks(&child.path, &mut active_tasks); |
| 446 | + } |
| 447 | + |
| 448 | + // Team: people linked from project |
| 449 | + let mut team = Vec::new(); |
| 450 | + if let Some(pid) = project_id { |
| 451 | + let outgoing = params.store.get_outgoing(pid, Some("wikilink"))?; |
| 452 | + for (fid, _) in &outgoing { |
| 453 | + if let Some(path) = params.store.get_file_path_by_id(*fid).ok().flatten() |
| 454 | + && path.to_lowercase().contains("people") |
| 455 | + { |
| 456 | + team.push(path); |
| 457 | + } |
| 458 | + } |
| 459 | + } |
| 460 | + |
| 461 | + // Recent mentions in daily notes |
| 462 | + let mut recent_mentions = Vec::new(); |
| 463 | + if let Ok(fts_results) = params.store.fts_search(name, 10) { |
| 464 | + for r in fts_results { |
| 465 | + if let Some(path) = params.store.get_file_path_by_id(r.file_id).ok().flatten() |
| 466 | + && (path.contains("Daily") || path.contains("daily")) |
| 467 | + { |
| 468 | + let docid = params |
| 469 | + .store |
| 470 | + .get_file_by_id(r.file_id) |
| 471 | + .ok() |
| 472 | + .flatten() |
| 473 | + .and_then(|f| f.docid); |
| 474 | + recent_mentions.push(MentionInfo { |
| 475 | + path, |
| 476 | + docid, |
| 477 | + snippet: r.snippet, |
| 478 | + }); |
| 479 | + if recent_mentions.len() >= 5 { |
| 480 | + break; |
| 481 | + } |
| 482 | + } |
| 483 | + } |
| 484 | + } |
| 485 | + |
| 486 | + let total_chars = note.as_ref().map(|n| n.char_count).unwrap_or(0); |
| 487 | + |
| 488 | + Ok(ProjectContext { |
| 489 | + name: name.to_string(), |
| 490 | + note, |
| 491 | + child_notes, |
| 492 | + active_tasks, |
| 493 | + team, |
| 494 | + recent_mentions, |
| 495 | + total_chars, |
| 496 | + }) |
| 497 | +} |
| 498 | + |
347 | 499 | // --------------------------------------------------------------------------- |
348 | 500 | // Tests |
349 | 501 | // --------------------------------------------------------------------------- |
@@ -546,4 +698,64 @@ mod tests { |
546 | 698 | assert!(person.note.is_none()); |
547 | 699 | assert!(person.mentioned_in.is_empty()); |
548 | 700 | } |
| 701 | + |
| 702 | + #[test] |
| 703 | + fn test_project_context() { |
| 704 | + let tmp = TempDir::new().unwrap(); |
| 705 | + let root = tmp.path().to_path_buf(); |
| 706 | + std::fs::create_dir_all(root.join("01-Projects")).unwrap(); |
| 707 | + std::fs::write( |
| 708 | + root.join("01-Projects/MyProject.md"), |
| 709 | + "# MyProject\n\n- [ ] Task one\n- [x] Done task\n- [ ] Task two", |
| 710 | + ) |
| 711 | + .unwrap(); |
| 712 | + std::fs::write( |
| 713 | + root.join("01-Projects/child.md"), |
| 714 | + "# Child\nRelated to [[MyProject]].\n- [ ] Sub task", |
| 715 | + ) |
| 716 | + .unwrap(); |
| 717 | + |
| 718 | + let store = Store::open_memory().unwrap(); |
| 719 | + let f1 = store |
| 720 | + .insert_file( |
| 721 | + "01-Projects/MyProject.md", |
| 722 | + "h1", |
| 723 | + 100, |
| 724 | + &["project".into()], |
| 725 | + "aaa111", |
| 726 | + ) |
| 727 | + .unwrap(); |
| 728 | + let f2 = store |
| 729 | + .insert_file("01-Projects/child.md", "h2", 100, &[], "bbb222") |
| 730 | + .unwrap(); |
| 731 | + store.insert_edge(f2, f1, "wikilink").unwrap(); |
| 732 | + store.insert_edge(f1, f2, "wikilink").unwrap(); |
| 733 | + |
| 734 | + let params = ContextParams { |
| 735 | + store: &store, |
| 736 | + vault_path: &root, |
| 737 | + profile: None, |
| 738 | + }; |
| 739 | + let proj = context_project(¶ms, "MyProject").unwrap(); |
| 740 | + assert!(proj.note.is_some()); |
| 741 | + assert!(!proj.child_notes.is_empty()); |
| 742 | + // Should find "Task one" and "Task two" (not "Done task") |
| 743 | + assert!(proj.active_tasks.len() >= 2); |
| 744 | + assert!(proj.active_tasks.iter().any(|t| t.text == "Task one")); |
| 745 | + assert!(proj.active_tasks.iter().any(|t| t.text == "Task two")); |
| 746 | + assert!(!proj.active_tasks.iter().any(|t| t.text.contains("Done"))); |
| 747 | + } |
| 748 | + |
| 749 | + #[test] |
| 750 | + fn test_project_not_found() { |
| 751 | + let (_tmp, store, root) = setup_vault(); |
| 752 | + let params = ContextParams { |
| 753 | + store: &store, |
| 754 | + vault_path: &root, |
| 755 | + profile: None, |
| 756 | + }; |
| 757 | + let proj = context_project(¶ms, "NonExistent").unwrap(); |
| 758 | + assert!(proj.note.is_none()); |
| 759 | + assert!(proj.child_notes.is_empty()); |
| 760 | + } |
549 | 761 | } |
0 commit comments