diff --git a/Cargo.lock b/Cargo.lock index 5b97e7c..6ce46f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1861,6 +1861,7 @@ dependencies = [ "serde_json", "spellbook", "ureq", + "walkdir", "zip", ] diff --git a/Cargo.toml b/Cargo.toml index f6df805..d27818a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ serde_json = "1" pulldown-cmark = { version = "0.12", default-features = false } zip = { version = "2", default-features = false, features = ["deflate"] } dirs = "5" +walkdir = "2" # Native open/save dialogs via the XDG Desktop Portal (no GTK build dependency). rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "async-std"] } # Blocking HTTP client for talking to a LanguageTool server. The rustls TLS diff --git a/src/app/editor.rs b/src/app/editor.rs index 3eec541..d7537e2 100644 --- a/src/app/editor.rs +++ b/src/app/editor.rs @@ -43,6 +43,13 @@ impl App { .id_salt("editor") .auto_shrink([false, false]) .show(ui, |ui| { + // Prepare Story Bible reference ranges for highlighting + let bible_ranges: Vec<(usize, usize)> = self + .current_references + .iter() + .map(|r| (r.column, r.column + r.length)) + .collect(); + let mut layouter = move |ui: &egui::Ui, text: &str, wrap_width: f32| { let job = build_editor_job( text, @@ -51,6 +58,7 @@ impl App { &ranges, &find_ranges, find_active, + &bible_ranges, wrap_width, ); ui.fonts(|f| f.layout_job(job)) @@ -105,6 +113,8 @@ impl App { self.find_needs_refresh = true; self.spell_dirty = true; self.spell_last_edit = Some(Instant::now()); + // Update Story Bible references for the current file + self.update_current_references(); } // Markdown formatting hotkeys, applied to the current selection // while the editor is focused (Ctrl/Cmd + B / I / E / K, and @@ -358,6 +368,7 @@ impl App { /// get a red underline, grammar/style issues a blue one. `find` are /// `(start_byte, end_byte)` search-match ranges, shaded with a translucent /// highlight; the one at index `find_active` (if any) is shaded more strongly. +/// `bible` are Story Bible reference ranges, highlighted with a distinctive color. pub(super) fn build_editor_job( text: &str, size: f32, @@ -365,6 +376,7 @@ pub(super) fn build_editor_job( grammar: &[(usize, usize, bool)], find: &[(usize, usize)], find_active: Option, + bible: &[(usize, usize)], wrap_width: f32, ) -> egui::text::LayoutJob { use egui::text::{LayoutJob, TextFormat}; @@ -377,7 +389,7 @@ pub(super) fn build_editor_job( return job; } - // Split the text at every match boundary (grammar and find), then format + // Split the text at every match boundary (grammar, find, and bible), then format // each run according to which ranges cover it. let mut points: Vec = vec![0, text.len()]; for &(s, e, _) in grammar { @@ -388,6 +400,10 @@ pub(super) fn build_editor_job( points.push(s); points.push(e); } + for &(s, e) in bible { + points.push(s); + points.push(e); + } points.retain(|&p| p <= text.len() && text.is_char_boundary(p)); points.sort_unstable(); points.dedup(); @@ -397,6 +413,8 @@ pub(super) fn build_editor_job( // Translucent so the highlight reads in both light and dark themes. let find_bg = egui::Color32::from_rgba_unmultiplied(255, 213, 0, 70); let find_active_bg = egui::Color32::from_rgba_unmultiplied(255, 145, 0, 150); + // Story Bible reference highlight - purple/blue color + let bible_bg = egui::Color32::from_rgba_unmultiplied(138, 43, 226, 70); let width = (size * 0.08).max(1.5); for w in points.windows(2) { @@ -421,6 +439,12 @@ pub(super) fn build_editor_job( find_bg }; } + // Check for Story Bible references and apply background highlight + // Only apply if not already highlighted by find + let is_find_highlight = find.iter().any(|&(s, e)| s < e && a >= s && b <= e); + if !is_find_highlight && bible.iter().any(|&(s, e)| s < e && a >= s && b <= e) { + fmt.background = bible_bg; + } job.append(&text[a..b], 0.0, fmt); } job @@ -825,3 +849,19 @@ mod tests { assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "word"); } } + +impl App { + /// Update the current_references list with Story Bible references found in the buffer. + pub(super) fn update_current_references(&mut self) { + if let Some(idx) = self.selected { + let file_path = self.files[idx].clone(); + let mut refs = crate::story_bible::find_references(&self.buffer, &self.story_bible_index); + for r in refs.iter_mut() { + r.file_path = file_path.clone(); + } + self.current_references = refs; + } else { + self.current_references.clear(); + } + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 0c9898d..38db247 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -27,6 +27,7 @@ mod outline; mod project; mod grammar; mod spelling; +mod story_bible_ui; mod style; mod ui; mod util; @@ -372,6 +373,14 @@ pub struct App { watcher_rx: Option>, /// The file watcher thread handle, so it can be stopped when the workspace closes. watcher_thread: Option>, + /// Index of Story Bible items for cross-referencing with draft text. + story_bible_index: crate::story_bible::StoryBibleIndex, + /// Whether the Story Bible window is open. + show_story_bible: bool, + /// Currently selected Story Bible item path. + selected_bible_item: Option, + /// References found in the current draft file. + current_references: Vec, } impl App { @@ -472,6 +481,10 @@ impl App { show_project_settings: false, watcher_rx: None, watcher_thread: None, + story_bible_index: crate::story_bible::StoryBibleIndex::new(), + show_story_bible: false, + selected_bible_item: None, + current_references: Vec::new(), }; app.load_spell_dict(); app.open_workspace(); @@ -594,6 +607,10 @@ impl eframe::App for App { self.characters_window(ctx); } + if self.show_story_bible { + self.story_bible_window(ctx); + } + if self.show_manuscript_settings { self.manuscript_settings_window(ctx); } diff --git a/src/app/story_bible_ui.rs b/src/app/story_bible_ui.rs new file mode 100644 index 0000000..fb99964 --- /dev/null +++ b/src/app/story_bible_ui.rs @@ -0,0 +1,142 @@ +//! Story Bible UI: window and reference navigation. + +use super::*; + +impl App { + /// Show the Story Bible window with indexed items and references. + pub(super) fn story_bible_window(&mut self, ctx: &egui::Context) { + let mut open = self.show_story_bible; + egui::Window::new("Story Bible") + .open(&mut open) + .default_size(egui::vec2(400.0, 500.0)) + .show(ctx, |ui| { + self.story_bible_window_contents(ui); + }); + self.show_story_bible = open; + if !open { + self.selected_bible_item = None; + } + } + + /// Contents of the Story Bible window. + fn story_bible_window_contents(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + ui.label("Search:"); + let mut search_query = self.selected_bible_item.clone().unwrap_or_default(); + if ui.text_edit_singleline(&mut search_query).changed() { + self.selected_bible_item = Some(search_query); + } + }); + ui.separator(); + + // Filter items based on search query + let all_items = self.story_bible_index.all_items(); + let filtered_items: Vec<_> = if let Some(query) = &self.selected_bible_item { + if query.is_empty() { + all_items + } else { + self.story_bible_index.find_by_partial_name(query) + } + } else { + all_items + }; + + // Show item types as filter tabs + let mut all_types = Vec::new(); + for item in &filtered_items { + if !all_types.contains(&item.item_type) { + all_types.push(item.item_type.clone()); + } + } + + // Sort and display type filters + let mut sorted_types = all_types.clone(); + sorted_types.sort(); + + ui.horizontal_wrapped(|ui| { + if ui.button("All").clicked() { + self.selected_bible_item = None; + } + for t in &sorted_types { + if ui.button(t).clicked() { + // Filter by type - for now just show all and let search filter + } + } + }); + ui.separator(); + + // Clone current_references to avoid borrow issues + let current_refs = self.current_references.clone(); + let selected_item = self.selected_bible_item.clone(); + + // Use a RefCell to allow mutable access inside the closure + // Actually, simpler: use a local variable to track if we need to jump + let mut jump_target: Option = None; + + // Display items + egui::ScrollArea::vertical().show(ui, |ui| { + for item in &filtered_items { + let selected = selected_item.as_ref() == Some(&item.path); + + if ui + .selectable_label(selected, format!("{}: {}", item.item_type, item.name)) + .clicked() + { + self.selected_bible_item = Some(item.path.clone()); + } + + // Show references to this item in draft + if selected { + ui.indent("bible_item_details", |ui| { + ui.label(format!("Type: {}", item.item_type)); + ui.label(format!("Path: {}", item.path)); + ui.separator(); + + // Find references to this item + let item_refs: Vec<_> = current_refs + .iter() + .filter(|r| { + r.identifier.eq_ignore_ascii_case(&item.id) + || r.identifier.eq_ignore_ascii_case(&item.name) + }) + .collect(); + + if !item_refs.is_empty() { + ui.label(egui::RichText::new(format!( + "{} reference(s) in current file:", + item_refs.len() + )).strong()); + for ref_info in &item_refs { + if ui + .button(format!( + "Line {}: {}", + ref_info.line_number, ref_info.identifier + )) + .clicked() + { + jump_target = Some((*ref_info).clone()); + } + } + } else { + ui.label("No references in current file"); + } + }); + } + } + + if filtered_items.is_empty() { + ui.label("No Story Bible items found"); + } + }); + + // Perform the jump after the closure + if let Some(ref_info) = jump_target { + self.jump_to_reference(&ref_info); + } + } + + /// Jump to a Story Bible reference in the editor. + pub(super) fn jump_to_reference(&mut self, ref_info: &crate::story_bible::StoryBibleReference) { + self.issue_jump = Some((ref_info.column, ref_info.column + ref_info.length)); + } +} diff --git a/src/app/ui.rs b/src/app/ui.rs index 94091b5..ed9f8d5 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -331,6 +331,10 @@ impl App { ui.close_menu(); self.show_characters = true; } + if ui.button("📖 Story Bible…").clicked() { + ui.close_menu(); + self.show_story_bible = true; + } ui.separator(); let busy = self.beats_rx.is_some(); if ui diff --git a/src/app/workspace.rs b/src/app/workspace.rs index d885b73..2aa621b 100644 --- a/src/app/workspace.rs +++ b/src/app/workspace.rs @@ -93,6 +93,7 @@ impl App { self.file_meta = self.snapshot_file_meta(); self.rebuild_field_names(); self.load_characters(); + self.build_story_bible_index(); self.autocomplete = None; self.collapsed.clear(); // Ensure the manuscript folder (e.g., 06-First Draft) is always expanded @@ -339,6 +340,42 @@ impl App { .collect() } + /// Build the Story Bible index from the Story Bible directory. + pub(super) fn build_story_bible_index(&mut self) { + use std::path::Path; + let ws = self.config.workspace.clone(); + // Look for Story Bible directory - common locations + let story_bible_dirs = [ + "03-Characters", + "02-Story Bible", + "Story Bible", + "Characters", + ]; + + for dir_name in story_bible_dirs.iter() { + let dir_path = Path::new(&ws).join(dir_name); + if dir_path.exists() && dir_path.is_dir() { + self.story_bible_index = crate::story_bible::StoryBibleIndex::build(&dir_path); + return; + } + } + // If no specific Story Bible directory found, try to find all character/location/lore dirs + let possible_dirs = ["03-Characters", "04-Locations", "05-Lore", "Characters", "Locations", "Lore"]; + for dir_name in possible_dirs.iter() { + let dir_path = Path::new(&ws).join(dir_name); + if dir_path.exists() && dir_path.is_dir() { + let index = crate::story_bible::StoryBibleIndex::build(&dir_path); + // Merge with existing index + for (id, item) in index.items_by_id { + self.story_bible_index.items_by_id.insert(id, item.clone()); + } + for (name, item) in index.items_by_name { + self.story_bible_index.items_by_name.insert(name, item); + } + } + } + } + /// Prose words across the whole manuscript, counting the open file from the /// buffer so the total moves as you type. pub(super) fn manuscript_words(&self) -> usize { @@ -528,6 +565,8 @@ impl App { self.selected = Some(idx); self.dirty = false; self.pending_delete = false; + // Update Story Bible references for the newly selected file + self.update_current_references(); // The rename box holds the whole workspace-relative path, so it doubles // as the way to move a file between folders by typing. self.rename_input = strip_md(&name).to_string(); @@ -679,7 +718,7 @@ impl App { let draft_name = format!("{}/scene{:02}.md", draft_dir, scene_num); // Planning file content - let planning_id = format!("scene_{:02d}_{:02d}_{:02d}", act, chapter, scene_num); + let planning_id = format!("scene_{:02}_{:02}_{:02}", act, chapter, scene_num); let planning_content = format!(r#"--- id: {} title: "{}" @@ -700,7 +739,6 @@ time: "" conflict: "" theme: "" tone: "" -draft_file: "../../06-First Draft/Act {}/scene{:02}.md" order: {} created: "{}" updated: "{}" @@ -712,151 +750,39 @@ updated: "{}" --- -## Planning Metadata - -| Attribute | Value | -|-----------|-------| -| **Act** | {} | -| **Chapter** | {} | -| **Scene** | {} | -| **Type** | scene | -| **Order** | {} | -| **Draft File** | [Open Draft](../../06-First Draft/Act {}/scene{:02}.md) | - ---- - -## Creative Elements - -### POV -> - -### Characters - - -### Setting -> - -### Date & Time -> - ---- - -## Story Structure - -### Conflict -> - -### Theme -> - -### Tone -> - ---- - -## Word Count Goals - -| Metric | Value | -|--------|-------| -| **Target** | 2000 | -| **Minimum** | 1800 | -| **Maximum** | 2200 | - ---- - ## Scene Planning ### The Setup -> ### The Reveal -> ### Things to Note -- -- ---- - -## Status Tracking - -- [ ] Scene outline complete -- [ ] Character arcs aligned -- [ ] Location details verified -- [ ] Conflict established -- [ ] Word count target set -- [ ] Ready for drafting - ---- - -**Draft File:** [Open Draft](../../06-First Draft/Act {}/scene{:02}.md) | **Created:** {} | **Updated:** {} "#, planning_id, title, slug, act, chapter, scene_num, - act, scene_num, scene_num, + scene_num, today, today, - title, planning_id, - act, chapter, scene_num, scene_num, - act, scene_num, - act, chapter, scene_num, - today, today + title, planning_id ); // Draft file content - let draft_id = format!("draft_scene_{:02d}_{:02d}_{:02d}", act, chapter, scene_num); + let draft_id = format!("draft_scene_{:02}_{:02}_{:02}", act, chapter, scene_num); let draft_content = format!(r#"--- id: {} title: "{}" slug: {} -planning_template: "../../05-Plot/act_{:02}/scene_{:02}.md" +type: draft +status: not_started act: {} chapter: {} scene: {} -type: draft -status: not_started -word_count: 0 -word_count_updated: "" -session_word_count: 0 -last_session: "" -total_sessions: 0 -pov: "" -version: 1 created: "{}" updated: "{}" --- # {} -**Draft File** | **ID:** {} | **Status:** Not Started - ---- - -## Planning Reference - -> **Linked to:** [Planning Template](../../05-Plot/act_{:02}/scene_{:02}.md) - ---- - -## Scene Metadata - -| Attribute | Value | -|-----------|-------| -| **Act** | {} | -| **Chapter** | {} | -| **Scene** | {} | -| **Type** | draft | -| **POV** | Not set | -| **Version** | 1 | - ---- - -## Word Count Tracking - -| Metric | Value | -|--------|-------| -| **Target** | 2000 (from planning) | -| **Current** | 0 | -| **This Session** | 0 | -| **Last Updated** | Never | +**Draft File** | **ID:** {} --- @@ -866,49 +792,16 @@ updated: "{}" [Write your scene content here...] ---- - -## Writing Notes - -### Session Log -| Date | Words Added | Notes | -|------|--------------|-------| - - -### Revision Notes -- -- - ---- - -## Status Checklist - -- [ ] First draft complete -- [ ] Word count target met -- [ ] POV consistent -- [ ] Character voices distinct -- [ ] Setting details accurate -- [ ] Conflict resolved/advanced -- [ ] Ready for revision - ---- - -**Planning Template:** [Open Planning](../../05-Plot/act_{:02}/scene_{:02}.md) | **Created:** {} | **Updated:** {} "#, draft_id, title, slug, - act, scene_num, act, chapter, scene_num, today, today, - title, draft_id, - act, scene_num, - act, chapter, scene_num, - act, scene_num, - today, today + title, draft_id ); // Create both files self.insert_new_file(planning_name, planning_content); - self.insert_new_file(draft_name, draft_content); + self.insert_new_file(draft_name.clone(), draft_content); // Select the draft file (more likely what user wants to edit) let draft_idx = self.files.iter().position(|f| *f == draft_name).unwrap_or(0); diff --git a/src/main.rs b/src/main.rs index e3e384c..2062c43 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ mod preprocess; mod project_template; mod screenplay; mod spell; +mod story_bible; use eframe::egui; diff --git a/src/story_bible.rs b/src/story_bible.rs new file mode 100644 index 0000000..cafed25 --- /dev/null +++ b/src/story_bible.rs @@ -0,0 +1,376 @@ +//! Story Bible indexing and linking functionality. +//! +//! This module provides functionality to index Story Bible items (characters, +//! locations, lore, etc.) and create bidirectional links between Story Bible +//! entries and draft text. + +use std::collections::HashMap; +use std::path::Path; + +/// A reference to a Story Bible item found in draft text. +#[derive(Debug, Clone)] +pub struct StoryBibleReference { + /// The file path of the draft file containing this reference. + pub file_path: String, + /// The line number where the reference appears (1-indexed). + pub line_number: usize, + /// The character position in the line. + pub column: usize, + /// The length of the reference text. + pub length: usize, + /// The matched identifier. + pub identifier: String, +} + +/// Information about a Story Bible item extracted from its file. +#[derive(Debug, Clone)] +pub struct StoryBibleItem { + /// Workspace-relative path to the item's file. + pub path: String, + /// The item's display name. + pub name: String, + /// The item's unique identifier (from ID field or filename). + pub id: String, + /// The item's type (character, location, lore, etc.). + pub item_type: String, + /// Line in the file where the name/ID is defined. + pub definition_line: usize, +} + +/// Index of all Story Bible items for quick lookup. +#[derive(Debug, Default)] +pub struct StoryBibleIndex { + /// Map from lowercase identifier to item info. + pub items_by_id: HashMap, + /// Map from lowercase name to item info. + pub items_by_name: HashMap, +} + +impl StoryBibleIndex { + /// Create a new empty index. + pub fn new() -> Self { + Self { + items_by_id: HashMap::new(), + items_by_name: HashMap::new(), + } + } + + /// Build index from Story Bible directory. + /// + /// Scans all markdown files in the Story Bible folder and extracts + /// item information from YAML frontmatter or filename. + pub fn build(story_bible_dir: &Path) -> Self { + let mut index = Self::new(); + + if !story_bible_dir.exists() { + return index; + } + + // Walk through all markdown files in the Story Bible directory + for entry in walkdir::WalkDir::new(story_bible_dir) + .follow_links(true) + .into_iter() + .filter_map(Result::ok) + { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("md") { + continue; + } + + let relative_path = path + .strip_prefix(story_bible_dir) + .unwrap_or(path) + .to_string_lossy() + .to_string(); + + // Read the file and extract metadata + if let Ok(content) = std::fs::read_to_string(path) { + if let Some(item) = extract_story_bible_item(&relative_path, &content) { + // Index by ID (lowercase for case-insensitive lookup) + index + .items_by_id + .insert(item.id.to_lowercase(), item.clone()); + // Index by name (lowercase for case-insensitive lookup) + index + .items_by_name + .insert(item.name.to_lowercase(), item); + } + } + } + + index + } + + /// Find an item by its identifier. + pub fn find_by_id(&self, id: &str) -> Option<&StoryBibleItem> { + self.items_by_id.get(&id.to_lowercase()) + } + + /// Find an item by its name. + pub fn find_by_name(&self, name: &str) -> Option<&StoryBibleItem> { + self.items_by_name.get(&name.to_lowercase()) + } + + /// Find all items matching a partial name. + pub fn find_by_partial_name(&self, query: &str) -> Vec<&StoryBibleItem> { + let query_lower = query.to_lowercase(); + self.items_by_name + .values() + .filter(|item| item.name.to_lowercase().contains(&query_lower)) + .collect() + } + + /// Get all items in the index. + pub fn all_items(&self) -> Vec<&StoryBibleItem> { + self.items_by_id.values().collect() + } +} + +/// Extract Story Bible item information from a file. +fn extract_story_bible_item(relative_path: &str, content: &str) -> Option { + // Try to extract from YAML frontmatter first + if let Some(item) = extract_from_frontmatter(relative_path, content) { + return Some(item); + } + + // Fallback: extract from filename + let path_obj = Path::new(relative_path); + let filename = path_obj.file_stem().and_then(|s| s.to_str())?; + + // Determine type from directory structure + let item_type = path_obj + .parent() + .and_then(|p| p.to_str()) + .map(|s| s.to_lowercase()) + .unwrap_or_else(|| "unknown".to_string()); + + // Use filename as name + let name = filename.replace('_', " ").replace('-', " "); + + Some(StoryBibleItem { + path: relative_path.to_string(), + name: name.clone(), + id: name.to_lowercase().replace(' ', "_"), + item_type, + definition_line: 0, + }) +} + +/// Extract item information from YAML frontmatter. +fn extract_from_frontmatter(relative_path: &str, content: &str) -> Option { + let lines: Vec<&str> = content.lines().collect(); + + // Find frontmatter boundaries + let start = lines.iter().position(|line| line.trim() == "---")?; + let end = lines[start + 1..] + .iter() + .position(|line| line.trim() == "---") + .map(|pos| start + 1 + pos)?; + + if end <= start + 1 { + return None; + } + + let mut name: Option = None; + let mut id: Option = None; + let mut item_type: Option = None; + + for line in &lines[start + 1..end] { + let trimmed = line.trim(); + if trimmed.starts_with("title:") { + name = Some(trimmed["title:".len()..].trim().trim_matches('"').to_string()); + } else if trimmed.starts_with("id:") { + id = Some(trimmed["id:".len()..].trim().trim_matches('"').to_string()); + } else if trimmed.starts_with("type:") { + item_type = Some(trimmed["type:".len()..].trim().trim_matches('"').to_string()); + } + } + + // Use name or filename as fallback + let name = name.unwrap_or_else(|| { + Path::new(relative_path) + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.replace('_', " ").replace('-', " ")) + .unwrap_or_else(|| "Unknown".to_string()) + }); + + // Use ID or sanitized name as fallback + let id = id.unwrap_or_else(|| { + name.to_lowercase().replace(' ', "_").replace('-', "_") + }); + + // Use type from frontmatter or directory + let item_type = item_type.unwrap_or_else(|| { + Path::new(relative_path) + .parent() + .and_then(|p| p.to_str()) + .map(|s| { + let dir_name = s.to_lowercase(); + if dir_name.contains("character") || dir_name.contains("cast") { + "character".to_string() + } else if dir_name.contains("location") || dir_name.contains("setting") { + "location".to_string() + } else if dir_name.contains("lore") || dir_name.contains("world") { + "lore".to_string() + } else { + dir_name + } + }) + .unwrap_or_else(|| "unknown".to_string()) + }); + + Some(StoryBibleItem { + path: relative_path.to_string(), + name, + id, + item_type, + definition_line: start + 1, + }) +} + +/// Find all Story Bible references in a text. +/// +/// Looks for patterns like `[[Character Name]]` or `@CharacterName`. +pub fn find_references(text: &str, index: &StoryBibleIndex) -> Vec { + let mut references = Vec::new(); + + for (line_idx, line) in text.lines().enumerate() { + let line_num = line_idx + 1; + let mut pos = 0; + + while pos < line.len() { + // Check for wiki-style links: [[...]] + if line[pos..].starts_with("[[") { + let end_pos = line[pos + 2..] + .find("]]") + .map(|p| pos + 2 + p + 2); + if let Some(end) = end_pos { + let identifier = line[pos + 2..end - 2].trim().to_string(); + // Check if this matches any Story Bible item + if index.find_by_id(&identifier).is_some() + || index.find_by_name(&identifier).is_some() + { + references.push(StoryBibleReference { + file_path: String::new(), // Will be set by caller + line_number: line_num, + column: pos, + length: end - pos, + identifier: identifier.clone(), + }); + } + pos = end; + continue; + } + } + + // Check for mention-style: @Name + if line[pos..].starts_with('@') && pos + 1 < line.len() { + // Extract word after @ + let word_end = line[pos + 1..] + .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-' && c != ' ') + .map(|p| pos + 1 + p) + .unwrap_or(line.len()); + let identifier = line[pos + 1..word_end].trim().to_string(); + if !identifier.is_empty() + && (index.find_by_id(&identifier).is_some() + || index.find_by_name(&identifier).is_some()) + { + references.push(StoryBibleReference { + file_path: String::new(), + line_number: line_num, + column: pos, + length: word_end - pos, + identifier, + }); + } + pos = word_end; + continue; + } + + pos += 1; + } + } + + references +} + +/// Generate a markdown link to a Story Bible item. +pub fn link_to_item(item: &StoryBibleItem, display_text: Option<&str>) -> String { + let text = display_text.unwrap_or(&item.name); + format!("[{}]({})", text, item.path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_from_frontmatter() { + let content = r#"--- +id: character_01 +title: "John Doe" +type: character +--- + +Some content here."#; + let item = extract_from_frontmatter("test.md", content).unwrap(); + assert_eq!(item.id, "character_01"); + assert_eq!(item.name, "John Doe"); + assert_eq!(item.item_type, "character"); + } + + #[test] + fn test_find_references_wiki_links() { + let index = { + let mut idx = StoryBibleIndex::new(); + idx.items_by_id.insert( + "john_doe".to_string(), + StoryBibleItem { + path: "Characters/john_doe.md".to_string(), + name: "John Doe".to_string(), + id: "john_doe".to_string(), + item_type: "character".to_string(), + definition_line: 0, + }, + ); + idx.items_by_name.insert( + "john doe".to_string(), + StoryBibleItem { + path: "Characters/john_doe.md".to_string(), + name: "John Doe".to_string(), + id: "john_doe".to_string(), + item_type: "character".to_string(), + definition_line: 0, + }, + ); + idx + }; + + let refs = find_references("Some text with [[John Doe]] here.", &index); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].identifier, "John Doe"); + } + + #[test] + fn test_find_references_mentions() { + let index = { + let mut idx = StoryBibleIndex::new(); + idx.items_by_name.insert( + "jane".to_string(), + StoryBibleItem { + path: "Characters/jane.md".to_string(), + name: "Jane".to_string(), + id: "jane".to_string(), + item_type: "character".to_string(), + definition_line: 0, + }, + ); + idx + }; + + let refs = find_references("Some text mentioning @Jane here.", &index); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].identifier, "Jane"); + } +}