8fdb9a4cbd
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
296 lines
11 KiB
Rust
296 lines
11 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use std::path::PathBuf;
|
|
|
|
/// Persistent application configuration (which workspace to open, plus UI prefs).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Config {
|
|
/// Directory that holds the markdown files and the git repository.
|
|
pub workspace: PathBuf,
|
|
/// Last used ODT export path.
|
|
pub export_path: PathBuf,
|
|
/// Whether to show the live preview pane.
|
|
#[serde(default)]
|
|
pub show_preview: bool,
|
|
/// Marker line separating a file's editorial header from its prose. Content
|
|
/// above it (except `# Title:` / `# Slug:` metadata) is dropped on export.
|
|
/// An empty value disables header splitting.
|
|
#[serde(default = "default_marker")]
|
|
pub draft_marker: String,
|
|
/// When a chapter title defaults to its position number, pad it with leading
|
|
/// zeros to the width of the largest chapter number (e.g. "03." of 12).
|
|
#[serde(default)]
|
|
pub zero_pad_index: bool,
|
|
/// Editor text zoom as a percentage offset from the base size (0 = default,
|
|
/// +100 = double, -50 = half).
|
|
#[serde(default)]
|
|
pub editor_zoom: f32,
|
|
/// Editor text contrast as a percentage: 0 = the theme's default text color,
|
|
/// 100 = maximum contrast against the background (white in dark mode, black
|
|
/// in light mode). Higher values make dim editor text easier to read.
|
|
#[serde(default)]
|
|
pub editor_text_contrast: f32,
|
|
/// URL scheme for the LanguageTool server: `http` (local) or `https`.
|
|
#[serde(default = "default_languagetool_scheme")]
|
|
pub languagetool_scheme: String,
|
|
/// Host of the LanguageTool server — an IP address or a domain name.
|
|
#[serde(default = "default_languagetool_host")]
|
|
pub languagetool_host: String,
|
|
/// TCP port of the LanguageTool server.
|
|
#[serde(default = "default_languagetool_port")]
|
|
pub languagetool_port: u16,
|
|
/// Optional bearer token sent in the `Authorization` header (for a server
|
|
/// behind an auth proxy, or the premium API key). Empty = no auth.
|
|
#[serde(default)]
|
|
pub languagetool_token: String,
|
|
/// Language passed to LanguageTool (`auto` to detect, or a code like `en-US`).
|
|
#[serde(default = "default_languagetool_language")]
|
|
pub languagetool_language: String,
|
|
/// Whether the offline (Hunspell) live spell checker underlines misspellings
|
|
/// as you type. Used whenever LanguageTool results aren't current.
|
|
#[serde(default = "default_spell_check")]
|
|
pub spell_check: bool,
|
|
/// Id of the spell-check dictionary to use (e.g. `en-CA`, `en_GB`), matching
|
|
/// a [`crate::spell::DictEntry::id`].
|
|
#[serde(default = "default_spell_language")]
|
|
pub spell_language: String,
|
|
/// Mistral API key for plot-beat generation. Stored in plain text; empty
|
|
/// disables the feature until set.
|
|
#[serde(default)]
|
|
pub mistral_api_key: String,
|
|
/// Mistral model id used for plot-beat generation (blank = the built-in default).
|
|
#[serde(default = "default_mistral_model")]
|
|
pub mistral_model: String,
|
|
/// 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.
|
|
pub fn default_mistral_model() -> String {
|
|
crate::mistral::DEFAULT_MODEL.to_string()
|
|
}
|
|
|
|
/// Default Mistral API base URL.
|
|
pub fn default_mistral_base_url() -> String {
|
|
crate::mistral::DEFAULT_BASE_URL.to_string()
|
|
}
|
|
|
|
/// Live spell checking is on by default.
|
|
pub fn default_spell_check() -> bool {
|
|
true
|
|
}
|
|
|
|
/// Default dictionary: the built-in Canadian English one.
|
|
pub fn default_spell_language() -> String {
|
|
crate::spell::DEFAULT_LANGUAGE.to_string()
|
|
}
|
|
|
|
/// Default scheme: plain HTTP, matching a local server.
|
|
pub fn default_languagetool_scheme() -> String {
|
|
"http".to_string()
|
|
}
|
|
|
|
/// Default host: the local machine.
|
|
pub fn default_languagetool_host() -> String {
|
|
"localhost".to_string()
|
|
}
|
|
|
|
/// Default port: the one the standalone LanguageTool server listens on.
|
|
pub fn default_languagetool_port() -> u16 {
|
|
8010
|
|
}
|
|
|
|
/// Default checking language: let the server auto-detect.
|
|
pub fn default_languagetool_language() -> String {
|
|
"auto".to_string()
|
|
}
|
|
|
|
/// Default header/body separator, matching the manuscript drafting convention.
|
|
pub fn default_marker() -> String {
|
|
"### Rough Draft:".to_string()
|
|
}
|
|
|
|
impl Default for Config {
|
|
fn default() -> Self {
|
|
let workspace = default_workspace();
|
|
let export_path = workspace.join("manuscript.odt");
|
|
Config {
|
|
workspace,
|
|
export_path,
|
|
show_preview: false,
|
|
draft_marker: default_marker(),
|
|
zero_pad_index: false,
|
|
editor_zoom: 0.0,
|
|
editor_text_contrast: 0.0,
|
|
languagetool_scheme: default_languagetool_scheme(),
|
|
languagetool_host: default_languagetool_host(),
|
|
languagetool_port: default_languagetool_port(),
|
|
languagetool_token: String::new(),
|
|
languagetool_language: default_languagetool_language(),
|
|
spell_check: default_spell_check(),
|
|
spell_language: default_spell_language(),
|
|
mistral_api_key: String::new(),
|
|
mistral_model: default_mistral_model(),
|
|
mistral_base_url: default_mistral_base_url(),
|
|
new_file_template: default_new_file_template(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The dedicated directory where manuscripts live by default: ~/Manuscript.
|
|
pub fn default_workspace() -> PathBuf {
|
|
dirs::home_dir()
|
|
.unwrap_or_else(|| PathBuf::from("."))
|
|
.join("Manuscript")
|
|
}
|
|
|
|
/// Location of the config file: ~/.config/md-manuscript/config.json
|
|
fn config_path() -> PathBuf {
|
|
dirs::config_dir()
|
|
.unwrap_or_else(|| PathBuf::from("."))
|
|
.join("md-manuscript")
|
|
.join("config.json")
|
|
}
|
|
|
|
impl Config {
|
|
/// Assemble the LanguageTool base URL (`scheme://host:port`) from its parts.
|
|
pub fn languagetool_base_url(&self) -> String {
|
|
let scheme = match self.languagetool_scheme.trim() {
|
|
"" => "http",
|
|
s => s,
|
|
};
|
|
format!(
|
|
"{scheme}://{}:{}",
|
|
self.languagetool_host.trim(),
|
|
self.languagetool_port
|
|
)
|
|
}
|
|
|
|
/// The Mistral model to use, falling back to the built-in default when blank.
|
|
pub fn mistral_effective_model(&self) -> String {
|
|
let m = self.mistral_model.trim();
|
|
if m.is_empty() {
|
|
crate::mistral::DEFAULT_MODEL.to_string()
|
|
} else {
|
|
m.to_string()
|
|
}
|
|
}
|
|
|
|
/// 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('/');
|
|
if b.is_empty() {
|
|
crate::mistral::DEFAULT_BASE_URL.to_string()
|
|
} else {
|
|
b.to_string()
|
|
}
|
|
}
|
|
|
|
pub fn load() -> Config {
|
|
let path = config_path();
|
|
match std::fs::read_to_string(&path) {
|
|
Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
|
|
Err(_) => Config::default(),
|
|
}
|
|
}
|
|
|
|
pub fn save(&self) {
|
|
let path = config_path();
|
|
if let Some(parent) = path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
if let Ok(text) = serde_json::to_string_pretty(self) {
|
|
let _ = std::fs::write(&path, text);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|