feat: Merge 05-Plot and 06-First Draft into 05-First Draft with YAML frontmatter
- Add YAML frontmatter support with split_yaml_frontmatter() in preprocess.rs - Update create_scene_pair to create single merged files with YAML frontmatter - Rename 06-First Draft to 05-First Draft and merge with 05-Plot content - Update default_project_open_subdir to '05-First Draft' - Add capitalizes filter to MiniJinja environment for template rendering - Fix template placeholder escaping in CONSISTENCY.md - Update all references from 06-First Draft and 05-Plot to 05-First Draft - Update test expectations to match new folder structure The merged approach uses YAML frontmatter (--- delimiters) at the top of each draft file to store planning metadata, allowing it to be collapsed in the editor while keeping all planning and draft content in one file. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
+143
-86
@@ -96,7 +96,7 @@ impl App {
|
||||
self.build_story_bible_index();
|
||||
self.autocomplete = None;
|
||||
self.collapsed.clear();
|
||||
// Ensure the manuscript folder (e.g., 06-First Draft) is always expanded
|
||||
// Ensure the manuscript folder (e.g., 05-First Draft) is always expanded
|
||||
if let Some(ref dir) = self.manuscript_dir {
|
||||
self.collapsed.remove(dir);
|
||||
}
|
||||
@@ -284,7 +284,7 @@ impl App {
|
||||
/// if so, which of its subfolders is the manuscript.
|
||||
///
|
||||
/// The test is simply whether it has a child folder named like the
|
||||
/// configured manuscript folder (`06-First Draft`), matched leniently by
|
||||
/// configured manuscript folder (`05-First Draft`), matched leniently by
|
||||
/// [`order::dir_matches`]. `None` means the workspace is itself the
|
||||
/// manuscript, which is how the app behaved before project mode and remains
|
||||
/// the right answer for a plain folder of chapters.
|
||||
@@ -453,12 +453,23 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lift the header out of the buffer, leaving the prose. Does nothing for a
|
||||
/// document with no draft marker — there is nothing to collapse.
|
||||
/// Lift the header out of the buffer, leaving the prose. Supports both
|
||||
/// YAML frontmatter (`---` delimiters) and the legacy draft marker.
|
||||
pub(super) fn collapse_header(&mut self) {
|
||||
if self.header_stash.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try YAML frontmatter first
|
||||
if let Some((header, body)) = crate::preprocess::split_yaml_frontmatter(&self.buffer) {
|
||||
let (header, body) = (header.to_string(), body.to_string());
|
||||
self.header_stash = Some(header);
|
||||
self.buffer = body;
|
||||
self.after_view_change();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to draft marker
|
||||
let Some((header, body)) =
|
||||
crate::preprocess::split_at_marker(&self.buffer, &self.config.draft_marker)
|
||||
else {
|
||||
@@ -639,8 +650,8 @@ impl App {
|
||||
self.rebuild_field_names();
|
||||
}
|
||||
|
||||
/// Create a scene pair: both a planning file (in 05-Plot) and a draft file
|
||||
/// (in 06-First Draft), linked to each other. Used for the "New Scene" button.
|
||||
/// Create a new scene file with merged YAML frontmatter containing both
|
||||
/// planning metadata and draft content. Used for the "New Scene" button.
|
||||
pub(super) fn create_scene_pair(&mut self) {
|
||||
use super::today_utc;
|
||||
|
||||
@@ -652,24 +663,15 @@ impl App {
|
||||
.unwrap_or_default();
|
||||
|
||||
// Check if we're in a First Draft folder (Act 1, Act 2, etc.)
|
||||
let in_draft_folder = current_dir.starts_with("06-First Draft/Act")
|
||||
|| current_dir.starts_with("06-First Draft/");
|
||||
|
||||
// Check if we're in a Plot folder (act_1, act_2, etc.)
|
||||
let in_plot_folder = current_dir.starts_with("05-Plot/act_")
|
||||
|| current_dir.starts_with("05-Plot/");
|
||||
let in_draft_folder = current_dir.starts_with("05-First Draft/Act")
|
||||
|| current_dir.starts_with("05-First Draft/");
|
||||
|
||||
// Extract act number from current directory
|
||||
let act_num = if in_draft_folder {
|
||||
// Parse "06-First Draft/Act N" or "06-First Draft/Act N/"
|
||||
// Parse "05-First Draft/Act N" or "05-First Draft/Act N/"
|
||||
current_dir.split('/').nth(1).and_then(|s| {
|
||||
s.strip_prefix("Act ").and_then(|n| n.parse::<u32>().ok())
|
||||
})
|
||||
} else if in_plot_folder {
|
||||
// Parse "05-Plot/act_N" or "05-Plot/act_N/"
|
||||
current_dir.split('/').nth(1).and_then(|s| {
|
||||
s.strip_prefix("act_").and_then(|n| n.parse::<u32>().ok())
|
||||
})
|
||||
} else {
|
||||
// Default to Act 1
|
||||
Some(1)
|
||||
@@ -682,14 +684,14 @@ impl App {
|
||||
let existing_scenes: Vec<u32> = self.files.iter()
|
||||
.filter(|f| {
|
||||
let path_lower = f.to_lowercase();
|
||||
if in_draft_folder || in_plot_folder {
|
||||
if in_draft_folder {
|
||||
// Check files in current act
|
||||
path_lower.contains(&format!("act_{}/", act))
|
||||
path_lower.contains(&format!("act {}/", act))
|
||||
|| path_lower.contains(&format!("Act {}/", act))
|
||||
} else {
|
||||
// Check both folders
|
||||
path_lower.contains(&format!("act_{}/scene_", act))
|
||||
|| path_lower.contains(&format!("Act {}/scene", act))
|
||||
// Check files in 05-First Draft
|
||||
path_lower.contains(&format!("05-first draft/act {}/scene", act))
|
||||
|| path_lower.contains(&format!("05-first draft/Act {}/scene", act))
|
||||
}
|
||||
})
|
||||
.filter_map(|f| {
|
||||
@@ -709,17 +711,17 @@ impl App {
|
||||
let title = format!("Chapter {}: Scene {}", chapter, scene_num);
|
||||
let slug = format!("chapter-{}--scene-{}", chapter, scene_num);
|
||||
|
||||
// Determine where to create files
|
||||
let draft_dir = format!("06-First Draft/Act {}", act);
|
||||
let plot_dir = format!("05-Plot/act_{:02}", act);
|
||||
// Determine where to create file
|
||||
let draft_dir = format!("05-First Draft/Act {}", act);
|
||||
|
||||
// Create planning file
|
||||
let planning_name = format!("{}/scene_{:02}.md", plot_dir, scene_num);
|
||||
let draft_name = format!("{}/scene{:02}.md", draft_dir, scene_num);
|
||||
// Scene file
|
||||
let filename = format!("{}/scene{:02}.md", draft_dir, scene_num);
|
||||
|
||||
// Planning file content
|
||||
let planning_id = format!("scene_{:02}_{:02}_{:02}", act, chapter, scene_num);
|
||||
let planning_content = format!(r#"---
|
||||
// Scene ID
|
||||
let scene_id = format!("scene_{:02}_{:02}_{:02}", act, chapter, scene_num);
|
||||
|
||||
// Create merged file with YAML frontmatter
|
||||
let content = format!(r#"---
|
||||
id: {}
|
||||
title: "{}"
|
||||
slug: {}
|
||||
@@ -727,7 +729,7 @@ act: {}
|
||||
chapter: {}
|
||||
scene: {}
|
||||
type: scene
|
||||
status: planning
|
||||
status: draft
|
||||
word_count_target: 2000
|
||||
word_count_min: 1800
|
||||
word_count_max: 2200
|
||||
@@ -739,50 +741,75 @@ time: ""
|
||||
conflict: ""
|
||||
theme: ""
|
||||
tone: ""
|
||||
word_count: 0
|
||||
word_count_updated: ""
|
||||
session_word_count: 0
|
||||
last_session: ""
|
||||
total_sessions: 0
|
||||
version: 1
|
||||
created: "{}"
|
||||
updated: "{}"
|
||||
order: {}
|
||||
created: "{}"
|
||||
updated: "{}"
|
||||
---
|
||||
|
||||
# {}
|
||||
|
||||
**Planning Template** | **ID:** {} | **Status:** Planning
|
||||
**Scene File** | **ID:** {} | **Status:** Draft
|
||||
|
||||
---
|
||||
|
||||
## Scene Planning
|
||||
## Scene Metadata
|
||||
|
||||
### The Setup
|
||||
| Attribute | Value |
|
||||
|-----------|-------|
|
||||
| **Act** | {} |
|
||||
| **Chapter** | {} |
|
||||
| **Scene** | {} |
|
||||
| **Type** | Scene |
|
||||
| **POV** | Not set |
|
||||
| **Version** | 1 |
|
||||
| **Word Count Target** | 2000 |
|
||||
|
||||
### The Reveal
|
||||
|
||||
### Things to Note
|
||||
|
||||
"#,
|
||||
planning_id, title, slug, act, chapter, scene_num,
|
||||
scene_num,
|
||||
today, today,
|
||||
title, planning_id
|
||||
);
|
||||
|
||||
// Draft file content
|
||||
let draft_id = format!("draft_scene_{:02}_{:02}_{:02}", act, chapter, scene_num);
|
||||
let draft_content = format!(r#"---
|
||||
id: {}
|
||||
title: "{}"
|
||||
slug: {}
|
||||
type: draft
|
||||
status: not_started
|
||||
act: {}
|
||||
chapter: {}
|
||||
scene: {}
|
||||
created: "{}"
|
||||
updated: "{}"
|
||||
---
|
||||
|
||||
# {}
|
||||
## Word Count Tracking
|
||||
|
||||
**Draft File** | **ID:** {}
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Target** | 2000 |
|
||||
| **Minimum** | 1800 |
|
||||
| **Maximum** | 2200 |
|
||||
| **Current** | 0 |
|
||||
| **This Session** | 0 |
|
||||
| **Last Updated** | Never |
|
||||
|
||||
---
|
||||
|
||||
## Creative Elements
|
||||
|
||||
### POV
|
||||
> Not specified
|
||||
|
||||
### Characters
|
||||
|
||||
### Setting
|
||||
> Not specified
|
||||
|
||||
### Date & Time
|
||||
> Not specified | Not specified
|
||||
|
||||
---
|
||||
|
||||
## Story Structure
|
||||
|
||||
### Conflict
|
||||
> Not specified
|
||||
|
||||
### Theme
|
||||
> Not specified
|
||||
|
||||
### Tone
|
||||
> Not specified
|
||||
|
||||
---
|
||||
|
||||
@@ -792,20 +819,50 @@ updated: "{}"
|
||||
|
||||
[Write your scene content here...]
|
||||
|
||||
---
|
||||
|
||||
## Writing Notes
|
||||
|
||||
### Session Log
|
||||
| Date | Words Added | Notes |
|
||||
|------|--------------|-------|
|
||||
|
||||
|
||||
### Revision Notes
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## Status Checklist
|
||||
|
||||
- [ ] Planning complete
|
||||
- [ ] First draft complete
|
||||
- [ ] Word count target met
|
||||
- [ ] POV consistent
|
||||
- [ ] Character voices distinct
|
||||
- [ ] Setting details accurate
|
||||
- [ ] Conflict resolved/advanced
|
||||
- [ ] Ready for revision
|
||||
|
||||
---
|
||||
|
||||
**Created:** {} | **Updated:** {}
|
||||
"#,
|
||||
draft_id, title, slug,
|
||||
act, chapter, scene_num,
|
||||
scene_id, title, slug, act, chapter, scene_num,
|
||||
scene_num,
|
||||
today, today,
|
||||
title, draft_id
|
||||
title, scene_id,
|
||||
act, chapter, scene_num,
|
||||
today, today
|
||||
);
|
||||
|
||||
// Create both files
|
||||
self.insert_new_file(planning_name, planning_content);
|
||||
self.insert_new_file(draft_name.clone(), draft_content);
|
||||
// Create the merged file
|
||||
self.insert_new_file(filename.clone(), content);
|
||||
|
||||
// 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);
|
||||
self.selected = Some(draft_idx);
|
||||
// Select the new file
|
||||
let file_idx = self.files.iter().position(|f| *f == filename).unwrap_or(0);
|
||||
self.selected = Some(file_idx);
|
||||
self.buffer.clear();
|
||||
self.dirty = false;
|
||||
}
|
||||
@@ -1923,7 +1980,7 @@ mod tests {
|
||||
/// shapes a workspace can take.
|
||||
#[test]
|
||||
fn the_project_root_is_the_folder_the_manuscript_sits_in() {
|
||||
let ws = Path::new("/books/My Book/06-First Draft");
|
||||
let ws = Path::new("/books/My Book/05-First Draft");
|
||||
let project = Path::new("/books/My Book");
|
||||
|
||||
// Project mode: the workspace already is the project root, so an
|
||||
@@ -2183,10 +2240,10 @@ mod tests {
|
||||
let files = v(&[
|
||||
"00-Scratch Pad/Generated plot points.md",
|
||||
"03-Characters/ada.md",
|
||||
"06-First Draft/Act 1/scene01.md",
|
||||
"06-First Draft/draft_v1.md",
|
||||
"05-First Draft/Act 1/scene01.md",
|
||||
"05-First Draft/draft_v1.md",
|
||||
]);
|
||||
assert_eq!(first_listed(&files, Some("06-First Draft")), Some(2));
|
||||
assert_eq!(first_listed(&files, Some("05-First Draft")), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2201,33 +2258,33 @@ mod tests {
|
||||
#[test]
|
||||
fn an_empty_manuscript_falls_back_to_the_first_file() {
|
||||
let files = v(&["00-Scratch Pad/note.md", "03-Characters/ada.md"]);
|
||||
assert_eq!(first_listed(&files, Some("06-First Draft")), Some(0));
|
||||
assert_eq!(first_listed(&files, Some("05-First Draft")), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_a_removal_the_next_listed_file_is_chosen() {
|
||||
let files = v(&[
|
||||
"00-Scratch Pad/note.md",
|
||||
"06-First Draft/a.md",
|
||||
"06-First Draft/b.md",
|
||||
"05-First Draft/a.md",
|
||||
"05-First Draft/b.md",
|
||||
]);
|
||||
// Removing index 1 leaves the following manuscript file at index 1.
|
||||
assert_eq!(nearest_listed(&files, Some("06-First Draft"), 1), Some(1));
|
||||
assert_eq!(nearest_listed(&files, Some("05-First Draft"), 1), Some(1));
|
||||
// Removing the last one falls back to the previous manuscript file.
|
||||
assert_eq!(nearest_listed(&files, Some("06-First Draft"), 3), Some(2));
|
||||
assert_eq!(nearest_listed(&files, Some("05-First Draft"), 3), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_removal_never_lands_on_a_reference_file() {
|
||||
let files = v(&["00-Scratch Pad/note.md", "06-First Draft/a.md"]);
|
||||
let files = v(&["00-Scratch Pad/note.md", "05-First Draft/a.md"]);
|
||||
// Index 2 is past the end; the search back must skip the scratch pad.
|
||||
assert_eq!(nearest_listed(&files, Some("06-First Draft"), 2), Some(1));
|
||||
assert_eq!(nearest_listed(&files, Some("05-First Draft"), 2), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_the_only_manuscript_file_still_selects_something() {
|
||||
let files = v(&["00-Scratch Pad/note.md"]);
|
||||
assert_eq!(nearest_listed(&files, Some("06-First Draft"), 0), Some(0));
|
||||
assert_eq!(nearest_listed(&[], Some("06-First Draft"), 0), None);
|
||||
assert_eq!(nearest_listed(&files, Some("05-First Draft"), 0), Some(0));
|
||||
assert_eq!(nearest_listed(&[], Some("05-First Draft"), 0), None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user