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(