feat: Add aliases support to Story Bible for flexible linking

- Add aliases field to StoryBibleItem struct
- Add items_by_alias HashMap to StoryBibleIndex for quick alias lookup
- Add find_by_alias() method and update find_by_partial_name() to search aliases
- Parse aliases from YAML frontmatter (list and comma-separated formats)
- Update find_references() to match aliases in addition to IDs and names
- Fix @mention pattern matching to properly stop at spaces
- Add comprehensive tests for aliases functionality
- Update README.md with aliases documentation and usage examples

This allows Story Bible entries to have alternative names that can be
referenced in draft text using wiki-style [[...]] or mention-style @ syntax.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
landon
2026-09-16 14:18:57 -05:00
parent a0d1c3c206
commit 0583bc077c
2 changed files with 236 additions and 20 deletions
+17 -3
View File
@@ -383,13 +383,27 @@ location, lore, and other reference directories. It supports:
* **Purple background highlighting** of Story Bible references in the editor * **Purple background highlighting** of Story Bible references in the editor
* Click items in the Story Bible window to see all references to them in the current file * Click items in the Story Bible window to see all references to them in the current file
* Click reference buttons to jump directly to that location in the editor * Click reference buttons to jump directly to that location in the editor
* Metadata extraction from YAML frontmatter (id, title, type fields) or from filenames * Metadata extraction from YAML frontmatter (id, title, type, **aliases** fields) or from filenames
* Case-insensitive search and lookup * Case-insensitive search and lookup
* **Aliases support**: References match item IDs, names, **and aliases** for flexible linking
Character sheets in the `Characters` folder are automatically indexed, and any Character sheets in the `Characters` folder are automatically indexed, and any
markdown file with YAML frontmatter containing `id:` and `title:` fields will be markdown file with YAML frontmatter containing `id:` and `title:` fields will be
included in the index. References in your draft text that match Story Bible items included in the index. You can also add an `aliases:` field with alternative names
are highlighted and clickable. for a character or item:
```yaml
---
id: john_smith
title: "John Smith"
type: character
aliases: ["Johnny", "JS"]
---
```
With aliases defined, references like `[[Johnny]]` or `@JS` will match the "John Smith"
character entry. References in your draft text that match Story Bible items
by ID, name, or alias are highlighted and clickable.
### Story Bible Editor (Edit ▸ Story Bible…) ### Story Bible Editor (Edit ▸ Story Bible…)
+214 -12
View File
@@ -33,6 +33,8 @@ pub struct StoryBibleItem {
pub id: String, pub id: String,
/// The item's type (character, location, lore, etc.). /// The item's type (character, location, lore, etc.).
pub item_type: String, pub item_type: String,
/// Alternative names for this item (from aliases field in YAML frontmatter).
pub aliases: Vec<String>,
/// Line in the file where the name/ID is defined. /// Line in the file where the name/ID is defined.
pub definition_line: usize, pub definition_line: usize,
} }
@@ -44,6 +46,8 @@ pub struct StoryBibleIndex {
pub items_by_id: HashMap<String, StoryBibleItem>, pub items_by_id: HashMap<String, StoryBibleItem>,
/// Map from lowercase name to item info. /// Map from lowercase name to item info.
pub items_by_name: HashMap<String, StoryBibleItem>, pub items_by_name: HashMap<String, StoryBibleItem>,
/// Map from lowercase alias to item info.
pub items_by_alias: HashMap<String, StoryBibleItem>,
} }
impl StoryBibleIndex { impl StoryBibleIndex {
@@ -52,6 +56,7 @@ impl StoryBibleIndex {
Self { Self {
items_by_id: HashMap::new(), items_by_id: HashMap::new(),
items_by_name: HashMap::new(), items_by_name: HashMap::new(),
items_by_alias: HashMap::new(),
} }
} }
@@ -93,7 +98,13 @@ impl StoryBibleIndex {
// Index by name (lowercase for case-insensitive lookup) // Index by name (lowercase for case-insensitive lookup)
index index
.items_by_name .items_by_name
.insert(item.name.to_lowercase(), item); .insert(item.name.to_lowercase(), item.clone());
// Index by aliases (lowercase for case-insensitive lookup)
for alias in &item.aliases {
index
.items_by_alias
.insert(alias.to_lowercase(), item.clone());
}
} }
} }
} }
@@ -111,12 +122,23 @@ impl StoryBibleIndex {
self.items_by_name.get(&name.to_lowercase()) self.items_by_name.get(&name.to_lowercase())
} }
/// Find all items matching a partial name. /// Find an item by one of its aliases.
pub fn find_by_alias(&self, alias: &str) -> Option<&StoryBibleItem> {
self.items_by_alias.get(&alias.to_lowercase())
}
/// Find all items matching a partial name or alias.
pub fn find_by_partial_name(&self, query: &str) -> Vec<&StoryBibleItem> { pub fn find_by_partial_name(&self, query: &str) -> Vec<&StoryBibleItem> {
let query_lower = query.to_lowercase(); let query_lower = query.to_lowercase();
self.items_by_name self.items_by_name
.values() .values()
.filter(|item| item.name.to_lowercase().contains(&query_lower)) .filter(|item| {
item.name.to_lowercase().contains(&query_lower)
|| item
.aliases
.iter()
.any(|a| a.to_lowercase().contains(&query_lower))
})
.collect() .collect()
} }
@@ -152,6 +174,7 @@ fn extract_story_bible_item(relative_path: &str, content: &str) -> Option<StoryB
name: name.clone(), name: name.clone(),
id: name.to_lowercase().replace(' ', "_"), id: name.to_lowercase().replace(' ', "_"),
item_type, item_type,
aliases: Vec::new(),
definition_line: 0, definition_line: 0,
}) })
} }
@@ -174,6 +197,7 @@ fn extract_from_frontmatter(relative_path: &str, content: &str) -> Option<StoryB
let mut name: Option<String> = None; let mut name: Option<String> = None;
let mut id: Option<String> = None; let mut id: Option<String> = None;
let mut item_type: Option<String> = None; let mut item_type: Option<String> = None;
let mut aliases: Vec<String> = Vec::new();
for line in &lines[start + 1..end] { for line in &lines[start + 1..end] {
let trimmed = line.trim(); let trimmed = line.trim();
@@ -183,6 +207,10 @@ fn extract_from_frontmatter(relative_path: &str, content: &str) -> Option<StoryB
id = Some(trimmed["id:".len()..].trim().trim_matches('"').to_string()); id = Some(trimmed["id:".len()..].trim().trim_matches('"').to_string());
} else if trimmed.starts_with("type:") { } else if trimmed.starts_with("type:") {
item_type = Some(trimmed["type:".len()..].trim().trim_matches('"').to_string()); item_type = Some(trimmed["type:".len()..].trim().trim_matches('"').to_string());
} else if trimmed.starts_with("aliases:") {
// Parse aliases as a YAML list: aliases: ["Alias 1", "Alias 2"] or aliases: Alias1, Alias2
let value = trimmed["aliases:".len()..].trim();
aliases = parse_aliases(value);
} }
} }
@@ -225,10 +253,77 @@ fn extract_from_frontmatter(relative_path: &str, content: &str) -> Option<StoryB
name, name,
id, id,
item_type, item_type,
aliases,
definition_line: start + 1, definition_line: start + 1,
}) })
} }
/// Parse aliases from YAML frontmatter value.
/// Supports both list syntax: aliases: ["Alias 1", "Alias 2"]
/// and comma-separated: aliases: Alias1, Alias2
fn parse_aliases(value: &str) -> Vec<String> {
if value.is_empty() {
return Vec::new();
}
// Check if it's a YAML list: ["...", "..."]
if value.trim().starts_with('[') && value.trim().ends_with(']') {
let inner = value.trim()[1..value.trim().len() - 1].trim();
// Parse items from the list
let mut aliases = Vec::new();
let mut in_quote = false;
let mut quote_char = '\0';
let mut current = String::new();
for c in inner.chars() {
match c {
'"' | '\'' => {
if in_quote && c == quote_char {
in_quote = false;
quote_char = '\0';
if !current.is_empty() {
aliases.push(current.clone());
current.clear();
}
} else if !in_quote {
in_quote = true;
quote_char = c;
}
}
',' => {
if !in_quote {
if !current.is_empty() {
aliases.push(current.clone());
current.clear();
}
} else {
current.push(c);
}
}
_ => {
if in_quote {
current.push(c);
}
}
}
}
// Add the last item
if !current.is_empty() {
aliases.push(current);
}
return aliases;
}
// Comma-separated format
value
.split(',')
.map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
.filter(|s| !s.is_empty())
.collect()
}
/// Find all Story Bible references in a text. /// Find all Story Bible references in a text.
/// ///
/// Looks for patterns like `[[Character Name]]` or `@CharacterName`. /// Looks for patterns like `[[Character Name]]` or `@CharacterName`.
@@ -247,9 +342,10 @@ pub fn find_references(text: &str, index: &StoryBibleIndex) -> Vec<StoryBibleRef
.map(|p| pos + 2 + p + 2); .map(|p| pos + 2 + p + 2);
if let Some(end) = end_pos { if let Some(end) = end_pos {
let identifier = line[pos + 2..end - 2].trim().to_string(); let identifier = line[pos + 2..end - 2].trim().to_string();
// Check if this matches any Story Bible item // Check if this matches any Story Bible item (by ID, name, or alias)
if index.find_by_id(&identifier).is_some() if index.find_by_id(&identifier).is_some()
|| index.find_by_name(&identifier).is_some() || index.find_by_name(&identifier).is_some()
|| index.find_by_alias(&identifier).is_some()
{ {
references.push(StoryBibleReference { references.push(StoryBibleReference {
file_path: String::new(), // Will be set by caller file_path: String::new(), // Will be set by caller
@@ -266,15 +362,16 @@ pub fn find_references(text: &str, index: &StoryBibleIndex) -> Vec<StoryBibleRef
// Check for mention-style: @Name // Check for mention-style: @Name
if line[pos..].starts_with('@') && pos + 1 < line.len() { if line[pos..].starts_with('@') && pos + 1 < line.len() {
// Extract word after @ // Extract word after @ - stop at space or other non-word characters
let word_end = line[pos + 1..] let word_end = line[pos + 1..]
.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-' && c != ' ') .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
.map(|p| pos + 1 + p) .map(|p| pos + 1 + p)
.unwrap_or(line.len()); .unwrap_or(line.len());
let identifier = line[pos + 1..word_end].trim().to_string(); let identifier = line[pos + 1..word_end].trim().to_string();
if !identifier.is_empty() if !identifier.is_empty()
&& (index.find_by_id(&identifier).is_some() && (index.find_by_id(&identifier).is_some()
|| index.find_by_name(&identifier).is_some()) || index.find_by_name(&identifier).is_some()
|| index.find_by_alias(&identifier).is_some())
{ {
references.push(StoryBibleReference { references.push(StoryBibleReference {
file_path: String::new(), file_path: String::new(),
@@ -331,6 +428,7 @@ Some content here."#;
name: "John Doe".to_string(), name: "John Doe".to_string(),
id: "john_doe".to_string(), id: "john_doe".to_string(),
item_type: "character".to_string(), item_type: "character".to_string(),
aliases: Vec::new(),
definition_line: 0, definition_line: 0,
}, },
); );
@@ -341,6 +439,7 @@ Some content here."#;
name: "John Doe".to_string(), name: "John Doe".to_string(),
id: "john_doe".to_string(), id: "john_doe".to_string(),
item_type: "character".to_string(), item_type: "character".to_string(),
aliases: Vec::new(),
definition_line: 0, definition_line: 0,
}, },
); );
@@ -356,16 +455,16 @@ Some content here."#;
fn test_find_references_mentions() { fn test_find_references_mentions() {
let index = { let index = {
let mut idx = StoryBibleIndex::new(); let mut idx = StoryBibleIndex::new();
idx.items_by_name.insert( let item = StoryBibleItem {
"jane".to_string(),
StoryBibleItem {
path: "Characters/jane.md".to_string(), path: "Characters/jane.md".to_string(),
name: "Jane".to_string(), name: "Jane".to_string(),
id: "jane".to_string(), id: "jane".to_string(),
item_type: "character".to_string(), item_type: "character".to_string(),
aliases: Vec::new(),
definition_line: 0, definition_line: 0,
}, };
); idx.items_by_name.insert("jane".to_string(), item.clone());
idx.items_by_id.insert("jane".to_string(), item);
idx idx
}; };
@@ -373,4 +472,107 @@ Some content here."#;
assert_eq!(refs.len(), 1); assert_eq!(refs.len(), 1);
assert_eq!(refs[0].identifier, "Jane"); assert_eq!(refs[0].identifier, "Jane");
} }
#[test]
fn test_find_by_alias() {
let mut index = StoryBibleIndex::new();
let item = StoryBibleItem {
path: "Characters/john.md".to_string(),
name: "John Smith".to_string(),
id: "john_smith".to_string(),
item_type: "character".to_string(),
aliases: vec!["Johnny".to_string(), "JS".to_string()],
definition_line: 0,
};
index.items_by_id.insert("john_smith".to_string(), item.clone());
index.items_by_name.insert("john smith".to_string(), item.clone());
index.items_by_alias.insert("johnny".to_string(), item.clone());
index.items_by_alias.insert("js".to_string(), item);
assert!(index.find_by_alias("Johnny").is_some());
assert!(index.find_by_alias("JS").is_some());
assert!(index.find_by_alias("johnny").is_some());
assert!(index.find_by_alias("nonexistent").is_none());
}
#[test]
fn test_find_by_partial_name_with_aliases() {
let mut index = StoryBibleIndex::new();
let item = StoryBibleItem {
path: "Characters/john.md".to_string(),
name: "John Smith".to_string(),
id: "john_smith".to_string(),
item_type: "character".to_string(),
aliases: vec!["Johnny".to_string(), "JS".to_string()],
definition_line: 0,
};
index.items_by_name.insert("john smith".to_string(), item);
let results = index.find_by_partial_name("Johnny");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "John Smith");
let results = index.find_by_partial_name("JS");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "John Smith");
}
#[test]
fn test_find_references_with_aliases() {
let mut index = StoryBibleIndex::new();
let item = StoryBibleItem {
path: "Characters/john.md".to_string(),
name: "John Smith".to_string(),
id: "john_smith".to_string(),
item_type: "character".to_string(),
aliases: vec!["Johnny".to_string()],
definition_line: 0,
};
index.items_by_name.insert("john smith".to_string(), item.clone());
index.items_by_id.insert("john_smith".to_string(), item.clone());
index.items_by_alias.insert("johnny".to_string(), item);
let refs = find_references("Some text with [[Johnny]] here.", &index);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].identifier, "Johnny");
let refs = find_references("Some text with @Johnny here.", &index);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].identifier, "Johnny");
}
#[test]
fn test_parse_aliases_yaml_list() {
let aliases = parse_aliases(r#"["Alias 1", "Alias 2", "Alias 3"]"#);
assert_eq!(aliases, vec!["Alias 1", "Alias 2", "Alias 3"]);
}
#[test]
fn test_parse_aliases_comma_separated() {
let aliases = parse_aliases("Alias1, Alias2, Alias3");
assert_eq!(aliases, vec!["Alias1", "Alias2", "Alias3"]);
}
#[test]
fn test_parse_aliases_empty() {
let aliases = parse_aliases("");
assert!(aliases.is_empty());
}
#[test]
fn test_extract_from_frontmatter_with_aliases() {
let content = r#"---
id: character_01
title: "John Doe"
type: character
aliases: ["Johnny", "JS"]
---
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");
assert_eq!(item.aliases, vec!["Johnny", "JS"]);
}
} }