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:
@@ -457,6 +457,19 @@ pub struct App {
|
|||||||
/// Whether the caret was in the Story Bible editor's text area last frame,
|
/// Whether the caret was in the Story Bible editor's text area last frame,
|
||||||
/// which is what decides where `Ctrl+S` sends its save.
|
/// which is what decides where `Ctrl+S` sends its save.
|
||||||
story_bible_editor_focused: bool,
|
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.
|
/// List of auto-snapshots for rollback.
|
||||||
snapshots: Vec<crate::app::auto_snapshot::SnapshotInfo>,
|
snapshots: Vec<crate::app::auto_snapshot::SnapshotInfo>,
|
||||||
/// Word count at the last auto-snapshot.
|
/// Word count at the last auto-snapshot.
|
||||||
@@ -594,6 +607,11 @@ impl App {
|
|||||||
story_bible_editor_content: String::new(),
|
story_bible_editor_content: String::new(),
|
||||||
story_bible_editor_path: None,
|
story_bible_editor_path: None,
|
||||||
story_bible_editor_focused: false,
|
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(),
|
snapshots: Vec::new(),
|
||||||
last_snapshot_word_count: 0,
|
last_snapshot_word_count: 0,
|
||||||
show_snapshots: false,
|
show_snapshots: false,
|
||||||
@@ -749,6 +767,10 @@ impl eframe::App for App {
|
|||||||
self.story_bible_editor_window(ctx);
|
self.story_bible_editor_window(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.show_add_story_bible_item {
|
||||||
|
self.add_story_bible_item_window(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
if self.show_snapshots {
|
if self.show_snapshots {
|
||||||
self.snapshots_window(ctx);
|
self.snapshots_window(ctx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// A tree node for the Story Bible tree view.
|
/// A tree node for the Story Bible tree view.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -159,12 +159,16 @@ impl App {
|
|||||||
None
|
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(
|
fn render_bible_tree(
|
||||||
&self,
|
&self,
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
nodes: &[BibleTreeNode],
|
nodes: &[BibleTreeNode],
|
||||||
selected_path: &Option<String>,
|
selected_path: &Option<String>,
|
||||||
|
add_requested: &mut Option<String>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let mut clicked_path: Option<String> = None;
|
let mut clicked_path: Option<String> = None;
|
||||||
|
|
||||||
@@ -173,13 +177,23 @@ impl App {
|
|||||||
BibleTreeNode::Directory { name, path, children } => {
|
BibleTreeNode::Directory { name, path, children } => {
|
||||||
let _is_selected = selected_path.as_ref().map_or(false, |p| p == path);
|
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)
|
.default_open(true)
|
||||||
.show(ui, |ui| {
|
.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);
|
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 } => {
|
BibleTreeNode::File { name, path } => {
|
||||||
let is_selected = selected_path.as_ref().map_or(false, |p| p == path);
|
let is_selected = selected_path.as_ref().map_or(false, |p| p == path);
|
||||||
@@ -243,6 +257,9 @@ impl App {
|
|||||||
// Set by the editor's text area below, read back out once the closures
|
// Set by the editor's text area below, read back out once the closures
|
||||||
// that borrow `self` have finished.
|
// that borrow `self` have finished.
|
||||||
let mut focused = false;
|
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| {
|
ui.horizontal(|ui| {
|
||||||
// Left panel: Tree view (1/3 width). The width must be set on
|
// Left panel: Tree view (1/3 width). The width must be set on
|
||||||
// this panel's own child `Ui`, not the shared horizontal one —
|
// this panel's own child `Ui`, not the shared horizontal one —
|
||||||
@@ -276,7 +293,7 @@ impl App {
|
|||||||
} else {
|
} else {
|
||||||
// Render tree
|
// Render tree
|
||||||
if let Some(clicked_path) = self.render_bible_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
|
// Load the clicked file
|
||||||
self.load_story_bible_item(&clicked_path);
|
self.load_story_bible_item(&clicked_path);
|
||||||
@@ -345,6 +362,14 @@ impl App {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
self.story_bible_editor_focused = focused;
|
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.
|
/// 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.
|
/// Open the Story Bible editor.
|
||||||
pub(super) fn open_story_bible_editor(&mut self) {
|
pub(super) fn open_story_bible_editor(&mut self) {
|
||||||
self.show_story_bible_editor = true;
|
self.show_story_bible_editor = true;
|
||||||
@@ -420,3 +555,142 @@ impl App {
|
|||||||
None
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user