From 7e4932176b3d3fca4badfb586ff906ac7920f60a Mon Sep 17 00:00:00 2001 From: landon Date: Sat, 3 Oct 2026 08:51:33 -0500 Subject: [PATCH] Let the Story Bible Editor create new entries, not just edit existing ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-click a folder in the tree (a top-level category like 01-Characters, or any subfolder like cities) for "Add 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 --- src/app/mod.rs | 22 +++ src/app/story_bible_editor.rs | 288 +++++++++++++++++++++++++++++++++- 2 files changed, 303 insertions(+), 7 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 0f879bf..872ad7d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -457,6 +457,19 @@ pub struct App { /// Whether the caret was in the Story Bible editor's text area last frame, /// which is what decides where `Ctrl+S` sends its save. story_bible_editor_focused: bool, + /// Whether the "add Story Bible entry" dialog is open. + show_add_story_bible_item: bool, + /// Directory (relative to the Story Bible folder) the new entry will be + /// created in, set by the tree's right-click menu. + add_story_bible_dir: String, + /// Entry type inferred from that directory's name, pre-filled into the + /// new file's `type:` field — editable like any other field once created. + add_story_bible_type: String, + /// The dialog's name field. + add_story_bible_name: String, + /// Set alongside `show_add_story_bible_item` so the name field takes + /// focus on the frame the dialog opens. + add_story_bible_focus: bool, /// List of auto-snapshots for rollback. snapshots: Vec, /// Word count at the last auto-snapshot. @@ -594,6 +607,11 @@ impl App { story_bible_editor_content: String::new(), story_bible_editor_path: None, story_bible_editor_focused: false, + show_add_story_bible_item: false, + add_story_bible_dir: String::new(), + add_story_bible_type: String::new(), + add_story_bible_name: String::new(), + add_story_bible_focus: false, snapshots: Vec::new(), last_snapshot_word_count: 0, show_snapshots: false, @@ -749,6 +767,10 @@ impl eframe::App for App { self.story_bible_editor_window(ctx); } + if self.show_add_story_bible_item { + self.add_story_bible_item_window(ctx); + } + if self.show_snapshots { self.snapshots_window(ctx); } diff --git a/src/app/story_bible_editor.rs b/src/app/story_bible_editor.rs index fd6a5e2..ce31994 100644 --- a/src/app/story_bible_editor.rs +++ b/src/app/story_bible_editor.rs @@ -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, + add_requested: &mut Option, ) -> Option { let mut clicked_path: Option = 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 = 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::() + 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); + } +}