Add a template-backed new-file button to the file list
The file list's "+ New" needs a typed name and seeds only '# <name>',
so every new chapter started from a blank header. Add a second button,
"+ New from template", that takes no name: it picks the first free
untitled-N.md (reusing a gap left by a deleted file), seeds it from a
configurable template, appends it to the manuscript order and selects it.
The template lives in config.json and is edited under Settings > New-file
template..., alongside the existing LanguageTool and Mistral dialogs.
Three placeholders expand at creation time: {{name}} (file stem),
{{marker}} (the configured draft marker, so a template keeps working if
the marker changes) and {{date}} (today's UTC date). Blanking the box
falls back to the built-in default, which seeds the header fields the app
already understands followed by the draft marker.
The date is computed with a local civil-from-days conversion rather than
a date crate, keeping the binary self-contained - ldd still shows only
libc/libgcc/libm.
"+ New" is unchanged; both paths now share insert_new_file. Covered by
13 new tests: placeholder expansion, untitled-N gap reuse, civil date
conversion against known dates, config.json written before this field
still deserializing (Config::load falls back to Default on a parse error,
which would otherwise discard the user's workspace and API keys), and a
round trip proving the seeded header parses back into Title/Slug/POV/goal
via the app's own preprocess::parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBWj9TphFMCoh7VHaSRnvQ
This commit is contained in:
@@ -63,6 +63,27 @@ pub struct Config {
|
||||
/// Mistral API base URL (blank = the built-in default). Override for a proxy.
|
||||
#[serde(default = "default_mistral_base_url")]
|
||||
pub mistral_base_url: String,
|
||||
/// Markdown seeded into files made with the file list's template button.
|
||||
/// `{{name}}` expands to the file stem, `{{marker}}` to [`Config::draft_marker`]
|
||||
/// and `{{date}}` to today's UTC date. Blank = the built-in default.
|
||||
#[serde(default = "default_new_file_template")]
|
||||
pub new_file_template: String,
|
||||
}
|
||||
|
||||
/// Default new-file template: the editorial header fields this app already
|
||||
/// understands, followed by the draft marker so the body starts below it.
|
||||
pub fn default_new_file_template() -> String {
|
||||
[
|
||||
"# Title: {{name}}",
|
||||
"# Slug:",
|
||||
"# POV:",
|
||||
"# Word Count Target:",
|
||||
"",
|
||||
"{{marker}}",
|
||||
"",
|
||||
"",
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Default Mistral model.
|
||||
@@ -132,6 +153,7 @@ impl Default for Config {
|
||||
mistral_api_key: String::new(),
|
||||
mistral_model: default_mistral_model(),
|
||||
mistral_base_url: default_mistral_base_url(),
|
||||
new_file_template: default_new_file_template(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,6 +197,16 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// The new-file template to use, falling back to the built-in default when
|
||||
/// blank, so an accidentally emptied box still produces a usable file.
|
||||
pub fn effective_new_file_template(&self) -> String {
|
||||
if self.new_file_template.trim().is_empty() {
|
||||
default_new_file_template()
|
||||
} else {
|
||||
self.new_file_template.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// The Mistral base URL to use, falling back to the built-in default when blank.
|
||||
pub fn mistral_effective_base_url(&self) -> String {
|
||||
let b = self.mistral_base_url.trim().trim_end_matches('/');
|
||||
@@ -203,3 +235,61 @@ impl Config {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A config.json written before `new_file_template` existed. `Config::load`
|
||||
/// falls back to `Config::default()` when parsing fails, which would quietly
|
||||
/// discard the user's workspace and API keys — so this must keep parsing.
|
||||
#[test]
|
||||
fn config_without_the_template_field_still_loads() {
|
||||
let old = r####"{
|
||||
"workspace": "/home/writer/Manuscript",
|
||||
"export_path": "/home/writer/Manuscript/book.odt",
|
||||
"show_preview": true,
|
||||
"draft_marker": "### Rough Draft:",
|
||||
"editor_zoom": 10.0,
|
||||
"languagetool_host": "lt.example.com",
|
||||
"mistral_api_key": "secret"
|
||||
}"####;
|
||||
let cfg: Config = serde_json::from_str(old).expect("old config must still parse");
|
||||
// Pre-existing values survive.
|
||||
assert_eq!(cfg.workspace, PathBuf::from("/home/writer/Manuscript"));
|
||||
assert_eq!(cfg.languagetool_host, "lt.example.com");
|
||||
assert_eq!(cfg.mistral_api_key, "secret");
|
||||
assert!(cfg.show_preview);
|
||||
// The new field is filled in from its default rather than left blank.
|
||||
assert_eq!(cfg.new_file_template, default_new_file_template());
|
||||
assert!(cfg.new_file_template.contains("{{marker}}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_template_falls_back_to_the_default() {
|
||||
let blank = Config {
|
||||
new_file_template: " \n ".to_string(),
|
||||
..Config::default()
|
||||
};
|
||||
assert_eq!(
|
||||
blank.effective_new_file_template(),
|
||||
default_new_file_template()
|
||||
);
|
||||
let custom = Config {
|
||||
new_file_template: "# Mine\n".to_string(),
|
||||
..Config::default()
|
||||
};
|
||||
assert_eq!(custom.effective_new_file_template(), "# Mine\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_survives_a_save_load_round_trip() {
|
||||
let cfg = Config {
|
||||
new_file_template: "# Title: {{name}}\n{{date}}\n".to_string(),
|
||||
..Config::default()
|
||||
};
|
||||
let text = serde_json::to_string_pretty(&cfg).expect("serialize");
|
||||
let back: Config = serde_json::from_str(&text).expect("deserialize");
|
||||
assert_eq!(back.new_file_template, cfg.new_file_template);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user