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:
+219
-17
@@ -33,6 +33,8 @@ pub struct StoryBibleItem {
|
||||
pub id: String,
|
||||
/// The item's type (character, location, lore, etc.).
|
||||
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.
|
||||
pub definition_line: usize,
|
||||
}
|
||||
@@ -44,6 +46,8 @@ pub struct StoryBibleIndex {
|
||||
pub items_by_id: HashMap<String, StoryBibleItem>,
|
||||
/// Map from lowercase name to item info.
|
||||
pub items_by_name: HashMap<String, StoryBibleItem>,
|
||||
/// Map from lowercase alias to item info.
|
||||
pub items_by_alias: HashMap<String, StoryBibleItem>,
|
||||
}
|
||||
|
||||
impl StoryBibleIndex {
|
||||
@@ -52,6 +56,7 @@ impl StoryBibleIndex {
|
||||
Self {
|
||||
items_by_id: 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
|
||||
.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())
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let query_lower = query.to_lowercase();
|
||||
self.items_by_name
|
||||
.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()
|
||||
}
|
||||
|
||||
@@ -152,6 +174,7 @@ fn extract_story_bible_item(relative_path: &str, content: &str) -> Option<StoryB
|
||||
name: name.clone(),
|
||||
id: name.to_lowercase().replace(' ', "_"),
|
||||
item_type,
|
||||
aliases: Vec::new(),
|
||||
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 id: Option<String> = None;
|
||||
let mut item_type: Option<String> = None;
|
||||
let mut aliases: Vec<String> = Vec::new();
|
||||
|
||||
for line in &lines[start + 1..end] {
|
||||
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());
|
||||
} else if trimmed.starts_with("type:") {
|
||||
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,
|
||||
id,
|
||||
item_type,
|
||||
aliases,
|
||||
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.
|
||||
///
|
||||
/// 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);
|
||||
if let Some(end) = end_pos {
|
||||
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()
|
||||
|| index.find_by_name(&identifier).is_some()
|
||||
|| index.find_by_alias(&identifier).is_some()
|
||||
{
|
||||
references.push(StoryBibleReference {
|
||||
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
|
||||
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..]
|
||||
.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-' && c != ' ')
|
||||
.find(|c: char| !c.is_alphanumeric() && 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())
|
||||
|| index.find_by_name(&identifier).is_some()
|
||||
|| index.find_by_alias(&identifier).is_some())
|
||||
{
|
||||
references.push(StoryBibleReference {
|
||||
file_path: String::new(),
|
||||
@@ -331,6 +428,7 @@ Some content here."#;
|
||||
name: "John Doe".to_string(),
|
||||
id: "john_doe".to_string(),
|
||||
item_type: "character".to_string(),
|
||||
aliases: Vec::new(),
|
||||
definition_line: 0,
|
||||
},
|
||||
);
|
||||
@@ -341,6 +439,7 @@ Some content here."#;
|
||||
name: "John Doe".to_string(),
|
||||
id: "john_doe".to_string(),
|
||||
item_type: "character".to_string(),
|
||||
aliases: Vec::new(),
|
||||
definition_line: 0,
|
||||
},
|
||||
);
|
||||
@@ -356,16 +455,16 @@ Some content here."#;
|
||||
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,
|
||||
},
|
||||
);
|
||||
let item = StoryBibleItem {
|
||||
path: "Characters/jane.md".to_string(),
|
||||
name: "Jane".to_string(),
|
||||
id: "jane".to_string(),
|
||||
item_type: "character".to_string(),
|
||||
aliases: Vec::new(),
|
||||
definition_line: 0,
|
||||
};
|
||||
idx.items_by_name.insert("jane".to_string(), item.clone());
|
||||
idx.items_by_id.insert("jane".to_string(), item);
|
||||
idx
|
||||
};
|
||||
|
||||
@@ -373,4 +472,107 @@ Some content here."#;
|
||||
assert_eq!(refs.len(), 1);
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user