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 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 11:53:48 -05:00
parent 7854a961a9
commit ab009e0a19
3 changed files with 84 additions and 3 deletions
+24 -3
View File
@@ -313,6 +313,27 @@ impl App {
.collect() .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. /// Read every file and return its current on-disk word count.
pub(super) fn snapshot_counts(&self) -> HashMap<String, usize> { pub(super) fn snapshot_counts(&self) -> HashMap<String, usize> {
self.files self.files
@@ -1014,7 +1035,7 @@ impl App {
fn collect_chapters(&self) -> Vec<Chapter> { fn collect_chapters(&self) -> Vec<Chapter> {
let marker = self.config.draft_marker.clone(); let marker = self.config.draft_marker.clone();
let pad_width = self.index_pad_width(); let pad_width = self.index_pad_width();
self.manuscript_files() self.exported_files()
.into_iter() .into_iter()
.map(|(i, name)| { .map(|(i, name)| {
let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); 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. /// below the draft marker is the Fountain.
fn collect_bodies(&self) -> Vec<String> { fn collect_bodies(&self) -> Vec<String> {
let marker = self.config.draft_marker.clone(); let marker = self.config.draft_marker.clone();
self.manuscript_files() self.exported_files()
.into_iter() .into_iter()
.map(|(_, name)| { .map(|(_, name)| {
let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); 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 marker = self.config.draft_marker.clone();
let pad_width = self.index_pad_width(); let pad_width = self.index_pad_width();
let mut chapters = Vec::new(); 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(); let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
// Strip comments + the editorial header, and lift out any // Strip comments + the editorial header, and lift out any
// `# Title:` / `# Slug:` metadata. // `# Title:` / `# Slug:` metadata.
+24
View File
@@ -137,6 +137,13 @@ pub struct Config {
/// what the project already has, so `10-Archive` is found and reused. /// what the project already has, so `10-Archive` is found and reused.
#[serde(default = "default_archive_folder")] #[serde(default = "default_archive_folder")]
pub archive_folder: String, 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<String>,
/// Manuscript title written into exported documents. Blank falls back to the /// Manuscript title written into exported documents. Blank falls back to the
/// project folder's name. /// project folder's name.
#[serde(default)] #[serde(default)]
@@ -243,6 +250,12 @@ pub fn default_archive_folder() -> String {
"10-Archive".to_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<String> {
vec!["Misc Scenes".to_string()]
}
/// Default parent directory for new projects. /// Default parent directory for new projects.
pub fn default_projects_dir() -> PathBuf { pub fn default_projects_dir() -> PathBuf {
dirs::document_dir().unwrap_or_else(|| { dirs::document_dir().unwrap_or_else(|| {
@@ -352,6 +365,7 @@ impl Default for Config {
gitea_use_ssh: default_gitea_use_ssh(), gitea_use_ssh: default_gitea_use_ssh(),
hidden_folders: default_hidden_folders(), hidden_folders: default_hidden_folders(),
archive_folder: default_archive_folder(), archive_folder: default_archive_folder(),
export_exclude_folders: default_export_exclude_folders(),
manuscript_title: String::new(), manuscript_title: String::new(),
manuscript_author: String::new(), manuscript_author: String::new(),
manuscript_contact: String::new(), manuscript_contact: String::new(),
@@ -621,6 +635,16 @@ mod tests {
assert_eq!(cfg.sprint_minutes, 25); 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` /// An explicit opt-out must survive a reload, the same way `gitea_use_ssh`
/// being turned off has to (both default to `true`). /// being turned off has to (both default to `true`).
#[test] #[test]
+36
View File
@@ -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('/') 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 /// Turn text typed into the new-file or rename box into a safe workspace-relative
/// markdown path. `\\` becomes `/`, blank components and `.`/`..` are dropped so /// markdown path. `\\` becomes `/`, blank components and `.`/`..` are dropped so
/// nothing can point outside the workspace, and a `.md` extension is ensured. /// nothing can point outside the workspace, and a `.md` extension is ensured.
@@ -592,6 +603,31 @@ mod tests {
assert!(dir_matches("2061", "2061")); 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] #[test]
fn hidden_folders_are_kept_out_of_the_scan() { fn hidden_folders_are_kept_out_of_the_scan() {
let ws = scratch( let ws = scratch(