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:
Generated
+1
@@ -1861,6 +1861,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"spellbook",
|
"spellbook",
|
||||||
"ureq",
|
"ureq",
|
||||||
|
"walkdir",
|
||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ serde_json = "1"
|
|||||||
pulldown-cmark = { version = "0.12", default-features = false }
|
pulldown-cmark = { version = "0.12", default-features = false }
|
||||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
|
walkdir = "2"
|
||||||
# Native open/save dialogs via the XDG Desktop Portal (no GTK build dependency).
|
# Native open/save dialogs via the XDG Desktop Portal (no GTK build dependency).
|
||||||
rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "async-std"] }
|
rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "async-std"] }
|
||||||
# Blocking HTTP client for talking to a LanguageTool server. The rustls TLS
|
# Blocking HTTP client for talking to a LanguageTool server. The rustls TLS
|
||||||
|
|||||||
+41
-1
@@ -43,6 +43,13 @@ impl App {
|
|||||||
.id_salt("editor")
|
.id_salt("editor")
|
||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
|
// Prepare Story Bible reference ranges for highlighting
|
||||||
|
let bible_ranges: Vec<(usize, usize)> = self
|
||||||
|
.current_references
|
||||||
|
.iter()
|
||||||
|
.map(|r| (r.column, r.column + r.length))
|
||||||
|
.collect();
|
||||||
|
|
||||||
let mut layouter = move |ui: &egui::Ui, text: &str, wrap_width: f32| {
|
let mut layouter = move |ui: &egui::Ui, text: &str, wrap_width: f32| {
|
||||||
let job = build_editor_job(
|
let job = build_editor_job(
|
||||||
text,
|
text,
|
||||||
@@ -51,6 +58,7 @@ impl App {
|
|||||||
&ranges,
|
&ranges,
|
||||||
&find_ranges,
|
&find_ranges,
|
||||||
find_active,
|
find_active,
|
||||||
|
&bible_ranges,
|
||||||
wrap_width,
|
wrap_width,
|
||||||
);
|
);
|
||||||
ui.fonts(|f| f.layout_job(job))
|
ui.fonts(|f| f.layout_job(job))
|
||||||
@@ -105,6 +113,8 @@ impl App {
|
|||||||
self.find_needs_refresh = true;
|
self.find_needs_refresh = true;
|
||||||
self.spell_dirty = true;
|
self.spell_dirty = true;
|
||||||
self.spell_last_edit = Some(Instant::now());
|
self.spell_last_edit = Some(Instant::now());
|
||||||
|
// Update Story Bible references for the current file
|
||||||
|
self.update_current_references();
|
||||||
}
|
}
|
||||||
// Markdown formatting hotkeys, applied to the current selection
|
// Markdown formatting hotkeys, applied to the current selection
|
||||||
// while the editor is focused (Ctrl/Cmd + B / I / E / K, and
|
// while the editor is focused (Ctrl/Cmd + B / I / E / K, and
|
||||||
@@ -358,6 +368,7 @@ impl App {
|
|||||||
/// get a red underline, grammar/style issues a blue one. `find` are
|
/// get a red underline, grammar/style issues a blue one. `find` are
|
||||||
/// `(start_byte, end_byte)` search-match ranges, shaded with a translucent
|
/// `(start_byte, end_byte)` search-match ranges, shaded with a translucent
|
||||||
/// highlight; the one at index `find_active` (if any) is shaded more strongly.
|
/// highlight; the one at index `find_active` (if any) is shaded more strongly.
|
||||||
|
/// `bible` are Story Bible reference ranges, highlighted with a distinctive color.
|
||||||
pub(super) fn build_editor_job(
|
pub(super) fn build_editor_job(
|
||||||
text: &str,
|
text: &str,
|
||||||
size: f32,
|
size: f32,
|
||||||
@@ -365,6 +376,7 @@ pub(super) fn build_editor_job(
|
|||||||
grammar: &[(usize, usize, bool)],
|
grammar: &[(usize, usize, bool)],
|
||||||
find: &[(usize, usize)],
|
find: &[(usize, usize)],
|
||||||
find_active: Option<usize>,
|
find_active: Option<usize>,
|
||||||
|
bible: &[(usize, usize)],
|
||||||
wrap_width: f32,
|
wrap_width: f32,
|
||||||
) -> egui::text::LayoutJob {
|
) -> egui::text::LayoutJob {
|
||||||
use egui::text::{LayoutJob, TextFormat};
|
use egui::text::{LayoutJob, TextFormat};
|
||||||
@@ -377,7 +389,7 @@ pub(super) fn build_editor_job(
|
|||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split the text at every match boundary (grammar and find), then format
|
// Split the text at every match boundary (grammar, find, and bible), then format
|
||||||
// each run according to which ranges cover it.
|
// each run according to which ranges cover it.
|
||||||
let mut points: Vec<usize> = vec![0, text.len()];
|
let mut points: Vec<usize> = vec![0, text.len()];
|
||||||
for &(s, e, _) in grammar {
|
for &(s, e, _) in grammar {
|
||||||
@@ -388,6 +400,10 @@ pub(super) fn build_editor_job(
|
|||||||
points.push(s);
|
points.push(s);
|
||||||
points.push(e);
|
points.push(e);
|
||||||
}
|
}
|
||||||
|
for &(s, e) in bible {
|
||||||
|
points.push(s);
|
||||||
|
points.push(e);
|
||||||
|
}
|
||||||
points.retain(|&p| p <= text.len() && text.is_char_boundary(p));
|
points.retain(|&p| p <= text.len() && text.is_char_boundary(p));
|
||||||
points.sort_unstable();
|
points.sort_unstable();
|
||||||
points.dedup();
|
points.dedup();
|
||||||
@@ -397,6 +413,8 @@ pub(super) fn build_editor_job(
|
|||||||
// Translucent so the highlight reads in both light and dark themes.
|
// Translucent so the highlight reads in both light and dark themes.
|
||||||
let find_bg = egui::Color32::from_rgba_unmultiplied(255, 213, 0, 70);
|
let find_bg = egui::Color32::from_rgba_unmultiplied(255, 213, 0, 70);
|
||||||
let find_active_bg = egui::Color32::from_rgba_unmultiplied(255, 145, 0, 150);
|
let find_active_bg = egui::Color32::from_rgba_unmultiplied(255, 145, 0, 150);
|
||||||
|
// Story Bible reference highlight - purple/blue color
|
||||||
|
let bible_bg = egui::Color32::from_rgba_unmultiplied(138, 43, 226, 70);
|
||||||
let width = (size * 0.08).max(1.5);
|
let width = (size * 0.08).max(1.5);
|
||||||
|
|
||||||
for w in points.windows(2) {
|
for w in points.windows(2) {
|
||||||
@@ -421,6 +439,12 @@ pub(super) fn build_editor_job(
|
|||||||
find_bg
|
find_bg
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Check for Story Bible references and apply background highlight
|
||||||
|
// Only apply if not already highlighted by find
|
||||||
|
let is_find_highlight = find.iter().any(|&(s, e)| s < e && a >= s && b <= e);
|
||||||
|
if !is_find_highlight && bible.iter().any(|&(s, e)| s < e && a >= s && b <= e) {
|
||||||
|
fmt.background = bible_bg;
|
||||||
|
}
|
||||||
job.append(&text[a..b], 0.0, fmt);
|
job.append(&text[a..b], 0.0, fmt);
|
||||||
}
|
}
|
||||||
job
|
job
|
||||||
@@ -825,3 +849,19 @@ mod tests {
|
|||||||
assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "word");
|
assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "word");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// Update the current_references list with Story Bible references found in the buffer.
|
||||||
|
pub(super) fn update_current_references(&mut self) {
|
||||||
|
if let Some(idx) = self.selected {
|
||||||
|
let file_path = self.files[idx].clone();
|
||||||
|
let mut refs = crate::story_bible::find_references(&self.buffer, &self.story_bible_index);
|
||||||
|
for r in refs.iter_mut() {
|
||||||
|
r.file_path = file_path.clone();
|
||||||
|
}
|
||||||
|
self.current_references = refs;
|
||||||
|
} else {
|
||||||
|
self.current_references.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ mod outline;
|
|||||||
mod project;
|
mod project;
|
||||||
mod grammar;
|
mod grammar;
|
||||||
mod spelling;
|
mod spelling;
|
||||||
|
mod story_bible_ui;
|
||||||
mod style;
|
mod style;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod util;
|
mod util;
|
||||||
@@ -372,6 +373,14 @@ pub struct App {
|
|||||||
watcher_rx: Option<mpsc::Receiver<WatcherMsg>>,
|
watcher_rx: Option<mpsc::Receiver<WatcherMsg>>,
|
||||||
/// The file watcher thread handle, so it can be stopped when the workspace closes.
|
/// The file watcher thread handle, so it can be stopped when the workspace closes.
|
||||||
watcher_thread: Option<thread::JoinHandle<()>>,
|
watcher_thread: Option<thread::JoinHandle<()>>,
|
||||||
|
/// Index of Story Bible items for cross-referencing with draft text.
|
||||||
|
story_bible_index: crate::story_bible::StoryBibleIndex,
|
||||||
|
/// Whether the Story Bible window is open.
|
||||||
|
show_story_bible: bool,
|
||||||
|
/// Currently selected Story Bible item path.
|
||||||
|
selected_bible_item: Option<String>,
|
||||||
|
/// References found in the current draft file.
|
||||||
|
current_references: Vec<crate::story_bible::StoryBibleReference>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -472,6 +481,10 @@ impl App {
|
|||||||
show_project_settings: false,
|
show_project_settings: false,
|
||||||
watcher_rx: None,
|
watcher_rx: None,
|
||||||
watcher_thread: None,
|
watcher_thread: None,
|
||||||
|
story_bible_index: crate::story_bible::StoryBibleIndex::new(),
|
||||||
|
show_story_bible: false,
|
||||||
|
selected_bible_item: None,
|
||||||
|
current_references: Vec::new(),
|
||||||
};
|
};
|
||||||
app.load_spell_dict();
|
app.load_spell_dict();
|
||||||
app.open_workspace();
|
app.open_workspace();
|
||||||
@@ -594,6 +607,10 @@ impl eframe::App for App {
|
|||||||
self.characters_window(ctx);
|
self.characters_window(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.show_story_bible {
|
||||||
|
self.story_bible_window(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
if self.show_manuscript_settings {
|
if self.show_manuscript_settings {
|
||||||
self.manuscript_settings_window(ctx);
|
self.manuscript_settings_window(ctx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//! Story Bible UI: window and reference navigation.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// Show the Story Bible window with indexed items and references.
|
||||||
|
pub(super) fn story_bible_window(&mut self, ctx: &egui::Context) {
|
||||||
|
let mut open = self.show_story_bible;
|
||||||
|
egui::Window::new("Story Bible")
|
||||||
|
.open(&mut open)
|
||||||
|
.default_size(egui::vec2(400.0, 500.0))
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
self.story_bible_window_contents(ui);
|
||||||
|
});
|
||||||
|
self.show_story_bible = open;
|
||||||
|
if !open {
|
||||||
|
self.selected_bible_item = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contents of the Story Bible window.
|
||||||
|
fn story_bible_window_contents(&mut self, ui: &mut egui::Ui) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Search:");
|
||||||
|
let mut search_query = self.selected_bible_item.clone().unwrap_or_default();
|
||||||
|
if ui.text_edit_singleline(&mut search_query).changed() {
|
||||||
|
self.selected_bible_item = Some(search_query);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// Filter items based on search query
|
||||||
|
let all_items = self.story_bible_index.all_items();
|
||||||
|
let filtered_items: Vec<_> = if let Some(query) = &self.selected_bible_item {
|
||||||
|
if query.is_empty() {
|
||||||
|
all_items
|
||||||
|
} else {
|
||||||
|
self.story_bible_index.find_by_partial_name(query)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
all_items
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show item types as filter tabs
|
||||||
|
let mut all_types = Vec::new();
|
||||||
|
for item in &filtered_items {
|
||||||
|
if !all_types.contains(&item.item_type) {
|
||||||
|
all_types.push(item.item_type.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort and display type filters
|
||||||
|
let mut sorted_types = all_types.clone();
|
||||||
|
sorted_types.sort();
|
||||||
|
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
if ui.button("All").clicked() {
|
||||||
|
self.selected_bible_item = None;
|
||||||
|
}
|
||||||
|
for t in &sorted_types {
|
||||||
|
if ui.button(t).clicked() {
|
||||||
|
// Filter by type - for now just show all and let search filter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// Clone current_references to avoid borrow issues
|
||||||
|
let current_refs = self.current_references.clone();
|
||||||
|
let selected_item = self.selected_bible_item.clone();
|
||||||
|
|
||||||
|
// Use a RefCell to allow mutable access inside the closure
|
||||||
|
// Actually, simpler: use a local variable to track if we need to jump
|
||||||
|
let mut jump_target: Option<crate::story_bible::StoryBibleReference> = None;
|
||||||
|
|
||||||
|
// Display items
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
for item in &filtered_items {
|
||||||
|
let selected = selected_item.as_ref() == Some(&item.path);
|
||||||
|
|
||||||
|
if ui
|
||||||
|
.selectable_label(selected, format!("{}: {}", item.item_type, item.name))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.selected_bible_item = Some(item.path.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show references to this item in draft
|
||||||
|
if selected {
|
||||||
|
ui.indent("bible_item_details", |ui| {
|
||||||
|
ui.label(format!("Type: {}", item.item_type));
|
||||||
|
ui.label(format!("Path: {}", item.path));
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// Find references to this item
|
||||||
|
let item_refs: Vec<_> = current_refs
|
||||||
|
.iter()
|
||||||
|
.filter(|r| {
|
||||||
|
r.identifier.eq_ignore_ascii_case(&item.id)
|
||||||
|
|| r.identifier.eq_ignore_ascii_case(&item.name)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !item_refs.is_empty() {
|
||||||
|
ui.label(egui::RichText::new(format!(
|
||||||
|
"{} reference(s) in current file:",
|
||||||
|
item_refs.len()
|
||||||
|
)).strong());
|
||||||
|
for ref_info in &item_refs {
|
||||||
|
if ui
|
||||||
|
.button(format!(
|
||||||
|
"Line {}: {}",
|
||||||
|
ref_info.line_number, ref_info.identifier
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
jump_target = Some((*ref_info).clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ui.label("No references in current file");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if filtered_items.is_empty() {
|
||||||
|
ui.label("No Story Bible items found");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Perform the jump after the closure
|
||||||
|
if let Some(ref_info) = jump_target {
|
||||||
|
self.jump_to_reference(&ref_info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jump to a Story Bible reference in the editor.
|
||||||
|
pub(super) fn jump_to_reference(&mut self, ref_info: &crate::story_bible::StoryBibleReference) {
|
||||||
|
self.issue_jump = Some((ref_info.column, ref_info.column + ref_info.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -331,6 +331,10 @@ impl App {
|
|||||||
ui.close_menu();
|
ui.close_menu();
|
||||||
self.show_characters = true;
|
self.show_characters = true;
|
||||||
}
|
}
|
||||||
|
if ui.button("📖 Story Bible…").clicked() {
|
||||||
|
ui.close_menu();
|
||||||
|
self.show_story_bible = true;
|
||||||
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
let busy = self.beats_rx.is_some();
|
let busy = self.beats_rx.is_some();
|
||||||
if ui
|
if ui
|
||||||
|
|||||||
+48
-155
@@ -93,6 +93,7 @@ impl App {
|
|||||||
self.file_meta = self.snapshot_file_meta();
|
self.file_meta = self.snapshot_file_meta();
|
||||||
self.rebuild_field_names();
|
self.rebuild_field_names();
|
||||||
self.load_characters();
|
self.load_characters();
|
||||||
|
self.build_story_bible_index();
|
||||||
self.autocomplete = None;
|
self.autocomplete = None;
|
||||||
self.collapsed.clear();
|
self.collapsed.clear();
|
||||||
// Ensure the manuscript folder (e.g., 06-First Draft) is always expanded
|
// Ensure the manuscript folder (e.g., 06-First Draft) is always expanded
|
||||||
@@ -339,6 +340,42 @@ impl App {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the Story Bible index from the Story Bible directory.
|
||||||
|
pub(super) fn build_story_bible_index(&mut self) {
|
||||||
|
use std::path::Path;
|
||||||
|
let ws = self.config.workspace.clone();
|
||||||
|
// Look for Story Bible directory - common locations
|
||||||
|
let story_bible_dirs = [
|
||||||
|
"03-Characters",
|
||||||
|
"02-Story Bible",
|
||||||
|
"Story Bible",
|
||||||
|
"Characters",
|
||||||
|
];
|
||||||
|
|
||||||
|
for dir_name in story_bible_dirs.iter() {
|
||||||
|
let dir_path = Path::new(&ws).join(dir_name);
|
||||||
|
if dir_path.exists() && dir_path.is_dir() {
|
||||||
|
self.story_bible_index = crate::story_bible::StoryBibleIndex::build(&dir_path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If no specific Story Bible directory found, try to find all character/location/lore dirs
|
||||||
|
let possible_dirs = ["03-Characters", "04-Locations", "05-Lore", "Characters", "Locations", "Lore"];
|
||||||
|
for dir_name in possible_dirs.iter() {
|
||||||
|
let dir_path = Path::new(&ws).join(dir_name);
|
||||||
|
if dir_path.exists() && dir_path.is_dir() {
|
||||||
|
let index = crate::story_bible::StoryBibleIndex::build(&dir_path);
|
||||||
|
// Merge with existing index
|
||||||
|
for (id, item) in index.items_by_id {
|
||||||
|
self.story_bible_index.items_by_id.insert(id, item.clone());
|
||||||
|
}
|
||||||
|
for (name, item) in index.items_by_name {
|
||||||
|
self.story_bible_index.items_by_name.insert(name, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Prose words across the whole manuscript, counting the open file from the
|
/// Prose words across the whole manuscript, counting the open file from the
|
||||||
/// buffer so the total moves as you type.
|
/// buffer so the total moves as you type.
|
||||||
pub(super) fn manuscript_words(&self) -> usize {
|
pub(super) fn manuscript_words(&self) -> usize {
|
||||||
@@ -528,6 +565,8 @@ impl App {
|
|||||||
self.selected = Some(idx);
|
self.selected = Some(idx);
|
||||||
self.dirty = false;
|
self.dirty = false;
|
||||||
self.pending_delete = false;
|
self.pending_delete = false;
|
||||||
|
// Update Story Bible references for the newly selected file
|
||||||
|
self.update_current_references();
|
||||||
// The rename box holds the whole workspace-relative path, so it doubles
|
// The rename box holds the whole workspace-relative path, so it doubles
|
||||||
// as the way to move a file between folders by typing.
|
// as the way to move a file between folders by typing.
|
||||||
self.rename_input = strip_md(&name).to_string();
|
self.rename_input = strip_md(&name).to_string();
|
||||||
@@ -679,7 +718,7 @@ impl App {
|
|||||||
let draft_name = format!("{}/scene{:02}.md", draft_dir, scene_num);
|
let draft_name = format!("{}/scene{:02}.md", draft_dir, scene_num);
|
||||||
|
|
||||||
// Planning file content
|
// Planning file content
|
||||||
let planning_id = format!("scene_{:02d}_{:02d}_{:02d}", act, chapter, scene_num);
|
let planning_id = format!("scene_{:02}_{:02}_{:02}", act, chapter, scene_num);
|
||||||
let planning_content = format!(r#"---
|
let planning_content = format!(r#"---
|
||||||
id: {}
|
id: {}
|
||||||
title: "{}"
|
title: "{}"
|
||||||
@@ -700,7 +739,6 @@ time: ""
|
|||||||
conflict: ""
|
conflict: ""
|
||||||
theme: ""
|
theme: ""
|
||||||
tone: ""
|
tone: ""
|
||||||
draft_file: "../../06-First Draft/Act {}/scene{:02}.md"
|
|
||||||
order: {}
|
order: {}
|
||||||
created: "{}"
|
created: "{}"
|
||||||
updated: "{}"
|
updated: "{}"
|
||||||
@@ -712,151 +750,39 @@ updated: "{}"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Planning Metadata
|
|
||||||
|
|
||||||
| Attribute | Value |
|
|
||||||
|-----------|-------|
|
|
||||||
| **Act** | {} |
|
|
||||||
| **Chapter** | {} |
|
|
||||||
| **Scene** | {} |
|
|
||||||
| **Type** | scene |
|
|
||||||
| **Order** | {} |
|
|
||||||
| **Draft File** | [Open Draft](../../06-First Draft/Act {}/scene{:02}.md) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Creative Elements
|
|
||||||
|
|
||||||
### POV
|
|
||||||
>
|
|
||||||
|
|
||||||
### Characters
|
|
||||||
|
|
||||||
|
|
||||||
### Setting
|
|
||||||
>
|
|
||||||
|
|
||||||
### Date & Time
|
|
||||||
>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Story Structure
|
|
||||||
|
|
||||||
### Conflict
|
|
||||||
>
|
|
||||||
|
|
||||||
### Theme
|
|
||||||
>
|
|
||||||
|
|
||||||
### Tone
|
|
||||||
>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Word Count Goals
|
|
||||||
|
|
||||||
| Metric | Value |
|
|
||||||
|--------|-------|
|
|
||||||
| **Target** | 2000 |
|
|
||||||
| **Minimum** | 1800 |
|
|
||||||
| **Maximum** | 2200 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scene Planning
|
## Scene Planning
|
||||||
|
|
||||||
### The Setup
|
### The Setup
|
||||||
>
|
|
||||||
|
|
||||||
### The Reveal
|
### The Reveal
|
||||||
>
|
|
||||||
|
|
||||||
### Things to Note
|
### Things to Note
|
||||||
-
|
|
||||||
-
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Status Tracking
|
|
||||||
|
|
||||||
- [ ] Scene outline complete
|
|
||||||
- [ ] Character arcs aligned
|
|
||||||
- [ ] Location details verified
|
|
||||||
- [ ] Conflict established
|
|
||||||
- [ ] Word count target set
|
|
||||||
- [ ] Ready for drafting
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Draft File:** [Open Draft](../../06-First Draft/Act {}/scene{:02}.md) | **Created:** {} | **Updated:** {}
|
|
||||||
"#,
|
"#,
|
||||||
planning_id, title, slug, act, chapter, scene_num,
|
planning_id, title, slug, act, chapter, scene_num,
|
||||||
act, scene_num, scene_num,
|
scene_num,
|
||||||
today, today,
|
today, today,
|
||||||
title, planning_id,
|
title, planning_id
|
||||||
act, chapter, scene_num, scene_num,
|
|
||||||
act, scene_num,
|
|
||||||
act, chapter, scene_num,
|
|
||||||
today, today
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Draft file content
|
// Draft file content
|
||||||
let draft_id = format!("draft_scene_{:02d}_{:02d}_{:02d}", act, chapter, scene_num);
|
let draft_id = format!("draft_scene_{:02}_{:02}_{:02}", act, chapter, scene_num);
|
||||||
let draft_content = format!(r#"---
|
let draft_content = format!(r#"---
|
||||||
id: {}
|
id: {}
|
||||||
title: "{}"
|
title: "{}"
|
||||||
slug: {}
|
slug: {}
|
||||||
planning_template: "../../05-Plot/act_{:02}/scene_{:02}.md"
|
type: draft
|
||||||
|
status: not_started
|
||||||
act: {}
|
act: {}
|
||||||
chapter: {}
|
chapter: {}
|
||||||
scene: {}
|
scene: {}
|
||||||
type: draft
|
|
||||||
status: not_started
|
|
||||||
word_count: 0
|
|
||||||
word_count_updated: ""
|
|
||||||
session_word_count: 0
|
|
||||||
last_session: ""
|
|
||||||
total_sessions: 0
|
|
||||||
pov: ""
|
|
||||||
version: 1
|
|
||||||
created: "{}"
|
created: "{}"
|
||||||
updated: "{}"
|
updated: "{}"
|
||||||
---
|
---
|
||||||
|
|
||||||
# {}
|
# {}
|
||||||
|
|
||||||
**Draft File** | **ID:** {} | **Status:** Not Started
|
**Draft File** | **ID:** {}
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Planning Reference
|
|
||||||
|
|
||||||
> **Linked to:** [Planning Template](../../05-Plot/act_{:02}/scene_{:02}.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scene Metadata
|
|
||||||
|
|
||||||
| Attribute | Value |
|
|
||||||
|-----------|-------|
|
|
||||||
| **Act** | {} |
|
|
||||||
| **Chapter** | {} |
|
|
||||||
| **Scene** | {} |
|
|
||||||
| **Type** | draft |
|
|
||||||
| **POV** | Not set |
|
|
||||||
| **Version** | 1 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Word Count Tracking
|
|
||||||
|
|
||||||
| Metric | Value |
|
|
||||||
|--------|-------|
|
|
||||||
| **Target** | 2000 (from planning) |
|
|
||||||
| **Current** | 0 |
|
|
||||||
| **This Session** | 0 |
|
|
||||||
| **Last Updated** | Never |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -866,49 +792,16 @@ updated: "{}"
|
|||||||
|
|
||||||
[Write your scene content here...]
|
[Write your scene content here...]
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Writing Notes
|
|
||||||
|
|
||||||
### Session Log
|
|
||||||
| Date | Words Added | Notes |
|
|
||||||
|------|--------------|-------|
|
|
||||||
|
|
||||||
|
|
||||||
### Revision Notes
|
|
||||||
-
|
|
||||||
-
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Status Checklist
|
|
||||||
|
|
||||||
- [ ] First draft complete
|
|
||||||
- [ ] Word count target met
|
|
||||||
- [ ] POV consistent
|
|
||||||
- [ ] Character voices distinct
|
|
||||||
- [ ] Setting details accurate
|
|
||||||
- [ ] Conflict resolved/advanced
|
|
||||||
- [ ] Ready for revision
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Planning Template:** [Open Planning](../../05-Plot/act_{:02}/scene_{:02}.md) | **Created:** {} | **Updated:** {}
|
|
||||||
"#,
|
"#,
|
||||||
draft_id, title, slug,
|
draft_id, title, slug,
|
||||||
act, scene_num,
|
|
||||||
act, chapter, scene_num,
|
act, chapter, scene_num,
|
||||||
today, today,
|
today, today,
|
||||||
title, draft_id,
|
title, draft_id
|
||||||
act, scene_num,
|
|
||||||
act, chapter, scene_num,
|
|
||||||
act, scene_num,
|
|
||||||
today, today
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create both files
|
// Create both files
|
||||||
self.insert_new_file(planning_name, planning_content);
|
self.insert_new_file(planning_name, planning_content);
|
||||||
self.insert_new_file(draft_name, draft_content);
|
self.insert_new_file(draft_name.clone(), draft_content);
|
||||||
|
|
||||||
// Select the draft file (more likely what user wants to edit)
|
// Select the draft file (more likely what user wants to edit)
|
||||||
let draft_idx = self.files.iter().position(|f| *f == draft_name).unwrap_or(0);
|
let draft_idx = self.files.iter().position(|f| *f == draft_name).unwrap_or(0);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ mod preprocess;
|
|||||||
mod project_template;
|
mod project_template;
|
||||||
mod screenplay;
|
mod screenplay;
|
||||||
mod spell;
|
mod spell;
|
||||||
|
mod story_bible;
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user