feat: Implement bidirectional Story Bible links with draft text

- Add story_bible.rs module with StoryBibleIndex, StoryBibleItem, and
  StoryBibleReference types for indexing Story Bible content
- Support wiki-style [[...]] and mention-style @... syntax for references
- Highlight Story Bible references in editor with purple background
- Add Story Bible window (View > Tools > Story Bible) to browse items
- Show references from current draft file in Story Bible window
- Allow clicking references to jump to their location in the editor
- Auto-index Story Bible items from common directories (03-Characters,
  02-Story Bible, Characters, etc.)
- Extract metadata from YAML frontmatter or filenames
- Add walkdir dependency for directory scanning
- Fix pre-existing format string errors in workspace.rs

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
landon
2026-09-16 08:56:28 -05:00
parent 511a7588fa
commit ca3e476a14
9 changed files with 631 additions and 156 deletions
+376
View File
@@ -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<String, StoryBibleItem>,
/// Map from lowercase name to item info.
pub items_by_name: HashMap<String, StoryBibleItem>,
}
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<StoryBibleItem> {
// 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<StoryBibleItem> {
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<String> = None;
let mut id: Option<String> = None;
let mut item_type: Option<String> = 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<StoryBibleReference> {
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");
}
}