From ab009e0a19a4d2a2a15a52d0889ed8586c9ef64f Mon Sep 17 00:00:00 2001 From: landon Date: Wed, 30 Sep 2026 11:53:48 -0500 Subject: [PATCH] Let a folder be excluded from export without hiding it Misc Scenes (05-First Draft) is a dumping ground for scenes written but not yet placed in the book. It should stay visible, editable and counted everywhere in the app, but never end up in an exported document. hidden_folders/archive_folder both hide a folder outright, so add a narrower export_exclude_folders setting (defaults to ["Misc Scenes"]) that only the three export paths honor, with chapter numbers renumbered afterward so excluded scenes don't leave gaps. Co-Authored-By: Claude Sonnet 5 --- src/app/workspace.rs | 27 ++++++++++++++++++++++++--- src/config.rs | 24 ++++++++++++++++++++++++ src/order.rs | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/app/workspace.rs b/src/app/workspace.rs index 26c0773..3f53614 100644 --- a/src/app/workspace.rs +++ b/src/app/workspace.rs @@ -313,6 +313,27 @@ impl App { .collect() } + /// Whether `path` sits inside a folder configured to be left out of + /// export (`export_exclude_folders`), matched leniently at any depth — + /// e.g. a "Misc Scenes" dumping ground for scenes not yet placed in the + /// book. Unlike `hidden_folders`, this doesn't affect the file panel, + /// word counts or any other view: the folder stays fully visible and + /// editable, just left out of the exported document. + fn is_export_excluded(&self, path: &str) -> bool { + order::is_within_any(path, &self.config.export_exclude_folders) + } + + /// The manuscript's files that actually belong in an export, in order, + /// paired with a fresh contiguous chapter index — so excluded files leave + /// no gaps in the chapter numbering. + pub(super) fn exported_files(&self) -> Vec<(usize, &String)> { + self.files + .iter() + .filter(|path| self.is_manuscript(path) && !self.is_export_excluded(path)) + .enumerate() + .collect() + } + /// Read every file and return its current on-disk word count. pub(super) fn snapshot_counts(&self) -> HashMap { self.files @@ -1014,7 +1035,7 @@ impl App { fn collect_chapters(&self) -> Vec { let marker = self.config.draft_marker.clone(); let pad_width = self.index_pad_width(); - self.manuscript_files() + self.exported_files() .into_iter() .map(|(i, name)| { let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); @@ -1060,7 +1081,7 @@ impl App { /// below the draft marker is the Fountain. fn collect_bodies(&self) -> Vec { let marker = self.config.draft_marker.clone(); - self.manuscript_files() + self.exported_files() .into_iter() .map(|(_, name)| { let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); @@ -1123,7 +1144,7 @@ impl App { let marker = self.config.draft_marker.clone(); let pad_width = self.index_pad_width(); let mut chapters = Vec::new(); - for (i, name) in self.manuscript_files() { + for (i, name) in self.exported_files() { let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); // Strip comments + the editorial header, and lift out any // `# Title:` / `# Slug:` metadata. diff --git a/src/config.rs b/src/config.rs index c2da6bd..1cc4b36 100644 --- a/src/config.rs +++ b/src/config.rs @@ -137,6 +137,13 @@ pub struct Config { /// what the project already has, so `10-Archive` is found and reused. #[serde(default = "default_archive_folder")] pub archive_folder: String, + /// Folders left out of every export (ODT, master, screenplay) but not + /// otherwise hidden — still visible, editable and counted everywhere + /// else. Matched leniently at any depth inside the manuscript folder, + /// the same way `hidden_folders` matches. For scenes that are written + /// but not yet placed in the book. + #[serde(default = "default_export_exclude_folders")] + pub export_exclude_folders: Vec, /// Manuscript title written into exported documents. Blank falls back to the /// project folder's name. #[serde(default)] @@ -243,6 +250,12 @@ pub fn default_archive_folder() -> String { "10-Archive".to_string() } +/// Folders excluded from export by default: the snowflake layout's dumping +/// ground for scenes that are written but not yet placed in the book. +pub fn default_export_exclude_folders() -> Vec { + vec!["Misc Scenes".to_string()] +} + /// Default parent directory for new projects. pub fn default_projects_dir() -> PathBuf { dirs::document_dir().unwrap_or_else(|| { @@ -352,6 +365,7 @@ impl Default for Config { gitea_use_ssh: default_gitea_use_ssh(), hidden_folders: default_hidden_folders(), archive_folder: default_archive_folder(), + export_exclude_folders: default_export_exclude_folders(), manuscript_title: String::new(), manuscript_author: String::new(), manuscript_contact: String::new(), @@ -621,6 +635,16 @@ mod tests { assert_eq!(cfg.sprint_minutes, 25); } + #[test] + fn config_without_the_export_exclude_field_still_loads() { + let old = r####"{ + "workspace": "/home/writer/Manuscript", + "export_path": "/home/writer/Manuscript/book.odt" + }"####; + let cfg: Config = serde_json::from_str(old).expect("old config must still parse"); + assert_eq!(cfg.export_exclude_folders, vec!["Misc Scenes".to_string()]); + } + /// An explicit opt-out must survive a reload, the same way `gitea_use_ssh` /// being turned off has to (both default to `true`). #[test] diff --git a/src/order.rs b/src/order.rs index 23784a5..05e867c 100644 --- a/src/order.rs +++ b/src/order.rs @@ -146,6 +146,17 @@ pub fn is_within(path: &str, dir: &str) -> bool { dir.is_empty() || path.starts_with(dir) && path[dir.len()..].starts_with('/') } +/// Whether `path` sits inside a folder named like one of `names`, matched +/// leniently by [`dir_matches`], at *any* depth — not just as a direct +/// child. Used for folders (like a "Misc Scenes" dumping ground) that can +/// live nested anywhere under the manuscript folder. +pub fn is_within_any(path: &str, names: &[String]) -> bool { + path.split('/') + .rev() + .skip(1) // the file name itself + .any(|component| names.iter().any(|n| dir_matches(component, n))) +} + /// Turn text typed into the new-file or rename box into a safe workspace-relative /// markdown path. `\\` becomes `/`, blank components and `.`/`..` are dropped so /// nothing can point outside the workspace, and a `.md` extension is ensured. @@ -592,6 +603,31 @@ mod tests { assert!(dir_matches("2061", "2061")); } + #[test] + fn is_within_any_matches_at_any_depth() { + let names = vec!["Misc Scenes".to_string()]; + assert!(is_within_any( + "05-First Draft/Misc Scenes/scene01.md", + &names + )); + // Nested deeper than a direct child still counts. + assert!(is_within_any( + "05-First Draft/Misc Scenes/sub/scene01.md", + &names + )); + // Matched leniently, same as `dir_matches`. + assert!(is_within_any("Misc Scenes/scene01.md", &names)); + } + + #[test] + fn is_within_any_does_not_match_the_file_itself_or_unrelated_folders() { + let names = vec!["Misc Scenes".to_string()]; + // A file merely *named* "Misc Scenes.md" is not a folder match. + assert!(!is_within_any("05-First Draft/Misc Scenes.md", &names)); + assert!(!is_within_any("05-First Draft/ch-01.md", &names)); + assert!(!is_within_any("scene01.md", &names)); + } + #[test] fn hidden_folders_are_kept_out_of_the_scan() { let ws = scratch(