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:
@@ -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…)
|
||||||
|
|
||||||
|
|||||||
+219
-17
@@ -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(),
|
path: "Characters/jane.md".to_string(),
|
||||||
StoryBibleItem {
|
name: "Jane".to_string(),
|
||||||
path: "Characters/jane.md".to_string(),
|
id: "jane".to_string(),
|
||||||
name: "Jane".to_string(),
|
item_type: "character".to_string(),
|
||||||
id: "jane".to_string(),
|
aliases: Vec::new(),
|
||||||
item_type: "character".to_string(),
|
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"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user