Auto-link Story Bible mentions in the draft, with a hover card to the entry
Bare proper nouns in prose ("Bixby walked in") now resolve against the
Story Bible automatically, the same as the existing [[wiki-links]] and
@mentions markup — no manual linking required. Matching is case-sensitive
and requires a capitalized, 3+ character match to a sheet's name or alias,
which is what keeps a character named "Hope" or "Will" from lighting up
on every unrelated use of the word; the longest phrase wins an overlap
("Bixby Reynolds" over "Bixby"), and a bare match never overrides the
author's own explicit markup.
Hovering a highlighted mention shows a card with the item's name, type
(Person/Place/Thing) and a summary (from a new summary:/description:
frontmatter field), with a link that opens the entry in the Story Bible
editor. The card appears after a short delay and stays open while the
pointer crosses onto it to reach the link.
Along the way:
- StoryBibleReference now carries absolute byte offsets (start/end) and
the resolved item_id, replacing column/length, which were actually
per-line and were being used as whole-buffer offsets — every reference
past a file's first line highlighted and jumped-to in the wrong place.
find_references() also now walks the buffer one char at a time rather
than one byte, which could previously land mid-character and panic on
any manuscript with accented letters or an em dash.
- build_story_bible_index() had its own, separately hardcoded list of
Story Bible directory names that had drifted from
find_story_bible_directory()'s list and no longer included
03-Story-Bible, the bundled template's actual folder — so the index,
and everything built on it, was silently empty for every project made
from that template. It now reuses the one list.
- Story Bible Editor window: its tree and content ScrollAreas (and the
content TextEdit) had no id_salt, so egui treated them as the same
widget — fixed with explicit salts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+291
-61
@@ -14,11 +14,13 @@ pub struct StoryBibleReference {
|
||||
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.
|
||||
/// Byte offset of the reference's start in the whole buffer.
|
||||
pub start: usize,
|
||||
/// Byte offset just past the reference's end in the whole buffer.
|
||||
pub end: usize,
|
||||
/// The id of the Story Bible item this reference resolves to.
|
||||
pub item_id: String,
|
||||
/// The matched text, exactly as it appears in the draft.
|
||||
pub identifier: String,
|
||||
}
|
||||
|
||||
@@ -37,6 +39,9 @@ pub struct StoryBibleItem {
|
||||
pub aliases: Vec<String>,
|
||||
/// Line in the file where the name/ID is defined.
|
||||
pub definition_line: usize,
|
||||
/// A short summary for the hover card, from a `summary:`/`description:`
|
||||
/// frontmatter field. `None` when the sheet doesn't have one.
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
/// Index of all Story Bible items for quick lookup.
|
||||
@@ -176,6 +181,7 @@ fn extract_story_bible_item(relative_path: &str, content: &str) -> Option<StoryB
|
||||
item_type,
|
||||
aliases: Vec::new(),
|
||||
definition_line: 0,
|
||||
summary: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -198,6 +204,8 @@ fn extract_from_frontmatter(relative_path: &str, content: &str) -> Option<StoryB
|
||||
let mut id: Option<String> = None;
|
||||
let mut item_type: Option<String> = None;
|
||||
let mut aliases: Vec<String> = Vec::new();
|
||||
let mut summary: Option<String> = None;
|
||||
let mut description: Option<String> = None;
|
||||
|
||||
for line in &lines[start + 1..end] {
|
||||
let trimmed = line.trim();
|
||||
@@ -211,8 +219,20 @@ fn extract_from_frontmatter(relative_path: &str, content: &str) -> Option<StoryB
|
||||
// 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);
|
||||
} else if trimmed.starts_with("summary:") {
|
||||
let value = trimmed["summary:".len()..].trim().trim_matches('"').trim();
|
||||
if !value.is_empty() {
|
||||
summary = Some(value.to_string());
|
||||
}
|
||||
} else if trimmed.starts_with("description:") {
|
||||
let value = trimmed["description:".len()..].trim().trim_matches('"').trim();
|
||||
if !value.is_empty() {
|
||||
description = Some(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
// `summary:` wins if a sheet somehow has both.
|
||||
let summary = summary.or(description);
|
||||
|
||||
// Use name or filename as fallback
|
||||
let name = name.unwrap_or_else(|| {
|
||||
@@ -255,6 +275,7 @@ fn extract_from_frontmatter(relative_path: &str, content: &str) -> Option<StoryB
|
||||
item_type,
|
||||
aliases,
|
||||
definition_line: start + 1,
|
||||
summary,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -324,72 +345,141 @@ fn parse_aliases(value: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Find all Story Bible references in a text.
|
||||
/// Find all Story Bible references in `text`, absolute byte offsets into the
|
||||
/// whole buffer (not per-line), so callers can map them straight onto a galley.
|
||||
///
|
||||
/// Looks for patterns like `[[Character Name]]` or `@CharacterName`.
|
||||
/// Two kinds, in priority order — an overlap always keeps the higher one:
|
||||
///
|
||||
/// 1. **Explicit markup** — `[[Name]]` or `@Name`, resolved by ID, name or
|
||||
/// alias. The author asked for this link by hand, so it always wins.
|
||||
/// 2. **Bare mentions** — a known item's name or an alias, spelled exactly as
|
||||
/// it appears in its Story Bible sheet, found in ordinary prose with no
|
||||
/// markup at all. This is what makes the linking automatic: plan the cast
|
||||
/// once in the Story Bible, then just write their names.
|
||||
///
|
||||
/// Bare matching is case-sensitive and requires the candidate to start with
|
||||
/// an uppercase letter — names are written that way both in the sheet and as
|
||||
/// proper nouns in prose, and that's what keeps a character named "Hope" or
|
||||
/// "Will" from lighting up on every unrelated use of the word. A phrase under
|
||||
/// three characters is skipped too, since a two-letter alias (initials, say)
|
||||
/// matches too much of everyday prose to be worth auto-detecting; it still
|
||||
/// works through the explicit `[[...]]`/`@` markup above.
|
||||
pub fn find_references(text: &str, index: &StoryBibleIndex) -> Vec<StoryBibleReference> {
|
||||
let mut references = Vec::new();
|
||||
// (start, end, item_id, identifier) — accepted spans, explicit first.
|
||||
let mut accepted: Vec<(usize, usize, String, String)> = 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 (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
|
||||
line_number: line_num,
|
||||
column: pos,
|
||||
length: end - pos,
|
||||
identifier: identifier.clone(),
|
||||
});
|
||||
}
|
||||
pos = end;
|
||||
continue;
|
||||
// 1. Explicit [[...]] and @Name markup. Walked one *char* at a time
|
||||
// (manuscript prose is full of accented letters and em dashes, and a
|
||||
// byte-at-a-time walk can land mid-character and panic on the next slice).
|
||||
let mut pos = 0usize;
|
||||
while pos < text.len() {
|
||||
if text[pos..].starts_with("[[") {
|
||||
// `]]` only counts on the same line — an unterminated `[[` should
|
||||
// not swallow the rest of the document looking for a closer.
|
||||
let line_end = text[pos..]
|
||||
.find('\n')
|
||||
.map(|o| pos + o)
|
||||
.unwrap_or(text.len());
|
||||
if let Some(rel_end) = text[pos + 2..line_end].find("]]") {
|
||||
let end = pos + 2 + rel_end + 2;
|
||||
let identifier = text[pos + 2..end - 2].trim().to_string();
|
||||
if let Some(item) = resolve(index, &identifier) {
|
||||
accepted.push((pos, end, item.id.clone(), identifier));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for mention-style: @Name
|
||||
if line[pos..].starts_with('@') && pos + 1 < line.len() {
|
||||
// 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 != '-')
|
||||
.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_alias(&identifier).is_some())
|
||||
{
|
||||
references.push(StoryBibleReference {
|
||||
file_path: String::new(),
|
||||
line_number: line_num,
|
||||
column: pos,
|
||||
length: word_end - pos,
|
||||
identifier,
|
||||
});
|
||||
}
|
||||
pos = word_end;
|
||||
pos = end;
|
||||
continue;
|
||||
}
|
||||
} else if text.as_bytes()[pos] == b'@' && pos + 1 < text.len() {
|
||||
let rest = &text[pos + 1..];
|
||||
let word_end = pos
|
||||
+ 1
|
||||
+ rest
|
||||
.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
|
||||
.unwrap_or(rest.len());
|
||||
let identifier = text[pos + 1..word_end].to_string();
|
||||
if !identifier.is_empty() {
|
||||
if let Some(item) = resolve(index, &identifier) {
|
||||
accepted.push((pos, word_end, item.id.clone(), identifier));
|
||||
}
|
||||
}
|
||||
pos = word_end;
|
||||
continue;
|
||||
}
|
||||
pos += text[pos..].chars().next().map(char::len_utf8).unwrap_or(1);
|
||||
}
|
||||
|
||||
pos += 1;
|
||||
// 2. Bare mentions. Every item's name and aliases, longest phrase first
|
||||
// so "Bixby Reynolds" claims its span before "Bixby" alone gets a chance.
|
||||
let mut candidates: Vec<(&str, &str)> = Vec::new(); // (phrase, item_id)
|
||||
for item in index.items_by_id.values() {
|
||||
candidates.push((item.name.as_str(), item.id.as_str()));
|
||||
for alias in &item.aliases {
|
||||
candidates.push((alias.as_str(), item.id.as_str()));
|
||||
}
|
||||
}
|
||||
candidates.retain(|(phrase, _)| {
|
||||
phrase.len() >= 3 && phrase.chars().next().is_some_and(char::is_uppercase)
|
||||
});
|
||||
candidates.sort_by_key(|(phrase, _)| std::cmp::Reverse(phrase.len()));
|
||||
|
||||
let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
|
||||
for (phrase, item_id) in candidates {
|
||||
for (start, matched) in text.match_indices(phrase) {
|
||||
let end = start + matched.len();
|
||||
let before_ok = text[..start]
|
||||
.chars()
|
||||
.next_back()
|
||||
.map_or(true, |c| !is_word_char(c));
|
||||
let after_ok = text[end..].chars().next().map_or(true, |c| !is_word_char(c));
|
||||
if !before_ok || !after_ok {
|
||||
continue;
|
||||
}
|
||||
if accepted.iter().any(|&(s, e, ..)| start < e && end > s) {
|
||||
continue; // overlaps an explicit link or a longer bare match
|
||||
}
|
||||
accepted.push((start, end, item_id.to_string(), phrase.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
references
|
||||
accepted.sort_by_key(|&(start, ..)| start);
|
||||
|
||||
let line_starts = line_start_offsets(text);
|
||||
accepted
|
||||
.into_iter()
|
||||
.map(|(start, end, item_id, identifier)| StoryBibleReference {
|
||||
file_path: String::new(), // set by the caller
|
||||
line_number: line_number_at(&line_starts, start),
|
||||
start,
|
||||
end,
|
||||
item_id,
|
||||
identifier,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Look up an identifier by ID, then name, then alias — whichever matches
|
||||
/// first, the same fallback order the old separate call sites each used.
|
||||
fn resolve<'a>(index: &'a StoryBibleIndex, identifier: &str) -> Option<&'a StoryBibleItem> {
|
||||
index
|
||||
.find_by_id(identifier)
|
||||
.or_else(|| index.find_by_name(identifier))
|
||||
.or_else(|| index.find_by_alias(identifier))
|
||||
}
|
||||
|
||||
/// Byte offset of the start of each line, `text[0]` included, in order.
|
||||
fn line_start_offsets(text: &str) -> Vec<usize> {
|
||||
std::iter::once(0)
|
||||
.chain(text.match_indices('\n').map(|(i, _)| i + 1))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 1-indexed line number containing `byte_offset`, from a table built by
|
||||
/// [`line_start_offsets`].
|
||||
fn line_number_at(line_starts: &[usize], byte_offset: usize) -> usize {
|
||||
match line_starts.binary_search(&byte_offset) {
|
||||
Ok(i) => i + 1,
|
||||
Err(i) => i,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a markdown link to a Story Bible item.
|
||||
@@ -398,6 +488,25 @@ pub fn link_to_item(item: &StoryBibleItem, display_text: Option<&str>) -> String
|
||||
format!("[{}]({})", text, item.path)
|
||||
}
|
||||
|
||||
/// A short, reader-friendly label for an item's type, for a hover card or
|
||||
/// list row — "Person" for a character sheet, "Place" for a location, and
|
||||
/// Title Case of anything else (lore, object, theme, …), since those don't
|
||||
/// reduce to one generic word.
|
||||
pub fn type_label(item_type: &str) -> String {
|
||||
match item_type.to_lowercase().as_str() {
|
||||
"character" | "characters" => "Person".to_string(),
|
||||
"location" | "locations" => "Place".to_string(),
|
||||
"object" | "objects" => "Thing".to_string(),
|
||||
other => {
|
||||
let mut chars = other.chars();
|
||||
match chars.next() {
|
||||
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
None => "Entry".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -417,6 +526,43 @@ Some content here."#;
|
||||
assert_eq!(item.item_type, "character");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_summary_feeds_the_hover_card() {
|
||||
let content = r#"---
|
||||
id: character_01
|
||||
title: "John Doe"
|
||||
type: character
|
||||
summary: A retired locksmith with one unfinished job.
|
||||
---
|
||||
|
||||
Some content here."#;
|
||||
let item = extract_from_frontmatter("test.md", content).unwrap();
|
||||
assert_eq!(
|
||||
item.summary.as_deref(),
|
||||
Some("A retired locksmith with one unfinished job.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_falls_back_to_description_and_prefers_summary() {
|
||||
let description_only = r#"---
|
||||
title: "Jane"
|
||||
description: Appears in Act 2.
|
||||
---
|
||||
"#;
|
||||
let item = extract_from_frontmatter("test.md", description_only).unwrap();
|
||||
assert_eq!(item.summary.as_deref(), Some("Appears in Act 2."));
|
||||
|
||||
let both = r#"---
|
||||
title: "Jane"
|
||||
summary: The short version.
|
||||
description: The long version.
|
||||
---
|
||||
"#;
|
||||
let item = extract_from_frontmatter("test.md", both).unwrap();
|
||||
assert_eq!(item.summary.as_deref(), Some("The short version."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_references_wiki_links() {
|
||||
let index = {
|
||||
@@ -430,6 +576,7 @@ Some content here."#;
|
||||
item_type: "character".to_string(),
|
||||
aliases: Vec::new(),
|
||||
definition_line: 0,
|
||||
summary: None,
|
||||
},
|
||||
);
|
||||
idx.items_by_name.insert(
|
||||
@@ -441,6 +588,7 @@ Some content here."#;
|
||||
item_type: "character".to_string(),
|
||||
aliases: Vec::new(),
|
||||
definition_line: 0,
|
||||
summary: None,
|
||||
},
|
||||
);
|
||||
idx
|
||||
@@ -462,6 +610,7 @@ Some content here."#;
|
||||
item_type: "character".to_string(),
|
||||
aliases: Vec::new(),
|
||||
definition_line: 0,
|
||||
summary: None,
|
||||
};
|
||||
idx.items_by_name.insert("jane".to_string(), item.clone());
|
||||
idx.items_by_id.insert("jane".to_string(), item);
|
||||
@@ -483,6 +632,7 @@ Some content here."#;
|
||||
item_type: "character".to_string(),
|
||||
aliases: vec!["Johnny".to_string(), "JS".to_string()],
|
||||
definition_line: 0,
|
||||
summary: None,
|
||||
};
|
||||
index.items_by_id.insert("john_smith".to_string(), item.clone());
|
||||
index.items_by_name.insert("john smith".to_string(), item.clone());
|
||||
@@ -505,6 +655,7 @@ Some content here."#;
|
||||
item_type: "character".to_string(),
|
||||
aliases: vec!["Johnny".to_string(), "JS".to_string()],
|
||||
definition_line: 0,
|
||||
summary: None,
|
||||
};
|
||||
index.items_by_name.insert("john smith".to_string(), item);
|
||||
|
||||
@@ -527,6 +678,7 @@ Some content here."#;
|
||||
item_type: "character".to_string(),
|
||||
aliases: vec!["Johnny".to_string()],
|
||||
definition_line: 0,
|
||||
summary: None,
|
||||
};
|
||||
index.items_by_name.insert("john smith".to_string(), item.clone());
|
||||
index.items_by_id.insert("john_smith".to_string(), item.clone());
|
||||
@@ -541,6 +693,84 @@ Some content here."#;
|
||||
assert_eq!(refs[0].identifier, "Johnny");
|
||||
}
|
||||
|
||||
/// A one-item index for the bare-mention tests below, keyed by name and
|
||||
/// (if given) aliases — the same shape a real `StoryBibleIndex::build`
|
||||
/// produces for a single character sheet.
|
||||
fn index_with(id: &str, name: &str, aliases: &[&str]) -> StoryBibleIndex {
|
||||
let item = StoryBibleItem {
|
||||
path: format!("Characters/{id}.md"),
|
||||
name: name.to_string(),
|
||||
id: id.to_string(),
|
||||
item_type: "character".to_string(),
|
||||
aliases: aliases.iter().map(|a| a.to_string()).collect(),
|
||||
definition_line: 0,
|
||||
summary: None,
|
||||
};
|
||||
let mut index = StoryBibleIndex::new();
|
||||
index.items_by_id.insert(id.to_string(), item.clone());
|
||||
index.items_by_name.insert(name.to_lowercase(), item.clone());
|
||||
for alias in aliases {
|
||||
index.items_by_alias.insert(alias.to_lowercase(), item.clone());
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_mention_is_found_without_any_markup() {
|
||||
let index = index_with("bixby", "Bixby", &[]);
|
||||
let refs = find_references("Bixby walked into the room.", &index);
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].identifier, "Bixby");
|
||||
assert_eq!(refs[0].item_id, "bixby");
|
||||
assert_eq!(&"Bixby walked into the room."[refs[0].start..refs[0].end], "Bixby");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_mention_respects_word_boundaries() {
|
||||
let index = index_with("bixby", "Bixby", &[]);
|
||||
let refs = find_references("Bixbys and Bixbyish things aren't Bixby.", &index);
|
||||
assert_eq!(refs.len(), 1, "only the standalone \"Bixby\" should match");
|
||||
assert_eq!(refs[0].start, 34);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_mention_is_case_sensitive_to_dodge_common_words() {
|
||||
// A character named "Hope" must not light up every ordinary use of
|
||||
// the word "hope" — only the capitalized, name-spelled uses.
|
||||
let index = index_with("hope", "Hope", &[]);
|
||||
let refs = find_references("She had no hope left, until Hope arrived.", &index);
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].start, 28);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_longest_bare_mention_wins_an_overlap() {
|
||||
let index = index_with("bixby_reynolds", "Bixby Reynolds", &["Bixby"]);
|
||||
let refs = find_references("Bixby Reynolds frowned.", &index);
|
||||
assert_eq!(refs.len(), 1, "the full name should claim the span, not just \"Bixby\"");
|
||||
assert_eq!(refs[0].identifier, "Bixby Reynolds");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_mention_does_not_duplicate_an_explicit_link() {
|
||||
let index = index_with("bixby", "Bixby", &[]);
|
||||
let refs = find_references("Meet [[Bixby]] properly.", &index);
|
||||
assert_eq!(refs.len(), 1, "the explicit [[...]] span should win, not add a second match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_offsets_are_absolute_into_the_whole_buffer() {
|
||||
// A regression check for the bug where offsets were relative to each
|
||||
// line, so anything past line 1 highlighted (and jumped to) the wrong
|
||||
// place in the editor.
|
||||
let index = index_with("bixby", "Bixby", &[]);
|
||||
let text = "First line.\nSecond line has Bixby in it.";
|
||||
let refs = find_references(text, &index);
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(&text[refs[0].start..refs[0].end], "Bixby");
|
||||
assert_eq!(refs[0].line_number, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_aliases_yaml_list() {
|
||||
let aliases = parse_aliases(r#"["Alias 1", "Alias 2", "Alias 3"]"#);
|
||||
|
||||
Reference in New Issue
Block a user