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:
@@ -71,7 +71,9 @@ Terminal=false
|
|||||||
enclosing repository**. Accept it to have **⟳ Sync** commit and push to that
|
enclosing repository**. Accept it to have **⟳ Sync** commit and push to that
|
||||||
repo; decline to leave the workspace on its own, where **Init git** creates a
|
repo; decline to leave the workspace on its own, where **Init git** creates a
|
||||||
separate repository in the folder.
|
separate repository in the folder.
|
||||||
3. Create files with **+ New**, edit on the right, `Ctrl+S` (or the Save button)
|
3. Create files with **+ New** (type a name first) or **+ New from template**
|
||||||
|
(no name needed — see [New files from a template](#new-files-from-a-template)),
|
||||||
|
edit on the right, `Ctrl+S` (or the Save button)
|
||||||
to write to disk. Drag the `⠿` handles to reorder. Use the **Zoom** slider
|
to write to disk. Drag the `⠿` handles to reorder. Use the **Zoom** slider
|
||||||
above the editor to scale the editor text (0% = default; **Reset** returns to
|
above the editor to scale the editor text (0% = default; **Reset** returns to
|
||||||
it), and the **Contrast** slider to brighten dim editor text (0% = theme
|
it), and the **Contrast** slider to brighten dim editor text (0% = theme
|
||||||
@@ -137,6 +139,49 @@ A single number (`## Word Count Target: 1800`) sets a point goal; ranges accept
|
|||||||
`-`, `–`, `to`, and grouped digits (`1,500`). Like the other header lines, the
|
`-`, `–`, `to`, and grouped digits (`1,500`). Like the other header lines, the
|
||||||
target is stripped from the exported document.
|
target is stripped from the exported document.
|
||||||
|
|
||||||
|
## New files from a template
|
||||||
|
|
||||||
|
The file list has two create buttons:
|
||||||
|
|
||||||
|
| Button | Name | Contents |
|
||||||
|
|---|---|---|
|
||||||
|
| **+ New** | the name you type beside it | `# <name>` and a blank line |
|
||||||
|
| **+ New from template** | auto-assigned `untitled-N.md` | the configured template |
|
||||||
|
|
||||||
|
**+ New from template** takes no typed name — it picks the first free
|
||||||
|
`untitled-N.md` (reusing a gap if you have deleted one), seeds it from the
|
||||||
|
template, appends it to the manuscript order and selects it. Rename it later with
|
||||||
|
the **Rename** box, or set a `# Title:` header and let the chapter title come
|
||||||
|
from that.
|
||||||
|
|
||||||
|
Edit the template under **Settings ▸ New-file template…**. The built-in default is:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Title: {{name}}
|
||||||
|
# Slug:
|
||||||
|
# POV:
|
||||||
|
# Word Count Target:
|
||||||
|
|
||||||
|
### Rough Draft:
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Three placeholders are expanded when the file is created:
|
||||||
|
|
||||||
|
| Placeholder | Expands to |
|
||||||
|
|---|---|
|
||||||
|
| `{{name}}` | the file stem, e.g. `untitled-3` |
|
||||||
|
| `{{marker}}` | the draft marker set in the top bar (default `### Rough Draft:`) |
|
||||||
|
| `{{date}}` | today's date in UTC, as `YYYY-MM-DD` |
|
||||||
|
|
||||||
|
Anything else in double braces is left alone. Using `{{marker}}` rather than
|
||||||
|
typing the marker literally keeps the template working if you change the marker
|
||||||
|
later. Emptying the box restores the built-in default, and **Reset to default**
|
||||||
|
does the same in one click.
|
||||||
|
|
||||||
|
The template is stored in `~/.config/md-manuscript/config.json`, so it is shared
|
||||||
|
by every workspace on the machine rather than committed with a manuscript.
|
||||||
|
|
||||||
## Spelling (offline)
|
## Spelling (offline)
|
||||||
|
|
||||||
Spelling is checked **live as you type**, entirely offline — no server, no
|
Spelling is checked **live as you type**, entirely offline — no server, no
|
||||||
@@ -286,7 +331,7 @@ only triggers in the header region, never in the prose below the marker.
|
|||||||
| `<workspace>/*.md` | your manuscript files |
|
| `<workspace>/*.md` | your manuscript files |
|
||||||
| `<workspace>/order.json` | the manual ordering (committed to git) |
|
| `<workspace>/order.json` | the manual ordering (committed to git) |
|
||||||
| `<workspace>/titles.json` | chapter-title overrides (committed to git) |
|
| `<workspace>/titles.json` | chapter-title overrides (committed to git) |
|
||||||
| `~/.config/md-manuscript/config.json` | last workspace, export path, prefs |
|
| `~/.config/md-manuscript/config.json` | last workspace, export path, prefs, new-file template |
|
||||||
|
|
||||||
## Markdown supported in ODT export
|
## Markdown supported in ODT export
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,16 @@ impl App {
|
|||||||
self.create_file();
|
self.create_file();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if ui
|
||||||
|
.button("+ New from template")
|
||||||
|
.on_hover_text(
|
||||||
|
"Create untitled-N.md seeded from the template \
|
||||||
|
(Settings ▸ New-file template…)",
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.create_file_from_template();
|
||||||
|
}
|
||||||
|
|
||||||
if self.selected.is_some() {
|
if self.selected.is_some() {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
|
|||||||
@@ -265,6 +265,8 @@ pub struct App {
|
|||||||
spell_menu: Option<usize>,
|
spell_menu: Option<usize>,
|
||||||
/// Whether the Mistral settings window is open.
|
/// Whether the Mistral settings window is open.
|
||||||
show_mistral_settings: bool,
|
show_mistral_settings: bool,
|
||||||
|
/// Whether the new-file template settings window is open.
|
||||||
|
show_template_settings: bool,
|
||||||
/// In-flight background plot-beat generation, if any.
|
/// In-flight background plot-beat generation, if any.
|
||||||
beats_rx: Option<std::sync::mpsc::Receiver<Result<String, String>>>,
|
beats_rx: Option<std::sync::mpsc::Receiver<Result<String, String>>>,
|
||||||
/// One-line status for the plot-beat generator.
|
/// One-line status for the plot-beat generator.
|
||||||
@@ -333,6 +335,7 @@ impl App {
|
|||||||
spell_status: String::new(),
|
spell_status: String::new(),
|
||||||
spell_menu: None,
|
spell_menu: None,
|
||||||
show_mistral_settings: false,
|
show_mistral_settings: false,
|
||||||
|
show_template_settings: false,
|
||||||
beats_rx: None,
|
beats_rx: None,
|
||||||
beats_status: String::new(),
|
beats_status: String::new(),
|
||||||
beats_output: None,
|
beats_output: None,
|
||||||
@@ -415,6 +418,10 @@ impl eframe::App for App {
|
|||||||
self.mistral_settings_window(ctx);
|
self.mistral_settings_window(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.show_template_settings {
|
||||||
|
self.template_settings_window(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
if self.beats_output.is_some() {
|
if self.beats_output.is_some() {
|
||||||
self.beats_window(ctx);
|
self.beats_window(ctx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,6 +273,10 @@ impl App {
|
|||||||
ui.close_menu();
|
ui.close_menu();
|
||||||
self.show_mistral_settings = true;
|
self.show_mistral_settings = true;
|
||||||
}
|
}
|
||||||
|
if ui.button("New-file template…").clicked() {
|
||||||
|
ui.close_menu();
|
||||||
|
self.show_template_settings = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
ui.menu_button("Help", |ui| {
|
ui.menu_button("Help", |ui| {
|
||||||
if ui.button("📝 Markdown cheatsheet").clicked() {
|
if ui.button("📝 Markdown cheatsheet").clicked() {
|
||||||
|
|||||||
@@ -29,10 +29,64 @@ pub(super) fn chrono_like_timestamp() -> String {
|
|||||||
format!("@{secs}")
|
format!("@{secs}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Today's UTC date as `YYYY-MM-DD`, for the `{{date}}` template placeholder.
|
||||||
|
/// UTC rather than local time so no timezone database is needed, keeping the
|
||||||
|
/// binary self-contained.
|
||||||
|
pub(super) fn today_utc() -> String {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
let secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let (y, m, d) = civil_from_days((secs / 86_400) as i64);
|
||||||
|
format!("{y:04}-{m:02}-{d:02}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Days since the Unix epoch -> civil `(year, month, day)`, using Howard
|
||||||
|
/// Hinnant's `civil_from_days`. Avoids a date-crate dependency.
|
||||||
|
pub(super) fn civil_from_days(days: i64) -> (i64, u32, u32) {
|
||||||
|
// Shift the epoch to 0000-03-01 so leap days land at the end of the cycle.
|
||||||
|
let z = days + 719_468;
|
||||||
|
let era = z.div_euclid(146_097);
|
||||||
|
let doe = z.rem_euclid(146_097); // day of era, 0..=146096
|
||||||
|
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||||
|
let y = yoe + era * 400;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of year, March-based
|
||||||
|
let mp = (5 * doy + 2) / 153; // March-based month, 0..=11
|
||||||
|
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||||
|
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||||
|
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn civil_from_days_matches_known_dates() {
|
||||||
|
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||||
|
// Straddles the 2000 leap day, the case the algorithm exists to get right.
|
||||||
|
assert_eq!(civil_from_days(11_017), (2000, 3, 1));
|
||||||
|
assert_eq!(civil_from_days(19_358), (2023, 1, 1));
|
||||||
|
assert_eq!(civil_from_days(19_723), (2024, 1, 1));
|
||||||
|
// Day before the epoch, to confirm the negative branch works.
|
||||||
|
assert_eq!(civil_from_days(-1), (1969, 12, 31));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn today_utc_is_a_well_formed_date() {
|
||||||
|
let s = today_utc();
|
||||||
|
assert_eq!(s.len(), 10, "expected YYYY-MM-DD, got {s}");
|
||||||
|
let parts: Vec<&str> = s.split('-').collect();
|
||||||
|
assert_eq!(parts.len(), 3);
|
||||||
|
let year: i64 = parts[0].parse().expect("year");
|
||||||
|
let month: u32 = parts[1].parse().expect("month");
|
||||||
|
let day: u32 = parts[2].parse().expect("day");
|
||||||
|
assert!(year >= 2024, "clock looks wrong: {s}");
|
||||||
|
assert!((1..=12).contains(&month));
|
||||||
|
assert!((1..=31).contains(&day));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn counts_words_across_whitespace() {
|
fn counts_words_across_whitespace() {
|
||||||
assert_eq!(count_words(""), 0);
|
assert_eq!(count_words(""), 0);
|
||||||
|
|||||||
+239
-3
@@ -212,14 +212,39 @@ impl App {
|
|||||||
if stem.to_lowercase().ends_with(".md") {
|
if stem.to_lowercase().ends_with(".md") {
|
||||||
stem.truncate(stem.len() - 3);
|
stem.truncate(stem.len() - 3);
|
||||||
}
|
}
|
||||||
let name = format!("{stem}.md");
|
let seed = format!("# {stem}\n\n");
|
||||||
|
self.insert_new_file(format!("{stem}.md"), seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a file seeded from the configured template, naming it
|
||||||
|
/// `untitled-N.md` so the button works without typing a name first.
|
||||||
|
pub(super) fn create_file_from_template(&mut self) {
|
||||||
|
let existing: std::collections::HashSet<String> =
|
||||||
|
self.files.iter().map(|n| n.to_lowercase()).collect();
|
||||||
|
let name = next_untitled_name(|candidate| {
|
||||||
|
existing.contains(&candidate.to_lowercase()) || self.path_for(candidate).exists()
|
||||||
|
});
|
||||||
|
let stem = name.trim_end_matches(".md").to_string();
|
||||||
|
let seed = render_template(
|
||||||
|
&self.config.effective_new_file_template(),
|
||||||
|
&stem,
|
||||||
|
self.config.draft_marker.trim(),
|
||||||
|
&today_utc(),
|
||||||
|
);
|
||||||
|
self.insert_new_file(name, seed);
|
||||||
|
// The template's header fields should be offerable straight away.
|
||||||
|
self.rebuild_field_names();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `contents` to a new file, append it to the manuscript order and
|
||||||
|
/// select it. Shared by the plain and template-backed create paths.
|
||||||
|
fn insert_new_file(&mut self, name: String, contents: String) {
|
||||||
let path = self.path_for(&name);
|
let path = self.path_for(&name);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
self.status = format!("{name} already exists");
|
self.status = format!("{name} already exists");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let seed = format!("# {stem}\n\n");
|
match std::fs::write(&path, contents) {
|
||||||
match std::fs::write(&path, seed) {
|
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
self.files.push(name.clone());
|
self.files.push(name.clone());
|
||||||
self.persist_order();
|
self.persist_order();
|
||||||
@@ -470,4 +495,215 @@ impl App {
|
|||||||
}
|
}
|
||||||
self.status = "Reordered".to_string();
|
self.status = "Reordered".to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Settings dialog for the markdown seeded into template-backed new files.
|
||||||
|
pub(super) fn template_settings_window(&mut self, ctx: &egui::Context) {
|
||||||
|
let mut open = self.show_template_settings;
|
||||||
|
let mut close_clicked = false;
|
||||||
|
egui::Window::new("New-file template")
|
||||||
|
.open(&mut open)
|
||||||
|
.resizable(true)
|
||||||
|
.collapsible(false)
|
||||||
|
.default_width(430.0)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
let mut save_now = false;
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(
|
||||||
|
"Seeded into files made with “+ New from template” in the file list.",
|
||||||
|
)
|
||||||
|
.small()
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
ui.add_space(4.0);
|
||||||
|
let r = ui.add(
|
||||||
|
egui::TextEdit::multiline(&mut self.config.new_file_template)
|
||||||
|
.code_editor()
|
||||||
|
.desired_width(f32::INFINITY)
|
||||||
|
.desired_rows(12),
|
||||||
|
);
|
||||||
|
save_now |= r.lost_focus();
|
||||||
|
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(egui::RichText::new("Placeholders").strong());
|
||||||
|
egui::Grid::new("template_placeholders")
|
||||||
|
.num_columns(2)
|
||||||
|
.spacing([10.0, 2.0])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
for (token, meaning) in [
|
||||||
|
("{{name}}", "the file stem, e.g. untitled-3"),
|
||||||
|
("{{marker}}", "the draft marker set in the toolbar"),
|
||||||
|
("{{date}}", "today's date (UTC), as YYYY-MM-DD"),
|
||||||
|
] {
|
||||||
|
ui.label(egui::RichText::new(token).monospace());
|
||||||
|
ui.label(egui::RichText::new(meaning).weak());
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(
|
||||||
|
"Leave the box empty to go back to the built-in default.",
|
||||||
|
)
|
||||||
|
.small()
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
ui.separator();
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("Close").clicked() {
|
||||||
|
close_clicked = true;
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.button("Reset to default")
|
||||||
|
.on_hover_text("Replace the box with the built-in template")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.config.new_file_template =
|
||||||
|
crate::config::default_new_file_template();
|
||||||
|
save_now = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if save_now {
|
||||||
|
self.config.save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let now_open = open && !close_clicked;
|
||||||
|
if self.show_template_settings && !now_open {
|
||||||
|
self.config.save();
|
||||||
|
}
|
||||||
|
self.show_template_settings = now_open;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First free `untitled-N.md`, so the template button needs no typed name.
|
||||||
|
/// `is_taken` reports names already used on disk or in the manuscript order.
|
||||||
|
pub(super) fn next_untitled_name(is_taken: impl Fn(&str) -> bool) -> String {
|
||||||
|
for n in 1..=9_999 {
|
||||||
|
let name = format!("untitled-{n}.md");
|
||||||
|
if !is_taken(&name) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Absurdly unlikely; fall back to something guaranteed unique-ish.
|
||||||
|
format!("untitled-{}.md", chrono_like_timestamp().trim_start_matches('@'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expand the template placeholders and guarantee a trailing newline.
|
||||||
|
pub(super) fn render_template(template: &str, stem: &str, marker: &str, date: &str) -> String {
|
||||||
|
let mut out = template
|
||||||
|
.replace("{{name}}", stem)
|
||||||
|
.replace("{{marker}}", marker)
|
||||||
|
.replace("{{date}}", date);
|
||||||
|
if !out.ends_with('\n') {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn untitled_name_fills_the_first_free_slot() {
|
||||||
|
assert_eq!(next_untitled_name(|_| false), "untitled-1.md");
|
||||||
|
let taken = ["untitled-1.md", "untitled-2.md"];
|
||||||
|
assert_eq!(
|
||||||
|
next_untitled_name(|n| taken.contains(&n)),
|
||||||
|
"untitled-3.md"
|
||||||
|
);
|
||||||
|
// A gap is reused rather than skipped.
|
||||||
|
let sparse = ["untitled-1.md", "untitled-3.md"];
|
||||||
|
assert_eq!(
|
||||||
|
next_untitled_name(|n| sparse.contains(&n)),
|
||||||
|
"untitled-2.md"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_template_expands_every_placeholder() {
|
||||||
|
let out = render_template(
|
||||||
|
"# Title: {{name}}\n# Started: {{date}}\n\n{{marker}}\n",
|
||||||
|
"untitled-7",
|
||||||
|
"### Rough Draft:",
|
||||||
|
"2026-08-22",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
"# Title: untitled-7\n# Started: 2026-08-22\n\n### Rough Draft:\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_template_repeats_and_leaves_unknown_tokens_alone() {
|
||||||
|
let out = render_template("{{name}}/{{name}} {{nope}}", "a", "M", "D");
|
||||||
|
assert_eq!(out, "a/a {{nope}}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_template_always_ends_with_a_newline() {
|
||||||
|
assert!(render_template("no trailing newline", "n", "m", "d").ends_with('\n'));
|
||||||
|
// An already-terminated template doesn't collect a second one.
|
||||||
|
assert_eq!(render_template("done\n", "n", "m", "d"), "done\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_template_tolerates_an_empty_marker() {
|
||||||
|
// An empty draft_marker disables header splitting; the line goes blank.
|
||||||
|
assert_eq!(render_template("a\n{{marker}}\nb\n", "n", "", "d"), "a\n\nb\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The point of the default template is that the app's own header parser
|
||||||
|
/// understands what it produces — otherwise the seeded fields are just text.
|
||||||
|
#[test]
|
||||||
|
fn default_template_round_trips_through_the_header_parser() {
|
||||||
|
let marker = crate::config::default_marker();
|
||||||
|
let seeded = render_template(
|
||||||
|
&crate::config::default_new_file_template(),
|
||||||
|
"chapter-01",
|
||||||
|
&marker,
|
||||||
|
"2026-08-22",
|
||||||
|
);
|
||||||
|
let h = crate::preprocess::parse(&seeded, &marker);
|
||||||
|
assert_eq!(h.title.as_deref(), Some("chapter-01"));
|
||||||
|
// Slug/POV are seeded blank, so they parse as absent rather than empty.
|
||||||
|
assert_eq!(h.slug, None);
|
||||||
|
assert_eq!(h.pov, None);
|
||||||
|
assert_eq!(h.goal, None);
|
||||||
|
// Nothing above the marker leaks into the exported prose.
|
||||||
|
assert_eq!(h.body.trim(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A filled-in template parses into the fields the file list displays.
|
||||||
|
#[test]
|
||||||
|
fn filled_template_parses_into_header_fields() {
|
||||||
|
let marker = crate::config::default_marker();
|
||||||
|
let seeded = render_template(
|
||||||
|
"# Title: {{name}}\n# Slug: the door\n# POV: Ada\n\
|
||||||
|
# Word Count Target: 1500 - 2000\n\n{{marker}}\n\nReal prose here.\n",
|
||||||
|
"chapter-02",
|
||||||
|
&marker,
|
||||||
|
"2026-08-22",
|
||||||
|
);
|
||||||
|
let h = crate::preprocess::parse(&seeded, &marker);
|
||||||
|
assert_eq!(h.title.as_deref(), Some("chapter-02"));
|
||||||
|
assert_eq!(h.slug.as_deref(), Some("the door"));
|
||||||
|
assert_eq!(h.pov.as_deref(), Some("Ada"));
|
||||||
|
assert!(h.goal.is_some(), "word-count target should parse");
|
||||||
|
assert_eq!(h.body.trim(), "Real prose here.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_template_renders_to_a_usable_header() {
|
||||||
|
let out = render_template(
|
||||||
|
&crate::config::default_new_file_template(),
|
||||||
|
"chapter-01",
|
||||||
|
"### Rough Draft:",
|
||||||
|
"2026-08-22",
|
||||||
|
);
|
||||||
|
assert!(out.starts_with("# Title: chapter-01\n"), "got {out:?}");
|
||||||
|
assert!(out.contains("\n### Rough Draft:\n"), "got {out:?}");
|
||||||
|
assert!(!out.contains("{{"), "placeholder left unexpanded in {out:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,27 @@ pub struct Config {
|
|||||||
/// Mistral API base URL (blank = the built-in default). Override for a proxy.
|
/// Mistral API base URL (blank = the built-in default). Override for a proxy.
|
||||||
#[serde(default = "default_mistral_base_url")]
|
#[serde(default = "default_mistral_base_url")]
|
||||||
pub mistral_base_url: String,
|
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.
|
/// Default Mistral model.
|
||||||
@@ -132,6 +153,7 @@ impl Default for Config {
|
|||||||
mistral_api_key: String::new(),
|
mistral_api_key: String::new(),
|
||||||
mistral_model: default_mistral_model(),
|
mistral_model: default_mistral_model(),
|
||||||
mistral_base_url: default_mistral_base_url(),
|
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.
|
/// The Mistral base URL to use, falling back to the built-in default when blank.
|
||||||
pub fn mistral_effective_base_url(&self) -> String {
|
pub fn mistral_effective_base_url(&self) -> String {
|
||||||
let b = self.mistral_base_url.trim().trim_end_matches('/');
|
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