Files
md-manuscript/src/config.rs
T
landon f35397b49b Add title pages, standard manuscript format and collapsible panels
Five changes to the export and the window chrome:

* Manuscript details gains a contact field and a "Begin exports with a
  title page" option: the title, the author beneath it, and the contact
  details beneath that, laid out line for line as typed. A chapters +
  master export puts the title page on the master, not on each chapter.

* "Standard manuscript format" lays an export out the way an agent or an
  editor expects a submission: 12pt Courier, double-spaced, half-inch
  first-line indents, a Surname / Title / page header on every page but
  the title page, chapters opening a third of the way down, and `---`
  rendered as the conventional centred `#` scene break. The title page
  becomes the submission kind, with contact top-left and an approximate
  word count top-right. Off by default; margins were already the
  standard 1in on US Letter and are unchanged either way.

* A folder button beside Export ODT opens the project folder in the file
  manager -- the enclosing git work tree, including one still awaiting
  confirmation, since showing a folder is a smaller question than
  choosing which repository to commit to.

* The toolbar and the file list each collapse, from the pair of buttons
  at the right of the menu bar or from the View menu, and Ctrl+D folds
  both away together for distraction-free writing. Both choices persist.

* The editor sizes to its viewport instead of a fixed 30 rows, so the
  height a folded panel gives back reaches the page rather than leaving
  grey space below the text box that did not even take focus.

Two traps worth recording. ODF's style:master-page-name is inherited,
and any paragraph style carrying one forces a page break before every
paragraph that uses it -- a four-line contact block came out as four
pages until Contact_20_Line stopped inheriting from Contact_20_Block;
a test now pins which styles may carry one. And the status bar shares
a function with the toolbar, so guarding that function's top rather
than the top panel alone silently took the word counts away with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYSGPDwkSzhm4qLqjCbqxU
2026-08-25 17:58:59 -05:00

492 lines
19 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,
/// Whether the toolbar under the menu bar — the Workspace, Export, Grammar
/// and Spelling rows — is shown. Hiding it hands four rows of height back to
/// the editor; the menu bar keeps a toggle so it can be brought back.
#[serde(default = "default_show_toolbar")]
pub show_toolbar: bool,
/// Whether the left-hand file list is shown. Hiding it, with the toolbar,
/// leaves nothing on screen but the page.
#[serde(default = "default_show_toolbar")]
pub show_file_panel: bool,
/// Whether the editor hides the editorial header, showing the prose alone.
#[serde(default)]
pub collapse_header: bool,
/// Whether the file panel lists a project's reference files (characters,
/// outline, scratch pad) alongside the manuscript. Off by default: the panel
/// is for the book, and the reference material has windows of its own.
#[serde(default)]
pub show_reference_files: 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,
/// Directory holding the cookiecutter template used by **File ▸ New
/// project…** (the folder containing `cookiecutter.json`).
#[serde(default = "default_project_template")]
pub project_template: PathBuf,
/// Path to the `cookiecutter` executable. Blank means look on `PATH` and in
/// the usual per-user Python prefixes.
#[serde(default)]
pub cookiecutter_bin: String,
/// Subfolder of a freshly generated project to open as the workspace. Blank
/// (or a folder the template did not create) opens the project root.
#[serde(default = "default_project_open_subdir")]
pub project_open_subdir: String,
/// Whether to run the template's pre/post-generation hooks. The snowflake
/// template's post-gen hook publishes the project to Gitea, so this decides
/// whether creating a project also creates a remote repository.
#[serde(default = "default_project_run_hooks")]
pub project_run_hooks: bool,
/// Directory new projects are created in; remembered between runs.
#[serde(default = "default_projects_dir")]
pub projects_dir: PathBuf,
/// Author prefilled into the new-project dialog.
#[serde(default)]
pub project_author: String,
/// Base URL of the Gitea server, passed to a template's hooks as
/// `GITEA_URL`. Empty leaves the variable unset, which the snowflake hook
/// treats as "do not publish".
#[serde(default)]
pub gitea_url: String,
/// Gitea account name, passed to hooks as `GITEA_USER`.
#[serde(default)]
pub gitea_user: String,
/// Gitea API token, passed to hooks as `GITEA_TOKEN`. Stored in plain text,
/// like the Mistral key.
#[serde(default)]
pub gitea_token: String,
/// Folders kept out of the file panel entirely, matched leniently so
/// `Archive` covers `10-Archive`. An archive of dead drafts can hold
/// hundreds of files that would otherwise swamp the tree.
#[serde(default = "default_hidden_folders")]
pub hidden_folders: Vec<String>,
/// Folder the Archive action moves files into. Matched leniently against
/// what the project already has, so `10-Archive` is found and reused.
#[serde(default = "default_archive_folder")]
pub archive_folder: String,
/// Manuscript title written into exported documents. Blank falls back to the
/// project folder's name.
#[serde(default)]
pub manuscript_title: String,
/// Author written into exported documents.
#[serde(default)]
pub manuscript_author: String,
/// How to reach the author: whatever belongs under their name on a title
/// page — an address, an email, a phone number, an agent. Written a line
/// per line, so the layout typed here is the layout that is exported.
#[serde(default)]
pub manuscript_contact: String,
/// Whether an export opens with a title page carrying the title, the author
/// and their contact details.
#[serde(default)]
pub manuscript_title_page: bool,
/// Whether exports are laid out in standard manuscript format — 12pt
/// Courier, double-spaced, with a running header — which is the shape an
/// agent or editor expects a submission in.
#[serde(default)]
pub manuscript_standard_format: bool,
}
/// The toolbar and the file list are what the window opens with; hiding either
/// is a deliberate act, and a config written before the options existed should
/// not start hidden.
pub fn default_show_toolbar() -> bool {
true
}
/// Default cookiecutter template for **File ▸ New project…**.
pub fn default_project_template() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("Documents")
.join("Cookiecutters")
.join("snowflake")
}
/// Default subfolder opened after generating a project: the snowflake
/// template's drafting folder, where the chapter files live.
pub fn default_project_open_subdir() -> String {
"06-First Draft".to_string()
}
/// Templates ship hooks because they are meant to run; honouring them is the
/// default, and an unconfigured hook is expected to skip itself.
pub fn default_project_run_hooks() -> bool {
true
}
/// Folders excluded from the panel by default: the snowflake layout's archive,
/// which holds superseded drafts rather than working material.
pub fn default_hidden_folders() -> Vec<String> {
vec!["Archive".to_string()]
}
/// Default archive folder, matching the snowflake layout.
pub fn default_archive_folder() -> String {
"10-Archive".to_string()
}
/// Default parent directory for new projects.
pub fn default_projects_dir() -> PathBuf {
dirs::document_dir().unwrap_or_else(|| {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))
})
}
/// 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,
show_toolbar: default_show_toolbar(),
show_file_panel: default_show_toolbar(),
collapse_header: false,
show_reference_files: 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(),
project_template: default_project_template(),
cookiecutter_bin: String::new(),
project_open_subdir: default_project_open_subdir(),
project_run_hooks: default_project_run_hooks(),
projects_dir: default_projects_dir(),
project_author: String::new(),
gitea_url: String::new(),
gitea_user: String::new(),
gitea_token: String::new(),
hidden_folders: default_hidden_folders(),
archive_folder: default_archive_folder(),
manuscript_title: String::new(),
manuscript_author: String::new(),
manuscript_contact: String::new(),
manuscript_title_page: false,
manuscript_standard_format: false,
}
}
}
/// 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);
}
/// A config.json written before **File ▸ New project…** existed. Same risk
/// as above: a `#[serde(default)]` missing from any new field would make the
/// whole file fail to parse and reset the user's settings.
#[test]
fn config_without_the_project_fields_still_loads() {
let old = r####"{
"workspace": "/home/writer/Manuscript",
"export_path": "/home/writer/Manuscript/book.odt",
"draft_marker": "### Rough Draft:",
"mistral_api_key": "secret"
}"####;
let cfg: Config = serde_json::from_str(old).expect("old config must still parse");
assert_eq!(cfg.workspace, PathBuf::from("/home/writer/Manuscript"));
assert_eq!(cfg.mistral_api_key, "secret");
// Every new field arrives at its default rather than blank.
assert_eq!(cfg.project_open_subdir, default_project_open_subdir());
assert_eq!(cfg.project_template, default_project_template());
assert_eq!(cfg.projects_dir, default_projects_dir());
assert!(cfg.project_run_hooks, "hooks default to running");
// Credentials are absent until set; blank is what tells a hook to skip.
assert!(cfg.gitea_url.is_empty());
assert!(cfg.gitea_token.is_empty());
assert!(cfg.cookiecutter_bin.is_empty());
}
/// The folder opened after generating a project. It is the snowflake
/// template's drafting folder, and the name contains a space — which has to
/// survive being stored and joined onto the project path.
#[test]
fn the_default_project_subdir_is_the_drafting_folder() {
assert_eq!(default_project_open_subdir(), "06-First Draft");
let joined = PathBuf::from("/tmp/The Winter Gate").join(default_project_open_subdir());
assert_eq!(joined, PathBuf::from("/tmp/The Winter Gate/06-First Draft"));
}
/// A saved subfolder choice must win over the built-in default, so changing
/// the default never silently moves an existing user's projects.
#[test]
fn a_saved_project_subdir_is_honoured() {
let saved = r####"{
"workspace": "/w",
"export_path": "/w/b.odt",
"project_open_subdir": "05-Plot"
}"####;
let cfg: Config = serde_json::from_str(saved).expect("must parse");
assert_eq!(cfg.project_open_subdir, "05-Plot");
}
}