Let the Story Bible Editor create new entries, not just edit existing ones

Right-click a folder in the tree (a top-level category like
01-Characters, or any subfolder like cities) for "Add <type> entry…" —
the type is guessed from the folder's own name (singularized: cities
-> city, Objects -> object), editable afterward like any other field.
Names a file, writes minimal id/name/aliases/summary/type/status
frontmatter, and loads it straight into the editor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 08:51:33 -05:00
parent 6627ae8000
commit 7e4932176b
2 changed files with 303 additions and 7 deletions
+281 -7
View File
@@ -4,7 +4,7 @@
use super::*;
use std::fs;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
/// A tree node for the Story Bible tree view.
#[derive(Debug, Clone)]
@@ -159,12 +159,16 @@ impl App {
None
}
/// Render the Story Bible tree recursively.
/// Render the Story Bible tree recursively. `add_requested` collects the
/// directory path a right-click menu's "Add … entry…" was clicked for,
/// so the caller can open the add dialog once rendering is done rather
/// than mutating `self` from inside this `&self` walk.
fn render_bible_tree(
&self,
ui: &mut egui::Ui,
nodes: &[BibleTreeNode],
selected_path: &Option<String>,
add_requested: &mut Option<String>,
) -> Option<String> {
let mut clicked_path: Option<String> = None;
@@ -172,18 +176,28 @@ impl App {
match node {
BibleTreeNode::Directory { name, path, children } => {
let _is_selected = selected_path.as_ref().map_or(false, |p| p == path);
egui::CollapsingHeader::new(name)
let header = egui::CollapsingHeader::new(name)
.default_open(true)
.show(ui, |ui| {
if let Some(child_clicked) = self.render_bible_tree(ui, children, selected_path) {
if let Some(child_clicked) =
self.render_bible_tree(ui, children, selected_path, add_requested)
{
clicked_path = Some(child_clicked);
}
});
header.header_response.context_menu(|ui| {
let entry_type = infer_entry_type(path);
let label = format!("➕ Add {entry_type} entry…");
if ui.button(label).clicked() {
*add_requested = Some(path.clone());
ui.close_menu();
}
});
}
BibleTreeNode::File { name, path } => {
let is_selected = selected_path.as_ref().map_or(false, |p| p == path);
if ui
.selectable_label(is_selected, name)
.clicked()
@@ -243,6 +257,9 @@ impl App {
// Set by the editor's text area below, read back out once the closures
// that borrow `self` have finished.
let mut focused = false;
// Directory a right-click menu's "Add … entry…" was clicked for, if
// any — applied after the tree finishes rendering, same reason.
let mut add_requested: Option<String> = None;
ui.horizontal(|ui| {
// Left panel: Tree view (1/3 width). The width must be set on
// this panel's own child `Ui`, not the shared horizontal one —
@@ -276,7 +293,7 @@ impl App {
} else {
// Render tree
if let Some(clicked_path) = self.render_bible_tree(
ui, tree, &self.story_bible_editor_path
ui, tree, &self.story_bible_editor_path, &mut add_requested,
) {
// Load the clicked file
self.load_story_bible_item(&clicked_path);
@@ -345,6 +362,14 @@ impl App {
});
});
self.story_bible_editor_focused = focused;
if let Some(dir) = add_requested {
self.add_story_bible_type = infer_entry_type(&dir);
self.add_story_bible_dir = dir;
self.add_story_bible_name.clear();
self.show_add_story_bible_item = true;
self.add_story_bible_focus = true;
}
}
/// Load a Story Bible item from the workspace.
@@ -390,6 +415,116 @@ impl App {
}
}
/// The "Add … entry…" dialog opened from a directory's right-click menu.
/// Mirrors `rename_window`'s shape (`src/app/workspace.rs`): a small
/// modal with a focused name field, Enter-to-submit, Create/Cancel.
pub(super) fn add_story_bible_item_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_add_story_bible_item;
let mut submit = false;
let mut cancel = false;
let title = format!("Add {} entry", capitalize(&self.add_story_bible_type));
egui::Window::new(title)
.open(&mut open)
.resizable(false)
.collapsible(false)
.default_width(320.0)
.show(ctx, |ui| {
ui.label(
egui::RichText::new(format!("in {}/", self.add_story_bible_dir))
.small()
.weak(),
);
ui.add_space(4.0);
let r = ui.add(
egui::TextEdit::singleline(&mut self.add_story_bible_name)
.desired_width(f32::INFINITY)
.hint_text("e.g. Captain Reyes"),
);
if self.add_story_bible_focus {
r.request_focus();
self.add_story_bible_focus = false;
}
submit |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.label(
egui::RichText::new(format!(
"Starts with id/name/aliases/summary/type: {} frontmatter — \
edit any of it once it's created.",
self.add_story_bible_type
))
.small()
.weak(),
);
ui.separator();
ui.horizontal(|ui| {
submit |= ui.button("Create").clicked();
cancel |= ui.button("Cancel").clicked();
});
});
if cancel || !open {
self.show_add_story_bible_item = false;
return;
}
if submit {
if self.create_story_bible_item() {
self.show_add_story_bible_item = false;
} else {
// Rejected (empty name): keep the dialog up, same as Rename.
self.add_story_bible_focus = true;
}
}
}
/// Create the new Story Bible entry `add_story_bible_name` names, inside
/// `add_story_bible_dir`, and load it into the editor. Returns `false`
/// (leaving the dialog open) when there's no usable name yet.
fn create_story_bible_item(&mut self) -> bool {
let name = self.add_story_bible_name.trim();
if name.is_empty() {
self.status = "Name needed".to_string();
return false;
}
let Some(base_dir) = self.find_story_bible_directory() else {
self.status = "Story Bible directory not found".to_string();
return false;
};
let dir = base_dir.join(&self.add_story_bible_dir);
if let Err(e) = fs::create_dir_all(&dir) {
self.status = format!("Could not create {}: {e}", dir.display());
return false;
}
let stem = unique_slug(&dir, &slugify(name));
let rel_path = format!("{}/{stem}.md", self.add_story_bible_dir);
let full_path = base_dir.join(&rel_path);
let content = format!(
"---\n\
id: {stem}\n\
name: \"{name}\"\n\
aliases: []\n\
summary: \"\"\n\
type: {}\n\
status: active\n\
---\n\n\
# {name}\n",
self.add_story_bible_type,
);
match fs::write(&full_path, &content) {
Ok(()) => {
self.status = format!("Created {rel_path}");
self.story_bible_editor_content = content;
self.story_bible_editor_path = Some(rel_path);
self.build_story_bible_index();
true
}
Err(e) => {
self.status = format!("Could not create {rel_path}: {e}");
false
}
}
}
/// Open the Story Bible editor.
pub(super) fn open_story_bible_editor(&mut self) {
self.show_story_bible_editor = true;
@@ -420,3 +555,142 @@ impl App {
None
}
}
/// Best-guess entry `type:` for a new Story Bible item, from the directory
/// it's being added to: the last path component, a leading `NN-`/`NN_`/`NN `
/// ordering prefix dropped, underscores turned to spaces, lowercased, and
/// singularized by a simple suffix heuristic (`cities` -> `city`, `Objects`
/// -> `object`). Only a default — nothing stops the file's own `type:`
/// field being edited afterward if the guess is off.
fn infer_entry_type(dir_path: &str) -> String {
let last = dir_path.rsplit('/').next().unwrap_or(dir_path).trim();
let rest = last.trim_start_matches(|c: char| c.is_ascii_digit());
let stripped = match rest.strip_prefix(['-', '_', ' ']) {
Some(after) if !after.trim().is_empty() => after.trim(),
_ => last,
};
let lower = stripped.replace(['_', '-'], " ").to_lowercase();
match lower.strip_suffix("ies") {
Some(stem) => format!("{stem}y"),
None => match lower.strip_suffix('s') {
Some(stem) => stem.to_string(),
None => lower,
},
}
}
/// Capitalize the first character, leaving the rest alone.
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
/// Turn a typed display name into a filename stem: lowercase, runs of
/// anything that isn't a letter or digit collapsed to a single `_`, trimmed
/// of leading/trailing `_`. Falls back to `entry` if nothing alphanumeric
/// survives (an emoji-only name, say), so a file always gets created.
fn slugify(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut last_was_sep = false;
for c in name.chars() {
if c.is_alphanumeric() {
out.extend(c.to_lowercase());
last_was_sep = false;
} else if !last_was_sep && !out.is_empty() {
out.push('_');
last_was_sep = true;
}
}
while out.ends_with('_') {
out.pop();
}
if out.is_empty() {
"entry".to_string()
} else {
out
}
}
/// `slug`, or `slug_2`/`slug_3`/… if a file by that name already exists in
/// `dir` — the same "find the first free name" shape as the main file
/// panel's `+ New from template`.
fn unique_slug(dir: &Path, slug: &str) -> String {
if !dir.join(format!("{slug}.md")).exists() {
return slug.to_string();
}
let mut n = 2;
loop {
let candidate = format!("{slug}_{n}");
if !dir.join(format!("{candidate}.md")).exists() {
return candidate;
}
n += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn infers_type_from_a_top_level_category() {
assert_eq!(infer_entry_type("01-Characters"), "character");
assert_eq!(infer_entry_type("04-Objects"), "object");
assert_eq!(infer_entry_type("05-Relationships"), "relationship");
}
#[test]
fn infers_type_from_the_closest_subfolder() {
assert_eq!(infer_entry_type("01-Characters/main_characters"), "main character");
assert_eq!(infer_entry_type("02-Locations/cities"), "city");
assert_eq!(infer_entry_type("03-Lore/factions"), "faction");
}
#[test]
fn a_name_with_no_trailing_s_is_left_alone() {
assert_eq!(infer_entry_type("06-Timeline"), "timeline");
assert_eq!(infer_entry_type("03-Lore/history"), "history");
}
#[test]
fn capitalize_only_touches_the_first_character() {
assert_eq!(capitalize("character"), "Character");
assert_eq!(capitalize(""), "");
assert_eq!(capitalize("main character"), "Main character");
}
#[test]
fn slugify_collapses_punctuation_and_spaces() {
assert_eq!(slugify("Captain Reyes"), "captain_reyes");
assert_eq!(slugify(" Mira Voss "), "mira_voss");
assert_eq!(slugify("O'Brien's Tavern!"), "o_brien_s_tavern");
}
#[test]
fn slugify_falls_back_rather_than_producing_an_empty_name() {
assert_eq!(slugify("***"), "entry");
assert_eq!(slugify(""), "entry");
}
#[test]
fn unique_slug_finds_the_first_free_name() {
let dir = std::env::temp_dir().join(format!(
"md-manuscript-sbe-slug-test-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
assert_eq!(unique_slug(&dir, "mira"), "mira");
fs::write(dir.join("mira.md"), "").unwrap();
assert_eq!(unique_slug(&dir, "mira"), "mira_2");
fs::write(dir.join("mira_2.md"), "").unwrap();
assert_eq!(unique_slug(&dir, "mira"), "mira_3");
let _ = fs::remove_dir_all(&dir);
}
}