Add project-mode tooling: characters, outline, diff and revision status

Rounds out project mode, where the workspace is the project root and one
subfolder holds the manuscript proper:

- Characters and Outline windows, backed by new `characters` and `outline`
  modules that read the cast from character sheets and measure how much of
  the snowflake outline is actually written.
- Edit ▸ Changes… diffs the open file against its last committed version.
- Revision status, per-file and project word counts, an archive action and
  hidden folders in the file panel.
- Chapter-file export alongside the ODT master, richer header parsing, and
  a project word list for names and invented terms.
- The export path now follows the workspace: opening a project points it at
  that project root, keeping a file name you chose yourself and re-deriving
  one that merely echoed the folder it sat in.
- Clicking an issue in the grammar/spelling panel takes the editor to it,
  selecting the words and centring them; applying a suggestion jumps to the
  rewritten text as well.

README covers the new windows and workflows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bSn3Xijp8GofZVUnRX4oq
This commit is contained in:
2026-08-24 19:56:36 -05:00
parent e951d57d45
commit 043cc692ac
23 changed files with 3879 additions and 144 deletions
+174
View File
@@ -145,6 +145,50 @@ 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.
## Project mode
Open a **project root** — a folder holding the manuscript alongside the
characters, outline, premise and the rest — and the app recognises the layout
instead of treating every file in it as a chapter.
The trigger is simple: if the folder you open contains a subfolder named like
the **manuscript folder** (`06-First Draft` by default), it is a project. A
plain folder of chapters has no such subfolder, so it behaves exactly as before
— everything in it is the manuscript.
In a project:
* **Files under the manuscript folder are the book.** They are ordered,
numbered, and they are what **Export ODT** concatenates. Chapter numbering
counts only them, so a defaulted title is `3.` because it is the third
*chapter*, not the third file in the project.
* **Everything else is reference** — character sheets, the outline, the scratch
pad — and is **not listed in the file panel**, which stays about the book.
Reference material is reached through the windows that understand it
([Characters](#characters-tools--characters), [Outline](#outline-tools--outline)),
and it stays out of the order and out of the export.
Turn on **View ▸ Show reference files** to list it in the tree anyway, dimmed,
with the manuscript folder tagged **· manuscript**.
* `order.json`, `titles.json` and `wordlist.json` live at the **project root**,
so one word list covers the whole project rather than one folder of it.
* **File ▸ New project…** opens the project root, not the drafting folder.
Set which folder is the manuscript under **Settings ▸ New project… ▸ Manuscript
folder**. The number is optional — `First Draft` matches `06-First Draft` — and
the setting is global, so every project on this machine uses the same layout.
If your writing actually lives in `05-Plot`, point it there.
### Hidden folders
An archive of superseded drafts can hold hundreds of files that would swamp the
tree, so folders named like **`Archive`** are skipped by the scan entirely — the
same treatment `target/` and `node_modules/` already get. The match ignores a
leading number, so `10-Archive` is covered.
Change the list with `hidden_folders` in
`~/.config/md-manuscript/config.json`; set it to `[]` to see everything.
## Organising with folders ## Organising with folders
The file list is a tree. Any `*.md` file at or below the workspace is part of the The file list is a tree. Any `*.md` file at or below the workspace is part of the
@@ -182,6 +226,101 @@ bottom**, so the order you see down the left is the order the chapters are
concatenated in, folders and all. Chapter numbering follows the same sequence, concatenated in, folders and all. Chapter numbering follows the same sequence,
so a defaulted title in `part-2/` continues counting from `part-1/`. so a defaulted title in `part-2/` continues counting from `part-1/`.
## Working on the novel's other parts
A manuscript project is more than its chapters. These read the rest of it.
### Hiding the header while you write
A scene card's fields sit at the top of the same file as its prose, and once
drafting is under way they are mostly in the way. **View ▸ Hide header in
editor** folds away everything above the draft marker, leaving the prose alone
in the editor. A single line above the editor says so — `▸ header hidden (8
fields)` — and clicking it flips back.
While the header is hidden it is genuinely **not in the editor's text**: the
file is put back together on save, so what lands on disk is unchanged. Doing it
that way rather than merely scrolling past the header means search, replace,
spelling underlines and the caret all address exactly what you can see, with no
offsets to go wrong. Everything that wants the whole file — the chapter title
hint, the word-count target, the file-list tooltip — still reads it whole.
Files with no draft marker have nothing to fold, so the line does not appear.
### Characters (Tools ▸ Characters…)
Reads the project's character sheets — a file whose level-1 heading is the name,
as either `# Bixby Reynolds` or `# Name: Jonathan Bronski`. For each character
it shows:
* their descriptor, from a `Slug:` field or the heading beneath the name;
* **which scenes they appear in**, by matching the `Characters:` line of every
manuscript file. A first name matches a full name, so `Characters: Bixby, Mom`
finds *Bixby Reynolds*. Click a scene to open it;
* **▦ Rebuild grid**, which regenerates the project's `character_grid.md` from
the sheets as a markdown table. A hand-kept grid drifts the moment a sheet
changes; this one cannot. Long values are trimmed, since a grid is a
quick-look summary.
### Outline (Tools ▸ Outline…)
The snowflake outline files are scaffolds of HTML comments, one per beat
(`<!-- Midpoint: A major turning point. -->`). The app strips comments, so
these files otherwise render and export as *blank* — there is no way to see how
much of the outline exists.
This window reads the scaffold back: every beat it names, and whether prose has
been written under it, with a progress bar over the whole outline. A beat counts
as written when there is ordinary text between its prompt and the next one.
Beats generated by [the Mistral tool](#plot-beats-mistral) can be appended
straight into the matching act file with **📝 Append to act_0N.md**, which
appears when a run filled exactly one act. It appends under a dated comment
rather than replacing, because the prompts are the reason the file is worth
keeping.
### Revision status
Give a file a `Status:` header line and the file panel shows a compact badge for
it (initials, so `Structural Edit` becomes `SE`) and offers a **Status** filter
above the list. The filter narrows the tree to one stage — every file you have
still to proofread, say.
### Archive
**🗄 Archive** moves the selected file into the project's archive folder,
keeping its place inside it (`Act 1/scene.md` → `10-Archive/Act 1/scene.md`).
It leaves the manuscript order and, since archive folders are
[hidden](#hidden-folders), the file panel — but stays on disk. Deleting is for
things you want gone; this is for things you have moved past.
### Changes since the last commit (Edit ▸ Changes…)
Diffs the open file against its committed version, coloured the way git colours
a diff. Revising is comparing, and the repository already holds the answer.
### Word counts
The status bar carries the open file's word count, its change this session, and
the **whole manuscript's total**. Reference files are not counted.
### Manuscript details (Settings ▸ Manuscript details…)
Title and author, written into exported `.odt` files as their document
properties — what a word processor shows under File ▸ Properties. A blank title
uses the project folder's name. Exports previously carried no metadata at all.
### Exporting chapter files (File ▸ Export chapters + master)
Writes one `.odt` per chapter into a `chapters/` folder, plus an `.odm` master
document that links them — the shape the template's `Full Text.odm` implies.
**The per-chapter files are the reliable part**: they are ordinary documents and
open anywhere. The master is a shell with a title page and one linked section
per chapter; LibreOffice does not follow those links when it opens the file, so
treat it as a starting point to relink rather than as the assembled book. For a
single finished document, use **Export ODT**.
## New projects from a cookiecutter template ## New projects from a cookiecutter template
**File ▸ ✨ New project…** scaffolds a whole manuscript project from a **File ▸ ✨ New project…** scaffolds a whole manuscript project from a
@@ -300,6 +439,37 @@ results take over the underlines until you next edit the text — at which point
the live offline checker resumes. In other words, LanguageTool is used when it's the live offline checker resumes. In other words, LanguageTool is used when it's
available and current; the offline checker is the always-on default. available and current; the offline checker is the always-on default.
### The word list (names and invented terms)
No Hunspell dictionary knows your characters, so without somewhere to record
them a novel's cast is underlined on every line of every scene. **Settings ▸
Word list…** is that place.
* **Right-click an underlined word** in the editor and choose
**📗 Add “…” to the word list**. The underline goes immediately — there is no
wait for the next check.
* **👤 Add character names** reads the project's character sheets and lists
every name in them at once. It looks for a `Characters` folder (a numeric
prefix like `03-Characters` is fine) beside the workspace and up to three
levels above it, so it still finds them while you are editing in
`06-First Draft`. Both `# Bixby Reynolds` and `# Name: Jonathan Bronski`
headings are understood, and an unfilled template sheet (`# Name`,
`# Protagonist`) contributes nothing. Each part of a name is listed
separately, since prose says *Bixby* far more often than *Bixby Reynolds*.
* Words can also be **typed in** directly, and taken back out with **✖**.
Matching is case-insensitive, and a listed name covers its **possessive and
plural** — `Bixby` accepts `Bixby's`, `Bixbys` and `Bixbys'` — because the
tokenizer hands the checker `Bixby's` as a single word.
The list is saved as **`wordlist.json`** in the workspace, beside `order.json`
and `titles.json`, so it is committed to git and travels with the manuscript. It
is a plain sorted JSON array, and a corrupt or hand-mangled file is treated as
empty rather than stopping the checker.
Note that the list belongs to the *workspace*, not the whole project — open a
different folder of the same project and it has its own list.
The bundled dictionaries live under `dictionaries/` and are derived from The bundled dictionaries live under `dictionaries/` and are derived from
[SCOWL](http://wordlist.sourceforge.net/) under a permissive license (kept [SCOWL](http://wordlist.sourceforge.net/) under a permissive license (kept
alongside them in each `license` file). alongside them in each `license` file).
@@ -467,8 +637,12 @@ only triggers in the header region, never in the prose below the marker.
| `<workspace>/**/*.md` | your manuscript files, in folders if you like | | `<workspace>/**/*.md` | your manuscript files, in folders if you like |
| `<workspace>/order.json` | the manual ordering, as paths (committed to git) | | `<workspace>/order.json` | the manual ordering, as paths (committed to git) |
| `<workspace>/titles.json` | chapter-title overrides, keyed by path (committed to git) | | `<workspace>/titles.json` | chapter-title overrides, keyed by path (committed to git) |
| `<workspace>/wordlist.json` | names the spell checker accepts (committed to git) |
| `~/.config/md-manuscript/config.json` | last workspace, export path, prefs, new-file template, project/Gitea settings | | `~/.config/md-manuscript/config.json` | last workspace, export path, prefs, new-file template, project/Gitea settings |
In [project mode](#project-mode) the workspace is the project root, so those
three bookkeeping files sit there and cover the whole project.
## Markdown supported in ODT export ## Markdown supported in ODT export
Headings, paragraphs, **bold**, *italic*, ***both***, `inline code`, fenced/indented Headings, paragraphs, **bold**, *italic*, ***both***, `inline code`, fenced/indented
+1 -1
View File
@@ -26,7 +26,7 @@ impl App {
/// Fold any new header field names from the current buffer into the list, so /// Fold any new header field names from the current buffer into the list, so
/// a field you just invented autocompletes without reopening the workspace. /// a field you just invented autocompletes without reopening the workspace.
pub(super) fn merge_field_names_from_buffer(&mut self) { pub(super) fn merge_field_names_from_buffer(&mut self) {
let found = header_field_names(&self.buffer, &self.config.draft_marker); let found = header_field_names(&self.document(), &self.config.draft_marker);
let mut added = false; let mut added = false;
for f in found { for f in found {
if !self.field_names.iter().any(|n| n.eq_ignore_ascii_case(&f)) { if !self.field_names.iter().any(|n| n.eq_ignore_ascii_case(&f)) {
+78
View File
@@ -144,6 +144,9 @@ impl App {
let mut open = true; let mut open = true;
let mut close = false; let mut close = false;
let mut save = false; let mut save = false;
let mut append_to_outline = false;
// Where these beats belong in the project's outline, if it has one.
let outline_target = self.outline_target_for_beats();
let running = self.beats_rx.is_some(); let running = self.beats_rx.is_some();
// Edit a local copy so the button row can borrow `self` mutably; persist // Edit a local copy so the button row can borrow `self` mutably; persist
// any edits back into `beats_output` afterwards. // any edits back into `beats_output` afterwards.
@@ -182,6 +185,18 @@ impl App {
{ {
save = true; save = true;
} }
if let Some(target) = &outline_target {
let label = format!("📝 Append to {}", base_name(target));
if ui
.add_enabled(have, egui::Button::new(label))
.on_hover_text(format!(
"Add these beats to {target}, where the outline lives"
))
.clicked()
{
append_to_outline = true;
}
}
if ui.add_enabled(have, egui::Button::new("⧉ Copy")).clicked() { if ui.add_enabled(have, egui::Button::new("⧉ Copy")).clicked() {
ui.output_mut(|o| o.copied_text = text.clone()); ui.output_mut(|o| o.copied_text = text.clone());
self.beats_status = "Copied to clipboard.".to_string(); self.beats_status = "Copied to clipboard.".to_string();
@@ -198,6 +213,11 @@ impl App {
} }
if save { if save {
self.save_beats_as_file(&text); self.save_beats_as_file(&text);
}
if append_to_outline {
if let Some(target) = outline_target {
self.append_beats_to_outline(&target, &text);
}
} else if close || !open { } else if close || !open {
self.beats_output = None; self.beats_output = None;
self.beats_status.clear(); self.beats_status.clear();
@@ -279,6 +299,64 @@ impl App {
} }
self.show_mistral_settings = now_open; self.show_mistral_settings = now_open;
} }
/// The outline file these beats belong in, as a workspace-relative path.
///
/// A fill-the-gaps run wrote exactly one act, and the scene-breakdown
/// scaffold has a file per act, so the two line up: `Act Two` goes to the
/// file whose name carries a 2. Anything less clear-cut gets no target,
/// since appending to the wrong act would be worse than not offering it.
pub(super) fn outline_target_for_beats(&self) -> Option<String> {
let [act] = self.beats_filled.as_slice() else {
return None;
};
let digit = match act {
crate::mistral::Act::One => '1',
crate::mistral::Act::Two => '2',
crate::mistral::Act::Three => '3',
};
let dir = self.outline_dir()?;
markdown_files_under(&dir)
.into_iter()
.filter(|path| {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_lowercase();
stem.contains("act") && stem.contains(digit)
})
.find_map(|path| self.relative_to_workspace(&path))
}
/// Append generated beats to an outline file, under a heading that says
/// where they came from. Appending rather than replacing matters: the
/// scaffold's prompts are the reason the file is worth keeping.
pub(super) fn append_beats_to_outline(&mut self, rel: &str, beats: &str) {
let path = self.path_for(rel);
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let mut out = existing;
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str(&format!(
"\n<!-- Generated beats, {} -->\n\n{}\n",
today_utc(),
beats.trim()
));
match std::fs::write(&path, out) {
Ok(_) => {
self.beats_status = format!("Appended to {rel}");
self.beats_output = None;
// Open it so the result is in front of the user.
if let Some(idx) = self.files.iter().position(|f| f == rel) {
self.selected = None;
self.select(idx);
}
}
Err(e) => self.beats_status = format!("Could not write {rel}: {e}"),
}
}
} }
/// Join act names for a status line or title: "Act Two", "Act Two and Act /// Join act names for a status line or title: "Act Two", "Act Two and Act
+200
View File
@@ -0,0 +1,200 @@
//! The Characters window: the project's cast, where each of them appears, and
//! keeping the summary grid in step with the sheets.
use super::*;
impl App {
/// (Re)read the project's character sheets.
pub(super) fn load_characters(&mut self) {
self.characters = match self.characters_dir() {
Some(dir) => crate::characters::read_sheets(&dir, &self.config.draft_marker),
None => Vec::new(),
};
}
/// Manuscript files whose `Characters:` line names `character`, as
/// workspace-relative paths.
///
/// This is the continuity question the app can answer that a folder of
/// markdown cannot: once a draft is long, "which scenes is she actually in?"
/// stops being answerable from memory.
pub(super) fn scenes_with(&self, character: &crate::characters::Character) -> Vec<String> {
self.files
.iter()
.filter(|name| self.is_manuscript(name))
.filter(|name| {
let Some(text) = std::fs::read_to_string(self.path_for(name)).ok() else {
return false;
};
let Some(line) =
crate::preprocess::field(&text, &self.config.draft_marker, "Characters")
else {
return false;
};
crate::preprocess::value_items(&line)
.iter()
.any(|mention| character.is_named_by(mention))
})
.cloned()
.collect()
}
/// Write the generated character grid over the project's grid file.
fn regenerate_character_grid(&mut self) {
let Some(dir) = self.characters_dir() else {
self.status = "No character folder found near this workspace".to_string();
return;
};
let markdown = crate::characters::grid_markdown(&self.characters);
// Reuse the grid file the project already has, wherever it sits.
let existing = crate::characters::grid_file(&dir);
let path = existing.unwrap_or_else(|| dir.join("character_grid").join("character_grid.md"));
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
self.status = format!("Could not write the grid: {e}");
return;
}
}
match std::fs::write(&path, markdown) {
Ok(_) => {
self.status = format!(
"Wrote {} character(s) to {}",
self.characters.len(),
path.display()
);
// The grid may be one of the workspace's own files.
self.file_meta = self.snapshot_file_meta();
}
Err(e) => self.status = format!("Could not write the grid: {e}"),
}
}
/// The Characters window.
pub(super) fn characters_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_characters;
let mut close = false;
let mut regenerate = false;
let mut reload = false;
// A scene or sheet the user asked to open, as a workspace-relative path.
let mut open_file: Option<String> = None;
egui::Window::new("Characters")
.open(&mut open)
.resizable(true)
.collapsible(false)
.default_width(420.0)
.show(ctx, |ui| {
match self.characters_dir() {
Some(dir) => {
ui.label(
egui::RichText::new(format!("Sheets in {}", dir.display()))
.small()
.weak(),
);
}
None => {
ui.label(
egui::RichText::new(
"No character folder found near this workspace.",
)
.weak(),
);
}
}
ui.horizontal(|ui| {
if ui.button("⟳ Reload sheets").clicked() {
reload = true;
}
if ui
.add_enabled(
!self.characters.is_empty(),
egui::Button::new("▦ Rebuild grid"),
)
.on_hover_text(
"Regenerate the character grid from the sheets, replacing \
whatever is in it",
)
.clicked()
{
regenerate = true;
}
});
ui.separator();
if self.characters.is_empty() {
ui.label(egui::RichText::new("No character sheets read yet.").weak());
}
egui::ScrollArea::vertical()
.auto_shrink([false, true])
.max_height(420.0)
.show(ui, |ui| {
for character in &self.characters {
let scenes = self.scenes_with(character);
let header = format!(
"{} ({} scene{})",
character.name,
scenes.len(),
if scenes.len() == 1 { "" } else { "s" }
);
egui::CollapsingHeader::new(header)
.id_salt(&character.name)
.show(ui, |ui| {
if let Some(tagline) = character.tagline() {
ui.label(egui::RichText::new(tagline).italics().weak());
}
if ui.link("open sheet").clicked() {
if let Some(rel) = self.relative_to_workspace(
&character.file,
) {
open_file = Some(rel);
}
}
if scenes.is_empty() {
ui.label(
egui::RichText::new(
"Not named in any scene's Characters: line.",
)
.small()
.weak(),
);
}
for scene in &scenes {
if ui.link(scene).clicked() {
open_file = Some(scene.clone());
}
}
});
}
});
ui.separator();
if ui.button("Close").clicked() {
close = true;
}
});
if reload {
self.load_characters();
}
if regenerate {
self.regenerate_character_grid();
}
if let Some(path) = open_file {
if let Some(idx) = self.files.iter().position(|f| *f == path) {
self.select(idx);
} else {
self.status = format!("{path} is not in this workspace");
}
}
self.show_characters = open && !close;
}
/// Express an absolute path as workspace-relative, if it is inside.
pub(super) fn relative_to_workspace(&self, path: &Path) -> Option<String> {
path.strip_prefix(self.workspace())
.ok()?
.to_str()
.map(|s| s.replace('\\', "/"))
}
}
+99
View File
@@ -0,0 +1,99 @@
//! Comparing the open file with its last committed version.
use super::*;
impl App {
/// Load the diff for the current file, ready for the diff window.
pub(super) fn open_diff(&mut self) {
self.save_current();
let Some(name) = self.selected.and_then(|i| self.files.get(i)).cloned() else {
self.status = "Open a file first".to_string();
return;
};
// Git is run from the work tree, and the workspace may be below it.
let root = self.repo_root.clone();
let Some(root) = root else {
self.diff_text = "This workspace is not in a git repository.".to_string();
self.show_diff = true;
return;
};
if !gitsync::has_commits(&root) {
self.diff_text = "The repository has no commits yet, so there is \
nothing to compare against."
.to_string();
self.show_diff = true;
return;
}
let abs = self.path_for(&name);
let rel = abs
.strip_prefix(&root)
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|_| name.clone());
self.diff_text = match gitsync::diff_file(&root, &rel) {
Ok(text) if text.trim().is_empty() => {
format!("{name} matches the last commit — no changes.")
}
Ok(text) => text,
Err(e) => format!("Could not diff {name}:\n{e}"),
};
self.diff_title = name;
self.show_diff = true;
}
/// The diff window: added and removed lines since the last commit.
pub(super) fn diff_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_diff;
let mut refresh = false;
egui::Window::new(format!("Changes — {}", self.diff_title))
.open(&mut open)
.resizable(true)
.collapsible(false)
.default_width(680.0)
.default_height(460.0)
.show(ctx, |ui| {
ui.horizontal(|ui| {
if ui.button("⟳ Refresh").clicked() {
refresh = true;
}
ui.label(
egui::RichText::new("compared with the last commit")
.small()
.weak(),
);
});
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
for line in self.diff_text.lines() {
// Colour the diff the way git does, so additions and
// removals are separable at a glance.
let (color, strong) = match line.as_bytes().first() {
Some(b'+') if !line.starts_with("+++") => {
(Some(egui::Color32::from_rgb(0x3F, 0x9E, 0x4F)), false)
}
Some(b'-') if !line.starts_with("---") => {
(Some(egui::Color32::from_rgb(0xC0, 0x50, 0x50)), false)
}
Some(b'@') => {
(Some(egui::Color32::from_rgb(0x3B, 0x82, 0xF6)), true)
}
_ => (None, false),
};
let mut text = egui::RichText::new(line).monospace();
if let Some(color) = color {
text = text.color(color);
}
if strong {
text = text.strong();
}
ui.label(text);
}
});
});
if refresh {
self.open_diff();
}
self.show_diff = open;
}
}
+84 -1
View File
@@ -110,7 +110,7 @@ impl App {
self.buffer = new_text; self.buffer = new_text;
self.dirty = true; self.dirty = true;
// Restore the caret/selection around the change. // Restore the caret/selection around the change.
let mut state = output.state; let mut state = output.state.clone();
state.cursor.set_char_range(Some(egui::text::CCursorRange::two( state.cursor.set_char_range(Some(egui::text::CCursorRange::two(
egui::text::CCursor::new(new_sel.start), egui::text::CCursor::new(new_sel.start),
egui::text::CCursor::new(new_sel.end), egui::text::CCursor::new(new_sel.end),
@@ -120,6 +120,32 @@ impl App {
} }
} }
// Take the editor to an issue picked in the results panel:
// select the words, centre them, and hand focus back so the
// user can type the correction straight away.
if let Some((start, end)) = self.issue_jump.take() {
let (cs, ce) = (
byte_to_char(&self.buffer, start),
byte_to_char(&self.buffer, end),
);
let rect = output
.galley
.pos_from_ccursor(egui::text::CCursor::new(cs))
.union(output.galley.pos_from_ccursor(egui::text::CCursor::new(ce)))
.translate(output.galley_pos.to_vec2());
ui.scroll_to_rect(rect, Some(egui::Align::Center));
let mut state = output.state.clone();
state
.cursor
.set_char_range(Some(egui::text::CCursorRange::two(
egui::text::CCursor::new(cs),
egui::text::CCursor::new(ce),
)));
state.store(ui.ctx(), output.response.id);
ui.ctx().memory_mut(|m| m.request_focus(output.response.id));
ui.ctx().request_repaint();
}
// Scroll the active search match into view when requested (on // Scroll the active search match into view when requested (on
// open, Next/Prev, Enter, or after a replace). // open, Next/Prev, Enter, or after a replace).
if self.find_scroll { if self.find_scroll {
@@ -162,6 +188,15 @@ impl App {
Some((i, reps)) Some((i, reps))
}); });
let mut chosen: Option<(usize, usize)> = None; let mut chosen: Option<(usize, usize)> = None;
// The word under the menu, so it can be added to the word list.
let target_word: Option<String> = self.spell_menu.and_then(|i| {
let m = self.current_matches().get(i)?;
m.spelling
.then(|| self.buffer.get(m.start..m.end))
.flatten()
.map(str::to_string)
});
let mut accept: Option<String> = None;
output.response.context_menu(|ui| { output.response.context_menu(|ui| {
match &menu { match &menu {
Some((i, reps)) if !reps.is_empty() => { Some((i, reps)) if !reps.is_empty() => {
@@ -180,7 +215,25 @@ impl App {
ui.label(egui::RichText::new("No spelling issue here").weak()); ui.label(egui::RichText::new("No spelling issue here").weak());
} }
} }
// A character or place name is not a misspelling; let it be
// accepted for good rather than corrected every time.
if let Some(word) = &target_word {
ui.separator();
if ui
.button(format!("📗 Add “{word}” to the word list"))
.on_hover_text(
"Accept this word from now on, for this workspace",
)
.clicked()
{
accept = Some(word.clone());
ui.close_menu();
}
}
}); });
if let Some(word) = accept {
self.add_to_dictionary(&word);
}
if let Some((i, j)) = chosen { if let Some((i, j)) = chosen {
self.apply_current_fix(i, j); self.apply_current_fix(i, j);
self.spell_menu = None; self.spell_menu = None;
@@ -401,6 +454,14 @@ pub(super) fn char_to_byte(s: &str, char_idx: usize) -> usize {
.unwrap_or(s.len()) .unwrap_or(s.len())
} }
/// Character index of byte offset `byte` in `s`, clamped to the end. An offset
/// landing inside a character — a stale match against an edited buffer — is
/// clamped to the next character boundary, matching how [`crate::langtool`]
/// resolves its UTF-16 offsets, so a jump never lands mid-character.
pub(super) fn byte_to_char(s: &str, byte: usize) -> usize {
s.char_indices().take_while(|(b, _)| *b < byte).count()
}
/// Apply an inline-format action to `text` given a selection in *character* /// Apply an inline-format action to `text` given a selection in *character*
/// indices, returning the new text and the new selection (also character indices). /// indices, returning the new text and the new selection (also character indices).
/// ///
@@ -470,6 +531,28 @@ pub(super) fn apply_format(
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn byte_to_char_is_the_inverse_of_char_to_byte() {
// "café!" — é is 2 bytes, so byte and char indices diverge after it.
let text = "café!";
for (i, _) in text.char_indices() {
assert_eq!(char_to_byte(text, byte_to_char(text, i)), i);
}
assert_eq!(byte_to_char(text, 0), 0);
assert_eq!(byte_to_char(text, 3), 3); // é
assert_eq!(byte_to_char(text, 5), 4); // '!' sits after the 2-byte é
}
#[test]
fn byte_to_char_clamps_past_the_end_and_inside_a_character() {
let text = "a😀b";
assert_eq!(byte_to_char(text, text.len()), 3);
assert_eq!(byte_to_char(text, 99), 3);
// Byte 3 is inside the 4-byte emoji, so it clamps forward to the
// boundary after it rather than splitting the character.
assert_eq!(byte_to_char(text, 3), 2);
}
#[test] #[test]
fn wraps_a_selection_in_bold_and_keeps_it_selected() { fn wraps_a_selection_in_bold_and_keeps_it_selected() {
let (text, sel) = apply_format("a word here", 2..6, Fmt::Bold); let (text, sel) = apply_format("a word here", 2..6, Fmt::Bold);
+202 -38
View File
@@ -21,8 +21,9 @@ pub(super) enum RowKind {
/// A folder header: how many markdown files live under it at any depth, and /// A folder header: how many markdown files live under it at any depth, and
/// the flat index of the first of them (where a drop into the folder lands). /// the flat index of the first of them (where a drop into the folder lands).
Folder { count: usize, first: usize }, Folder { count: usize, first: usize },
/// A file, by its index into [`App::files`]. /// A file. The panel resolves its own index against `App::files`, because a
File { idx: usize }, /// status filter means the row list and the file list no longer line up.
File,
} }
/// Where a dragged file was let go: which file moved, the flat position it /// Where a dragged file was let go: which file moved, the flat position it
@@ -96,7 +97,7 @@ pub(super) fn build_rows(files: &[String], collapsed: &HashSet<String>) -> Vec<R
rows.push(Row { rows.push(Row {
path: path.clone(), path: path.clone(),
depth: parts.len(), depth: parts.len(),
kind: RowKind::File { idx }, kind: RowKind::File,
}); });
} }
} }
@@ -108,6 +109,10 @@ impl App {
egui::SidePanel::left("files") egui::SidePanel::left("files")
.resizable(true) .resizable(true)
.default_width(260.0) .default_width(260.0)
// A hard ceiling: the panel is sized from its content, so any row
// that asks for more width than there is would otherwise push it
// wider every frame.
.width_range(160.0..=460.0)
.show(ctx, |ui| { .show(ctx, |ui| {
// The default theme renders unselected list rows fairly dim; bump // The default theme renders unselected list rows fairly dim; bump
// the widget text colours so file names stay legible (especially in // the widget text colours so file names stay legible (especially in
@@ -120,13 +125,71 @@ impl App {
.small() .small()
.weak(), .weak(),
); );
if self.manuscript_dir.is_some() {
let note = if self.config.show_reference_files {
"dimmed = reference, not part of the book"
} else {
"manuscript only · View ▸ Show reference files"
};
ui.label(egui::RichText::new(note).small().weak());
}
let statuses = self.known_statuses();
if !statuses.is_empty() {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Status:").small().weak());
let current = self
.status_filter
.clone()
.unwrap_or_else(|| "all".to_string());
egui::ComboBox::from_id_salt("status_filter")
.selected_text(egui::RichText::new(current).small())
.show_ui(ui, |ui| {
if ui
.selectable_label(self.status_filter.is_none(), "all")
.clicked()
{
self.status_filter = None;
}
for status in &statuses {
let picked = self
.status_filter
.as_deref()
.is_some_and(|s| s == status);
if ui.selectable_label(picked, status).clicked() {
self.status_filter = Some(status.clone());
}
}
});
});
}
ui.separator(); ui.separator();
let mut clicked: Option<usize> = None; let mut clicked: Option<usize> = None;
let mut toggled: Option<String> = None; let mut toggled: Option<String> = None;
let mut dropped: Option<FileDrop> = None; let mut dropped: Option<FileDrop> = None;
let pointer = ui.input(|i| i.pointer.interact_pos()); let pointer = ui.input(|i| i.pointer.interact_pos());
let rows = build_rows(&self.files, &self.collapsed); // Filtering happens on the flat list, so folders left with no
// files simply stop appearing.
let show_reference = self.config.show_reference_files;
let visible: Vec<String> = self
.files
.iter()
.filter(|name| show_reference || self.is_manuscript(name))
.filter(|name| match &self.status_filter {
None => true,
Some(want) => self
.file_meta
.get(*name)
.and_then(|m| m.status.as_deref())
.is_some_and(|s| s.eq_ignore_ascii_case(want)),
})
.cloned()
.collect();
let rows = build_rows(&visible, &self.collapsed);
// Measured once, from the panel rather than from the scrolled
// content: reading `available_width()` inside a row makes the
// content's width depend on the content's width.
let row_width = ui.available_width();
let nested = rows.iter().any(|r| r.depth > 0); let nested = rows.iter().any(|r| r.depth > 0);
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
@@ -138,6 +201,12 @@ impl App {
match row.kind { match row.kind {
RowKind::Folder { count, first } => { RowKind::Folder { count, first } => {
let open = !self.collapsed.contains(&row.path); let open = !self.collapsed.contains(&row.path);
// In a project, one folder holds the book.
let is_manuscript_root = self
.manuscript_dir
.as_deref()
.is_some_and(|d| d == row.path);
let in_book = self.is_manuscript(&row.path);
let header = ui let header = ui
.horizontal(|ui| { .horizontal(|ui| {
ui.add_space(indent); ui.add_space(indent);
@@ -146,12 +215,26 @@ impl App {
"{arrow} 🗀 {}", "{arrow} 🗀 {}",
base_name(&row.path) base_name(&row.path)
); );
let text = egui::RichText::new(label);
let text = if in_book {
text.strong()
} else {
text.weak()
};
// Sized from the panel and truncated:
// an unconstrained label is as wide as
// its text, and the panel is sized from
// its content, so a long folder name
// would widen the panel and keep it
// widened. The full path is on hover.
let name_w =
(row_width - indent - COUNT_W).max(48.0);
if ui if ui
.add( .add_sized(
egui::Button::new( [name_w, 20.0],
egui::RichText::new(label).strong(), egui::Button::new(text)
) .frame(false)
.frame(false), .truncate(),
) )
.on_hover_text(&row.path) .on_hover_text(&row.path)
.clicked() .clicked()
@@ -163,35 +246,59 @@ impl App {
.small() .small()
.weak(), .weak(),
); );
// Claim the rest of the line so the if is_manuscript_root {
// whole row is a drop target. ui.label(
ui.allocate_space(egui::vec2( egui::RichText::new("· manuscript")
ui.available_width(), .small()
0.0, .weak(),
)); )
.on_hover_text(
"These files are the book: ordered, \
numbered and exported. Everything \
else in the project is reference.",
);
}
}) })
.response; .response;
// Widened only for hit-testing and painting,
// which cannot affect the layout's width.
let header = full_width_row(ui, &header, &row.path);
if drop_highlight(ui, &header) { if drop_highlight(ui, &header) {
if let Some(payload) = if let Some(payload) =
header.dnd_release_payload::<usize>() header.dnd_release_payload::<usize>()
{ {
// `first` indexes the filtered list.
let to = visible
.get(first)
.and_then(|n| {
self.files.iter().position(|f| f == n)
})
.unwrap_or(self.files.len());
dropped = Some(FileDrop { dropped = Some(FileDrop {
from: *payload, from: *payload,
to: first, to,
dir: row.path.clone(), dir: row.path.clone(),
}); });
} }
} }
} }
RowKind::File { idx } => { RowKind::File => {
let name = row.path.clone(); let name = row.path.clone();
// `build_rows` indexed the filtered list; the
// rest of the app speaks in `files` indices.
let Some(idx) =
self.files.iter().position(|f| *f == name)
else {
continue;
};
let selected = self.selected == Some(idx); let selected = self.selected == Some(idx);
let in_book = self.is_manuscript(&name);
// The selected file's fields are read live from // The selected file's fields are read live from
// the buffer (so unsaved edits show); others come // the buffer (so unsaved edits show); others come
// from the cache filled on open/save. // from the cache filled on open/save.
let meta = if selected { let meta = if selected {
FileMeta::from_markdown( FileMeta::from_markdown(
&self.buffer, &self.document(),
&self.config.draft_marker, &self.config.draft_marker,
) )
} else { } else {
@@ -220,14 +327,16 @@ impl App {
} else { } else {
0.0 0.0
}; };
let label_w = let label_w = (row_width
(ui.available_width() - reserve).max(24.0); - indent
- HANDLE_W
- reserve)
.max(24.0);
let text = egui::RichText::new(base_name(&name));
let text = if in_book { text } else { text.weak() };
let mut label = ui.add_sized( let mut label = ui.add_sized(
[label_w, 20.0], [label_w, 20.0],
egui::SelectableLabel::new( egui::SelectableLabel::new(selected, text),
selected,
base_name(&name),
),
); );
if let Some(tooltip) = &tooltip { if let Some(tooltip) = &tooltip {
label = label.on_hover_text(tooltip); label = label.on_hover_text(tooltip);
@@ -243,6 +352,14 @@ impl App {
meta.prose_words, meta.prose_words,
); );
} }
if let Some(status) = &meta.status {
ui.label(
egui::RichText::new(status_tag(status))
.small()
.weak(),
)
.on_hover_text(format!("Status: {status}"));
}
}) })
.response; .response;
@@ -292,9 +409,9 @@ impl App {
.small() .small()
.weak(), .weak(),
); );
ui.allocate_space(egui::vec2(ui.available_width(), 0.0));
}) })
.response; .response;
let target = full_width_row(ui, &target, "\u{0}root-drop");
if drop_highlight(ui, &target) { if drop_highlight(ui, &target) {
if let Some(payload) = target.dnd_release_payload::<usize>() { if let Some(payload) = target.dnd_release_payload::<usize>() {
dropped = Some(FileDrop { dropped = Some(FileDrop {
@@ -360,6 +477,18 @@ impl App {
self.rename_selected(); self.rename_selected();
} }
}); });
ui.horizontal(|ui| {
if ui
.button("🗄 Archive")
.on_hover_text(
"Move this file into the archive folder and out of \
the manuscript, keeping it on disk",
)
.clicked()
{
self.archive_selected();
}
});
ui.horizontal(|ui| { ui.horizontal(|ui| {
if !self.pending_delete { if !self.pending_delete {
if ui.button("🗑 Delete").clicked() { if ui.button("🗑 Delete").clicked() {
@@ -397,6 +526,41 @@ fn drop_highlight(ui: &egui::Ui, response: &egui::Response) -> bool {
true true
} }
/// Width the drag handle occupies in a file row, so a row's label can be sized
/// from the panel width rather than from whatever is left of the content.
const HANDLE_W: f32 = 22.0;
/// Room left after a folder's name for its file count and the manuscript tag.
const COUNT_W: f32 = 86.0;
/// A response covering the whole visible width of `ui` at the row's height.
///
/// Rows want to be drop targets across their full width, but *claiming* that
/// width makes the content as wide as the panel, and the panel is sized from
/// its content — which grows it, frame after frame. Interacting with a rect
/// taken from the clip rectangle sidesteps that: it is the visible area, not
/// the content, so it cannot feed back into the layout.
fn full_width_row(ui: &egui::Ui, response: &egui::Response, key: &str) -> egui::Response {
let rect = egui::Rect::from_x_y_ranges(ui.clip_rect().x_range(), response.rect.y_range());
ui.interact(rect, egui::Id::new(("row", key)), egui::Sense::hover())
}
/// A compact badge for a `Status:` value: the first letters of its words, so a
/// long stage name still fits beside a file name.
pub(super) fn status_tag(status: &str) -> String {
let initials: String = status
.split_whitespace()
.filter_map(|w| w.chars().find(|c| c.is_alphanumeric()))
.collect();
if initials.chars().count() >= 2 {
initials.to_uppercase()
} else {
status.chars().take(4).collect::<String>().to_uppercase()
}
}
/// Resolve a chapter's title: a non-empty manual `override_title` wins, then the /// Resolve a chapter's title: a non-empty manual `override_title` wins, then the
/// `# Title:` header value, otherwise the chapter's 1-based position followed by /// `# Title:` header value, otherwise the chapter's 1-based position followed by
/// a period (e.g. "3."), zero-padded to `pad_width` digits (`1` = no padding). /// a period (e.g. "3."), zero-padded to `pad_width` digits (`1` = no padding).
@@ -512,7 +676,7 @@ mod tests {
RowKind::Folder { count, first } => { RowKind::Folder { count, first } => {
format!("{}:dir({count},{first}):{}", r.depth, r.path) format!("{}:dir({count},{first}):{}", r.depth, r.path)
} }
RowKind::File { idx } => format!("{}:file({idx}):{}", r.depth, r.path), RowKind::File => format!("{}:file:{}", r.depth, r.path),
}) })
.collect() .collect()
} }
@@ -521,7 +685,7 @@ mod tests {
fn flat_files_get_no_folder_rows() { fn flat_files_get_no_folder_rows() {
assert_eq!( assert_eq!(
sketch(&["a.md", "b.md"], &[]), sketch(&["a.md", "b.md"], &[]),
vec!["0:file(0):a.md", "0:file(1):b.md"] vec!["0:file:a.md", "0:file:b.md"]
); );
} }
@@ -531,9 +695,9 @@ mod tests {
sketch(&["p/a.md", "p/b.md", "top.md"], &[]), sketch(&["p/a.md", "p/b.md", "top.md"], &[]),
vec![ vec![
"0:dir(2,0):p", "0:dir(2,0):p",
"1:file(0):p/a.md", "1:file:p/a.md",
"1:file(1):p/b.md", "1:file:p/b.md",
"0:file(2):top.md", "0:file:top.md",
] ]
); );
} }
@@ -546,8 +710,8 @@ mod tests {
// `p` counts both files; `q` only its own. // `p` counts both files; `q` only its own.
"0:dir(2,0):p", "0:dir(2,0):p",
"1:dir(1,0):p/q", "1:dir(1,0):p/q",
"2:file(0):p/q/a.md", "2:file:p/q/a.md",
"1:file(1):p/b.md", "1:file:p/b.md",
] ]
); );
} }
@@ -556,7 +720,7 @@ mod tests {
fn collapsing_a_folder_hides_its_files_but_keeps_its_header() { fn collapsing_a_folder_hides_its_files_but_keeps_its_header() {
assert_eq!( assert_eq!(
sketch(&["p/a.md", "p/b.md", "top.md"], &["p"]), sketch(&["p/a.md", "p/b.md", "top.md"], &["p"]),
vec!["0:dir(2,0):p", "0:file(2):top.md"] vec!["0:dir(2,0):p", "0:file:top.md"]
); );
} }
@@ -564,12 +728,12 @@ mod tests {
fn collapsing_hides_nested_headers_too() { fn collapsing_hides_nested_headers_too() {
assert_eq!( assert_eq!(
sketch(&["p/q/a.md", "p/b.md", "top.md"], &["p"]), sketch(&["p/q/a.md", "p/b.md", "top.md"], &["p"]),
vec!["0:dir(2,0):p", "0:file(2):top.md"] vec!["0:dir(2,0):p", "0:file:top.md"]
); );
// Collapsing only the inner folder leaves the outer one drawn. // Collapsing only the inner folder leaves the outer one drawn.
assert_eq!( assert_eq!(
sketch(&["p/q/a.md", "p/b.md"], &["p/q"]), sketch(&["p/q/a.md", "p/b.md"], &["p/q"]),
vec!["0:dir(2,0):p", "1:dir(1,0):p/q", "1:file(1):p/b.md"] vec!["0:dir(2,0):p", "1:dir(1,0):p/q", "1:file:p/b.md"]
); );
} }
@@ -581,7 +745,7 @@ mod tests {
vec![ vec![
"0:dir(1,0):part-1", "0:dir(1,0):part-1",
"0:dir(1,1):part-10", "0:dir(1,1):part-10",
"1:file(1):part-10/b.md", "1:file:part-10/b.md",
] ]
); );
} }
@@ -592,9 +756,9 @@ mod tests {
sketch(&["p/a.md", "q/b.md"], &[]), sketch(&["p/a.md", "q/b.md"], &[]),
vec![ vec![
"0:dir(1,0):p", "0:dir(1,0):p",
"1:file(0):p/a.md", "1:file:p/a.md",
"0:dir(1,1):q", "0:dir(1,1):q",
"1:file(1):q/b.md", "1:file:q/b.md",
] ]
); );
} }
+51 -10
View File
@@ -113,13 +113,15 @@ impl App {
} }
/// Apply a LanguageTool suggestion to the buffer, then shift the remaining /// Apply a LanguageTool suggestion to the buffer, then shift the remaining
/// matches so their highlights stay valid. /// matches so their highlights stay valid. Returns whether the fix landed.
pub(super) fn apply_replacement(&mut self, match_idx: usize, rep_idx: usize) { pub(super) fn apply_replacement(&mut self, match_idx: usize, rep_idx: usize) -> bool {
// Only safe while the buffer still matches what was checked. // Only safe while the buffer still matches what was checked.
if self.buffer != self.lt_checked_text { if self.buffer != self.lt_checked_text {
return; return false;
}
if !splice_fix(&mut self.buffer, &mut self.lt_matches, match_idx, rep_idx) {
return false;
} }
if splice_fix(&mut self.buffer, &mut self.lt_matches, match_idx, rep_idx) {
self.dirty = true; self.dirty = true;
self.find_needs_refresh = true; self.find_needs_refresh = true;
self.lt_checked_text = self.buffer.clone(); self.lt_checked_text = self.buffer.clone();
@@ -128,16 +130,35 @@ impl App {
1 => "1 issue".to_string(), 1 => "1 issue".to_string(),
n => format!("{n} issues"), n => format!("{n} issues"),
}; };
} true
} }
/// Apply suggestion `rep_idx` of the match at `match_idx` from whichever /// Apply suggestion `rep_idx` of the match at `match_idx` from whichever
/// source currently owns the issues (LanguageTool or the spell checker). /// source currently owns the issues (LanguageTool or the spell checker),
/// then send the editor to the text that was rewritten.
pub(super) fn apply_current_fix(&mut self, match_idx: usize, rep_idx: usize) { pub(super) fn apply_current_fix(&mut self, match_idx: usize, rep_idx: usize) {
match self.issue_source() { // Where the replacement will sit once spliced in, worked out before the
// splice shifts every match after it.
let target = self.current_matches().get(match_idx).and_then(|m| {
m.replacements
.get(rep_idx)
.map(|rep| (m.start, m.start + rep.len()))
});
let applied = match self.issue_source() {
IssueSource::LanguageTool => self.apply_replacement(match_idx, rep_idx), IssueSource::LanguageTool => self.apply_replacement(match_idx, rep_idx),
IssueSource::Spell => self.apply_spell_fix(match_idx, rep_idx), IssueSource::Spell => self.apply_spell_fix(match_idx, rep_idx),
IssueSource::None => {} IssueSource::None => false,
};
if applied {
self.issue_jump = target;
}
}
/// Send the editor to the issue at `idx`, selecting it so it is obvious
/// which words the panel entry was talking about.
pub(super) fn jump_to_issue(&mut self, idx: usize) {
if let Some(m) = self.current_matches().get(idx) {
self.issue_jump = Some((m.start, m.end));
} }
} }
@@ -348,6 +369,7 @@ impl App {
} }
let mut apply: Option<(usize, usize)> = None; let mut apply: Option<(usize, usize)> = None;
let mut jump: Option<usize> = None;
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.auto_shrink([false, false]) .auto_shrink([false, false])
.show(ui, |ui| { .show(ui, |ui| {
@@ -359,12 +381,19 @@ impl App {
egui::Color32::from_rgb(0x3B, 0x82, 0xF6) egui::Color32::from_rgb(0x3B, 0x82, 0xF6)
}; };
ui.label(egui::RichText::new("").color(col)); ui.label(egui::RichText::new("").color(col));
// The snippet and the explanation are both live:
// clicking either takes the editor to the issue.
let mut clicked = false;
if !item.snippet.is_empty() { if !item.snippet.is_empty() {
ui.label( clicked |= go_to_label(
ui,
egui::RichText::new(format!("{}", item.snippet)).strong(), egui::RichText::new(format!("{}", item.snippet)).strong(),
); );
} }
ui.label(&item.message); clicked |= go_to_label(ui, egui::RichText::new(&item.message));
if clicked {
jump = Some(item.idx);
}
}); });
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
ui.add_space(16.0); ui.add_space(16.0);
@@ -384,13 +413,25 @@ impl App {
} }
}); });
// A fix wins over a bare jump: it moves the editor there too.
if let Some((i, j)) = apply { if let Some((i, j)) = apply {
self.apply_current_fix(i, j); self.apply_current_fix(i, j);
} else if let Some(i) = jump {
self.jump_to_issue(i);
} }
}); });
} }
} }
/// A panel label that behaves like a link to a place in the text: hand cursor,
/// hover hint, and `true` on the frame it is clicked.
fn go_to_label(ui: &mut egui::Ui, text: egui::RichText) -> bool {
ui.add(egui::Label::new(text).sense(egui::Sense::click()))
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text("Go to this spot in the text")
.clicked()
}
/// Apply suggestion `rep_idx` of `matches[match_idx]` to `buffer` in place and /// Apply suggestion `rep_idx` of `matches[match_idx]` to `buffer` in place and
/// remap the remaining matches. Returns whether a replacement was made (false if /// remap the remaining matches. Returns whether a replacement was made (false if
/// the indices are out of range or the match's bounds aren't valid boundaries). /// the indices are out of range or the match's bounds aren't valid boundaries).
+73 -6
View File
@@ -16,9 +16,12 @@ use std::time::{Duration, Instant};
mod autocomplete; mod autocomplete;
mod beats; mod beats;
mod characters;
mod diff;
mod editor; mod editor;
mod file_list; mod file_list;
mod find; mod find;
mod outline;
mod project; mod project;
mod grammar; mod grammar;
mod spelling; mod spelling;
@@ -33,6 +36,7 @@ use self::autocomplete::*;
use self::file_list::*; use self::file_list::*;
use self::grammar::*; use self::grammar::*;
use self::project::*; use self::project::*;
use self::spelling::*;
use self::style::*; use self::style::*;
use self::util::*; use self::util::*;
@@ -70,6 +74,8 @@ struct FileMeta {
goal: Option<crate::preprocess::WordGoal>, goal: Option<crate::preprocess::WordGoal>,
/// Prose (body) word count captured with the rest of this metadata. /// Prose (body) word count captured with the rest of this metadata.
prose_words: usize, prose_words: usize,
/// The `Status:` line, if any — the revision stage this file has reached.
status: Option<String>,
} }
impl FileMeta { impl FileMeta {
@@ -81,14 +87,10 @@ impl FileMeta {
pov: h.pov, pov: h.pov,
goal: h.goal, goal: h.goal,
prose_words: count_words(&h.body), prose_words: count_words(&h.body),
status: crate::preprocess::field(text, marker, "Status"),
} }
} }
/// Whether this entry carries anything worth caching/showing.
fn has_display(&self) -> bool {
self.slug.is_some() || self.pov.is_some() || self.goal.is_some()
}
/// The tooltip text — a `POV:` line then the slug synopsis — or `None` when /// The tooltip text — a `POV:` line then the slug synopsis — or `None` when
/// the file carries neither field. /// the file carries neither field.
fn tooltip(&self) -> Option<String> { fn tooltip(&self) -> Option<String> {
@@ -163,6 +165,10 @@ struct IssueItem {
pub struct App { pub struct App {
config: Config, config: Config,
/// The manuscript folder within the workspace when the workspace is a
/// project root — files under it are the book, everything else is
/// reference. `None` when the workspace is itself the manuscript.
manuscript_dir: Option<String>,
/// Ordered markdown files, as workspace-relative paths with `/` separators /// Ordered markdown files, as workspace-relative paths with `/` separators
/// (`part-1/ch-03.md`). Always held in folder-tree order — see /// (`part-1/ch-03.md`). Always held in folder-tree order — see
/// [`crate::order::tree_order`] — so this list reads exactly as the file /// [`crate::order::tree_order`] — so this list reads exactly as the file
@@ -177,8 +183,11 @@ pub struct App {
/// Editing buffer for the selected file's chapter title override. /// Editing buffer for the selected file's chapter title override.
title_input: String, title_input: String,
selected: Option<usize>, selected: Option<usize>,
/// Editor contents for the selected file. /// Editor contents for the selected file. While the header is collapsed
/// this is the prose only; [`App::document`] puts the file back together.
buffer: String, buffer: String,
/// The editorial header lifted out of `buffer` while it is collapsed.
header_stash: Option<String>,
dirty: bool, dirty: bool,
/// Editable copy of the workspace path shown in the top bar. /// Editable copy of the workspace path shown in the top bar.
workspace_input: String, workspace_input: String,
@@ -268,9 +277,33 @@ pub struct App {
spell_rx: Option<std::sync::mpsc::Receiver<SpellCheckMsg>>, spell_rx: Option<std::sync::mpsc::Receiver<SpellCheckMsg>>,
/// One-line status for the spell checker (dictionary name, count, or error). /// One-line status for the spell checker (dictionary name, count, or error).
spell_status: String, spell_status: String,
/// Names and invented terms the checker accepts on top of the dictionary,
/// loaded from the workspace's `wordlist.json`.
wordlist: crate::spell::WordList,
/// Whether the word-list window is open.
show_wordlist: bool,
/// When set, the file panel shows only files whose `Status:` matches.
status_filter: Option<String>,
/// The project's cast, read from its character sheets.
characters: Vec<crate::characters::Character>,
/// Whether the Characters window is open.
show_characters: bool,
/// Whether the manuscript-details window is open.
show_manuscript_settings: bool,
/// Whether the Outline window is open.
show_outline: bool,
/// Whether the diff window is open, and what it is showing.
show_diff: bool,
diff_text: String,
diff_title: String,
/// Text field for adding a word by hand.
wordlist_input: String,
/// Index (into the currently displayed matches) of the word a right-click /// Index (into the currently displayed matches) of the word a right-click
/// suggestion menu is open for, if any. /// suggestion menu is open for, if any.
spell_menu: Option<usize>, spell_menu: Option<usize>,
/// Byte range in `buffer` the editor should select and scroll into view next
/// frame, set when an issue is clicked in the results panel.
issue_jump: Option<(usize, 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. /// Whether the new-file template settings window is open.
@@ -312,12 +345,14 @@ impl App {
workspace_input: config.workspace.display().to_string(), workspace_input: config.workspace.display().to_string(),
export_input: config.export_path.display().to_string(), export_input: config.export_path.display().to_string(),
config, config,
manuscript_dir: None,
files: Vec::new(), files: Vec::new(),
collapsed: HashSet::new(), collapsed: HashSet::new(),
titles: HashMap::new(), titles: HashMap::new(),
title_input: String::new(), title_input: String::new(),
selected: None, selected: None,
buffer: String::new(), buffer: String::new(),
header_stash: None,
dirty: false, dirty: false,
new_name: String::new(), new_name: String::new(),
rename_input: String::new(), rename_input: String::new(),
@@ -360,7 +395,19 @@ impl App {
spell_last_edit: None, spell_last_edit: None,
spell_rx: None, spell_rx: None,
spell_status: String::new(), spell_status: String::new(),
wordlist: crate::spell::WordList::default(),
show_wordlist: false,
status_filter: None,
characters: Vec::new(),
show_characters: false,
show_manuscript_settings: false,
show_outline: false,
show_diff: false,
diff_text: String::new(),
diff_title: String::new(),
wordlist_input: String::new(),
spell_menu: None, spell_menu: None,
issue_jump: None,
show_mistral_settings: false, show_mistral_settings: false,
show_template_settings: false, show_template_settings: false,
beats_rx: None, beats_rx: None,
@@ -471,6 +518,26 @@ impl eframe::App for App {
self.beats_window(ctx); self.beats_window(ctx);
} }
if self.show_wordlist {
self.wordlist_window(ctx);
}
if self.show_characters {
self.characters_window(ctx);
}
if self.show_manuscript_settings {
self.manuscript_settings_window(ctx);
}
if self.show_outline {
self.outline_window(ctx);
}
if self.show_diff {
self.diff_window(ctx);
}
if self.show_cheatsheet { if self.show_cheatsheet {
crate::help::cheatsheet_window(ctx, &mut self.show_cheatsheet); crate::help::cheatsheet_window(ctx, &mut self.show_cheatsheet);
} }
+160
View File
@@ -0,0 +1,160 @@
//! The Outline window: how much of the project's outline is actually written.
use super::*;
/// One outline file and the state of the beats in it.
pub(super) struct OutlineFile {
/// Workspace-relative path, so it can be opened in the editor.
pub rel: String,
pub beats: Vec<crate::outline::Beat>,
}
impl App {
/// The project's outline folder, looked for beside the workspace and up
/// through its ancestors, the same way the character folder is found.
pub(super) fn outline_dir(&self) -> Option<PathBuf> {
const MAX_UP: usize = 3;
let mut dir = Some(self.workspace());
for _ in 0..=MAX_UP {
let current = dir?;
if let Some(name) = order::child_dir_matching(current, "Outline") {
return Some(current.join(name));
}
dir = current.parent();
}
None
}
/// Read every outline file and the beats in it.
pub(super) fn read_outline(&self) -> Vec<OutlineFile> {
let Some(dir) = self.outline_dir() else {
return Vec::new();
};
let mut out = Vec::new();
for path in markdown_files_under(&dir) {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let beats = crate::outline::beats(&text);
if beats.is_empty() {
continue;
}
let rel = self
.relative_to_workspace(&path)
.unwrap_or_else(|| path.display().to_string());
out.push(OutlineFile { rel, beats });
}
out
}
/// The Outline window: a checklist of the beats the scaffold names.
pub(super) fn outline_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_outline;
let mut close = false;
let mut open_file: Option<String> = None;
let files = self.read_outline();
egui::Window::new("Outline")
.open(&mut open)
.resizable(true)
.collapsible(false)
.default_width(440.0)
.show(ctx, |ui| {
match self.outline_dir() {
Some(dir) => {
ui.label(
egui::RichText::new(format!("Scaffold in {}", dir.display()))
.small()
.weak(),
);
}
None => {
ui.label(
egui::RichText::new(
"No outline folder found near this workspace.",
)
.weak(),
);
}
}
if files.is_empty() {
ui.label(
egui::RichText::new(
"No beat prompts found. The template writes them as HTML \
comments like <!-- Midpoint: … -->.",
)
.small()
.weak(),
);
}
let total: usize = files.iter().map(|f| f.beats.len()).sum();
let done: usize = files
.iter()
.map(|f| crate::outline::progress(&f.beats).0)
.sum();
if total > 0 {
ui.add_space(4.0);
ui.add(
egui::ProgressBar::new(done as f32 / total as f32)
.text(format!("{done} of {total} beats written")),
);
}
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, true])
.max_height(420.0)
.show(ui, |ui| {
for file in &files {
let (done, total) = crate::outline::progress(&file.beats);
egui::CollapsingHeader::new(format!(
"{} ({done}/{total})",
file.rel
))
.id_salt(&file.rel)
.default_open(files.len() == 1)
.show(ui, |ui| {
if ui.link("open this file").clicked() {
open_file = Some(file.rel.clone());
}
let mut section = "";
for beat in &file.beats {
if beat.section != section {
section = &beat.section;
if !section.is_empty() {
ui.label(
egui::RichText::new(section)
.small()
.strong(),
);
}
}
let mark = if beat.filled { "" } else { "" };
let text =
egui::RichText::new(format!(" {mark} {}", beat.name));
ui.label(if beat.filled {
text
} else {
text.weak()
});
}
});
}
});
ui.separator();
if ui.button("Close").clicked() {
close = true;
}
});
if let Some(path) = open_file {
match self.files.iter().position(|f| *f == path) {
Some(idx) => self.select(idx),
None => self.status = format!("{path} is not in this workspace"),
}
}
self.show_outline = open && !close;
}
}
+85 -15
View File
@@ -114,21 +114,20 @@ impl App {
} }
} }
/// Point the workspace at a generated project: its configured drafting /// Point the workspace at a generated project. The project *root* is opened
/// subfolder when the template produced one, otherwise the project root. /// rather than the drafting folder: the app recognises the layout and treats
/// the drafting folder as the manuscript, keeping the characters, outline and
/// the rest reachable in the same tree.
fn open_project(&mut self, project: &Path) { fn open_project(&mut self, project: &Path) {
let subdir = self.config.project_open_subdir.trim(); let subdir = self.config.project_open_subdir.trim();
let (workspace, note) = match subdir { let note = if subdir.is_empty() || project.join(subdir).is_dir() {
"" => (project.to_path_buf(), String::new()), String::new()
sub if project.join(sub).is_dir() => (project.join(sub), String::new()), } else {
sub => ( format!(" (no {subdir} folder in it, so nothing is marked as the manuscript)")
project.to_path_buf(),
format!(" (no {sub} folder in it, opened the project root)"),
),
}; };
self.save_current(); self.save_current();
self.workspace_input = workspace.display().to_string(); self.workspace_input = project.display().to_string();
self.config.workspace = workspace; self.config.workspace = project.to_path_buf();
// Export alongside the new project rather than into the previous one. // Export alongside the new project rather than into the previous one.
self.config.export_path = project.join(format!( self.config.export_path = project.join(format!(
"{}.odt", "{}.odt",
@@ -217,7 +216,7 @@ impl App {
if !subdir.is_empty() { if !subdir.is_empty() {
ui.label( ui.label(
egui::RichText::new(format!( egui::RichText::new(format!(
"Then opens its {subdir} folder as the workspace." "Then opens the project, with {subdir} as the manuscript."
)) ))
.small() .small()
.weak(), .weak(),
@@ -281,6 +280,71 @@ impl App {
} }
} }
/// Title and author written into exported documents.
pub(super) fn manuscript_settings_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_manuscript_settings;
let mut close = false;
egui::Window::new("Manuscript details")
.open(&mut open)
.resizable(false)
.collapsible(false)
.default_width(380.0)
.show(ctx, |ui| {
let mut save_now = false;
egui::Grid::new("manuscript_details_grid")
.num_columns(2)
.spacing([10.0, 8.0])
.show(ui, |ui| {
ui.label("Title:");
// Resolved first: the field borrows `self.config` mutably.
let fallback = self
.workspace()
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Manuscript")
.to_string();
let r = ui
.add(
egui::TextEdit::singleline(&mut self.config.manuscript_title)
.hint_text(fallback)
.desired_width(240.0),
)
.on_hover_text("Blank uses the project folder's name");
save_now |= r.lost_focus();
ui.end_row();
ui.label("Author:");
let r = ui.add(
egui::TextEdit::singleline(&mut self.config.manuscript_author)
.desired_width(240.0),
);
save_now |= r.lost_focus();
ui.end_row();
});
ui.label(
egui::RichText::new(
"Written into exported .odt files as their document \
properties, which is what a word processor shows under \
File ▸ Properties.",
)
.small()
.weak(),
);
ui.separator();
if ui.button("Close").clicked() {
close = true;
}
if save_now {
self.config.save();
}
});
let now_open = open && !close;
if self.show_manuscript_settings && !now_open {
self.config.save();
}
self.show_manuscript_settings = now_open;
}
/// Settings for **File ▸ New project…**: which template to render, how to /// Settings for **File ▸ New project…**: which template to render, how to
/// run it, and the credentials its hooks read. /// run it, and the credentials its hooks read.
pub(super) fn project_settings_window(&mut self, ctx: &egui::Context) { pub(super) fn project_settings_window(&mut self, ctx: &egui::Context) {
@@ -331,11 +395,17 @@ impl App {
.num_columns(2) .num_columns(2)
.spacing([10.0, 8.0]) .spacing([10.0, 8.0])
.show(ui, |ui| { .show(ui, |ui| {
ui.label("Open subfolder:"); ui.label("Manuscript folder:");
let r = ui.add( let r = ui
egui::TextEdit::singleline(&mut self.config.project_open_subdir) .add(
egui::TextEdit::singleline(
&mut self.config.project_open_subdir,
)
.hint_text("06-First Draft") .hint_text("06-First Draft")
.desired_width(280.0), .desired_width(280.0),
)
.on_hover_text(
"Which folder of a project holds the book. Its files are ordered, numbered and exported; the rest of the project is reference. A leading number is optional, so “First Draft” also matches “06-First Draft”.",
); );
save_now |= r.lost_focus(); save_now |= r.lost_focus();
ui.end_row(); ui.end_row();
+247 -5
View File
@@ -6,11 +6,13 @@ use super::*;
impl App { impl App {
/// Apply a spell-check suggestion to the buffer, keeping the remaining /// Apply a spell-check suggestion to the buffer, keeping the remaining
/// misspelling underlines aligned. /// misspelling underlines aligned.
pub(super) fn apply_spell_fix(&mut self, match_idx: usize, rep_idx: usize) { pub(super) fn apply_spell_fix(&mut self, match_idx: usize, rep_idx: usize) -> bool {
if self.buffer != self.spell_checked_text { if self.buffer != self.spell_checked_text {
return; return false;
}
if !splice_fix(&mut self.buffer, &mut self.spell_matches, match_idx, rep_idx) {
return false;
} }
if splice_fix(&mut self.buffer, &mut self.spell_matches, match_idx, rep_idx) {
self.dirty = true; self.dirty = true;
self.find_needs_refresh = true; self.find_needs_refresh = true;
// Keep these matches valid without forcing a full re-check. // Keep these matches valid without forcing a full re-check.
@@ -20,7 +22,7 @@ impl App {
1 => "1 spelling issue".to_string(), 1 => "1 spelling issue".to_string(),
n => format!("{n} spelling issues"), n => format!("{n} spelling issues"),
}; };
} true
} }
// ---- Spell check (offline) --------------------------------------------- // ---- Spell check (offline) ---------------------------------------------
@@ -125,13 +127,253 @@ impl App {
} }
self.spell_dirty = false; self.spell_dirty = false;
let text = self.buffer.clone(); let text = self.buffer.clone();
let extra = self.wordlist.clone();
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
self.spell_rx = Some(rx); self.spell_rx = Some(rx);
let ctx = ctx.clone(); let ctx = ctx.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let matches = crate::spell::check(&dict, &text); let matches = crate::spell::check(&dict, &extra, &text);
let _ = tx.send((text, matches)); let _ = tx.send((text, matches));
ctx.request_repaint(); ctx.request_repaint();
}); });
} }
// ---- The project word list ---------------------------------------------
/// Accept `word` from now on: add it to the workspace's word list, persist
/// it, and drop the underlines it was causing without waiting for a
/// re-check.
pub(super) fn add_to_dictionary(&mut self, word: &str) {
let word = word.trim().to_string();
if word.is_empty() {
return;
}
if !self.wordlist.insert(&word) {
self.spell_status = format!("{word}” is already in the word list");
return;
}
self.persist_wordlist();
self.drop_accepted_matches();
self.spell_status = format!("Added “{word}” — {}", Self::spell_count_status(
self.spell_matches.len()
));
}
/// Stop accepting `word`, so it is flagged again.
pub(super) fn remove_from_dictionary(&mut self, word: &str) {
if self.wordlist.remove(word) {
self.persist_wordlist();
// The word has to be found again, which needs a full pass.
self.spell_dirty = true;
self.spell_last_edit = None;
self.spell_status = format!("Removed “{word}” from the word list");
}
}
pub(super) fn persist_wordlist(&mut self) {
if let Err(e) = crate::spell::write_wordlist(self.workspace(), &self.wordlist) {
self.status = format!("Could not save the word list: {e}");
}
}
/// Clear the matches the word list now accepts, so an added word stops being
/// underlined immediately rather than after the next debounce.
fn drop_accepted_matches(&mut self) {
let text = self.spell_checked_text.clone();
let list = self.wordlist.clone();
self.spell_matches.retain(|m| {
text.get(m.start..m.end)
.is_none_or(|w| !list.accepts(w))
});
self.spell_menu = None;
}
/// The project's character folder, looked for beside the workspace and then
/// up through its ancestors — in a snowflake project the workspace is
/// `06-First Draft` and the sheets are its sibling `03-Characters`.
pub(super) fn characters_dir(&self) -> Option<PathBuf> {
/// How far up to look before giving up; deep enough for the layout,
/// shallow enough not to wander into the home directory.
const MAX_UP: usize = 3;
let mut dir = Some(self.workspace());
for _ in 0..=MAX_UP {
let current = dir?;
if let Ok(read) = std::fs::read_dir(current) {
for entry in read.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if crate::spell::is_characters_dir(name) && entry.path().is_dir() {
return Some(entry.path());
}
}
}
dir = current.parent();
}
None
}
/// Add every name found in the project's character sheets to the word list.
/// Returns how many were new, or an error message.
pub(super) fn harvest_character_names(&mut self) -> Result<usize, String> {
let dir = self
.characters_dir()
.ok_or_else(|| "No character folder found near this workspace".to_string())?;
let mut added = 0usize;
let mut sheets = 0usize;
for path in markdown_files_under(&dir) {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
sheets += 1;
for name in crate::spell::names_from_sheet(&text) {
if self.wordlist.insert(&name) {
added += 1;
}
}
}
if sheets == 0 {
return Err(format!("No character sheets in {}", dir.display()));
}
if added > 0 {
self.persist_wordlist();
self.drop_accepted_matches();
}
Ok(added)
}
/// The workspace's word list: what the checker accepts on top of the
/// dictionary, where it came from, and how to take a word back out.
pub(super) fn wordlist_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_wordlist;
let mut close = false;
let mut remove: Option<String> = None;
let mut harvest = false;
egui::Window::new("Word list")
.open(&mut open)
.resizable(true)
.collapsible(false)
.default_width(360.0)
.show(ctx, |ui| {
ui.label(
egui::RichText::new(
"Names and invented terms the spell checker accepts. Saved in \
the workspace as wordlist.json, so it is committed with the \
manuscript.",
)
.small()
.weak(),
);
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut self.wordlist_input)
.hint_text("add a word")
.desired_width(200.0),
);
if ui.button(" Add").clicked() {
let word = self.wordlist_input.trim().to_string();
if !word.is_empty() {
self.add_to_dictionary(&word);
self.wordlist_input.clear();
}
}
});
let found = self.characters_dir();
ui.horizontal(|ui| {
if ui
.add_enabled(found.is_some(), egui::Button::new("👤 Add character names"))
.on_hover_text(match &found {
Some(dir) => format!("Read the sheets in {}", dir.display()),
None => "No character folder found near this workspace".to_string(),
})
.clicked()
{
harvest = true;
}
});
ui.separator();
ui.label(
egui::RichText::new(format!("{} word(s)", self.wordlist.len())).strong(),
);
egui::ScrollArea::vertical()
.auto_shrink([false, true])
.max_height(260.0)
.show(ui, |ui| {
if self.wordlist.is_empty() {
ui.label(
egui::RichText::new(
"Nothing yet — right-click a underlined word in the \
editor to add it.",
)
.weak(),
);
}
for word in self.wordlist.words() {
ui.horizontal(|ui| {
if ui
.small_button("")
.on_hover_text("Flag this word again")
.clicked()
{
remove = Some(word.clone());
}
ui.label(word);
});
}
});
ui.separator();
if ui.button("Close").clicked() {
close = true;
}
});
if let Some(word) = remove {
self.remove_from_dictionary(&word);
}
if harvest {
self.spell_status = match self.harvest_character_names() {
Ok(0) => "No new names in the character sheets".to_string(),
Ok(n) => format!("Added {n} name(s) from the character sheets"),
Err(e) => format!("{e}"),
};
}
self.show_wordlist = open && !close;
}
}
/// Every `*.md` file at or below `dir`, one level of nesting at a time. Project
/// folders group their files into subfolders — character sheets into
/// `main_characters`, the outline into `scene_breakdown` — so a flat read of the
/// top level would miss all of them.
pub(super) fn markdown_files_under(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
// A guard against a symlink pointing back up the tree.
let mut budget = 512usize;
while let Some(current) = stack.pop() {
let Ok(read) = std::fs::read_dir(&current) else {
continue;
};
for entry in read.flatten() {
if budget == 0 {
return out;
}
budget -= 1;
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("md"))
{
out.push(path);
}
}
}
out.sort();
out
} }
+121 -2
View File
@@ -176,7 +176,7 @@ impl App {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(idx) = self.selected { if let Some(idx) = self.selected {
let name = &self.files[idx]; let name = &self.files[idx];
let total = count_words(&self.buffer); let total = count_words(&self.document());
let baseline = let baseline =
self.session_start_counts.get(name).copied().unwrap_or(0); self.session_start_counts.get(name).copied().unwrap_or(0);
let delta = total as i64 - baseline as i64; let delta = total as i64 - baseline as i64;
@@ -193,6 +193,21 @@ impl App {
"Words in the current file · net change since this session opened", "Words in the current file · net change since this session opened",
); );
// The whole book's length, which is the number that
// actually tells you where the manuscript stands.
ui.separator();
ui.label(
egui::RichText::new(format!(
"{} total",
thousands(self.manuscript_words())
))
.weak(),
)
.on_hover_text(
"Prose words across the whole manuscript (reference \
files are not counted)",
);
// Progress toward the file's Word Count Target, if set. // Progress toward the file's Word Count Target, if set.
if let Some((goal, prose)) = goal { if let Some((goal, prose)) = goal {
let (frac, color, text) = goal_progress(goal, prose); let (frac, color, text) = goal_progress(goal, prose);
@@ -240,6 +255,17 @@ impl App {
ui.close_menu(); ui.close_menu();
self.export_odt(); self.export_odt();
} }
if ui
.button("Export chapters + master (.odm)")
.on_hover_text(
"One .odt per chapter in a chapters/ folder, plus an \
.odm master that links them",
)
.clicked()
{
ui.close_menu();
self.export_master();
}
ui.separator(); ui.separator();
if ui.button("Quit").clicked() { if ui.button("Quit").clicked() {
ui.close_menu(); ui.close_menu();
@@ -247,12 +273,30 @@ impl App {
} }
}); });
ui.menu_button("Edit", |ui| { ui.menu_button("Edit", |ui| {
if ui
.button("⇄ Changes since last commit…")
.on_hover_text("Diff the open file against its committed version")
.clicked()
{
ui.close_menu();
self.open_diff();
}
ui.separator();
if ui.button("🔍 Find / Replace…").clicked() { if ui.button("🔍 Find / Replace…").clicked() {
ui.close_menu(); ui.close_menu();
self.open_find(true); self.open_find(true);
} }
}); });
ui.menu_button("Tools", |ui| { ui.menu_button("Tools", |ui| {
if ui.button("☑ Outline…").clicked() {
ui.close_menu();
self.show_outline = true;
}
if ui.button("👥 Characters…").clicked() {
ui.close_menu();
self.show_characters = true;
}
ui.separator();
let busy = self.beats_rx.is_some(); let busy = self.beats_rx.is_some();
if ui if ui
.add_enabled( .add_enabled(
@@ -272,6 +316,30 @@ impl App {
{ {
self.config.save(); self.config.save();
} }
if ui
.checkbox(
&mut self.config.show_reference_files,
"Show reference files",
)
.on_hover_text(
"List a project's characters, outline and scratch pad in \
the file panel alongside the manuscript",
)
.clicked()
{
self.config.save();
}
if ui
.checkbox(&mut self.config.collapse_header, "Hide header in editor")
.on_hover_text(
"Edit the prose alone, with the fields above the draft \
marker folded away",
)
.clicked()
{
self.apply_header_collapse();
self.config.save();
}
ui.checkbox(&mut self.show_lt_panel, "Grammar panel"); ui.checkbox(&mut self.show_lt_panel, "Grammar panel");
ui.checkbox(&mut self.show_log, "Git log"); ui.checkbox(&mut self.show_log, "Git log");
}); });
@@ -292,6 +360,14 @@ impl App {
ui.close_menu(); ui.close_menu();
self.show_project_settings = true; self.show_project_settings = true;
} }
if ui.button("Manuscript details…").clicked() {
ui.close_menu();
self.show_manuscript_settings = true;
}
if ui.button("Word list…").clicked() {
ui.close_menu();
self.show_wordlist = true;
}
}); });
ui.menu_button("Help", |ui| { ui.menu_button("Help", |ui| {
if ui.button("📝 Markdown cheatsheet").clicked() { if ui.button("📝 Markdown cheatsheet").clicked() {
@@ -357,7 +433,7 @@ impl App {
}); });
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Chapter title:"); ui.label("Chapter title:");
let auto = self.auto_title(idx, &self.buffer); let auto = self.auto_title(idx, &self.document());
let resp = ui.add( let resp = ui.add(
egui::TextEdit::singleline(&mut self.title_input) egui::TextEdit::singleline(&mut self.title_input)
.hint_text(format!("auto: {auto}")) .hint_text(format!("auto: {auto}"))
@@ -372,6 +448,7 @@ impl App {
.weak(), .weak(),
); );
}); });
self.header_toggle(ui);
ui.separator(); ui.separator();
if self.show_find { if self.show_find {
@@ -450,6 +527,48 @@ impl App {
self.decline_pending_repo(); self.decline_pending_repo();
} }
} }
/// A single line saying whether the editorial header is folded away, and
/// letting you flip it.
///
/// It exists so a hidden header can never be mistaken for a file that has
/// none: the editor is showing less than the file holds, and that has to be
/// visible without opening a menu.
pub(super) fn header_toggle(&mut self, ui: &mut egui::Ui) {
let collapsed = self.header_stash.is_some();
// Nothing to say about a file with no marker to fold at.
if !collapsed
&& crate::preprocess::split_at_marker(&self.buffer, &self.config.draft_marker)
.is_none()
{
return;
}
ui.horizontal(|ui| {
let (label, hover) = if collapsed {
let n = self.hidden_field_count();
(
format!("▸ header hidden ({n} field{})", if n == 1 { "" } else { "s" }),
"Show the fields above the draft marker",
)
} else {
(
"▾ header shown".to_string(),
"Fold the fields away and edit the prose alone",
)
};
if ui
.add(egui::Button::new(egui::RichText::new(label).small().weak()).frame(false))
.on_hover_text(hover)
.clicked()
{
self.config.collapse_header = !collapsed;
self.apply_header_collapse();
self.config.save();
}
});
}
} }
/// Very small markdown-ish preview (headings emphasised, everything else plain). /// Very small markdown-ish preview (headings emphasised, everything else plain).
+497 -22
View File
@@ -17,26 +17,59 @@ impl App {
self.status = format!("Cannot create workspace: {e}"); self.status = format!("Cannot create workspace: {e}");
return; return;
} }
self.files = order::resolve_order(&ws); // The project root has moved, so the export follows it.
self.retarget_export();
// Which folder is the manuscript has to be settled before the file
// list is read, since it decides what counts as a chapter.
self.manuscript_dir = self.detect_manuscript_dir(&ws);
self.files = order::resolve_order(&ws, &self.config.hidden_folders);
self.titles = order::read_titles(&ws); self.titles = order::read_titles(&ws);
self.wordlist = crate::spell::read_wordlist(&ws);
// Drop overrides for files that no longer exist. // Drop overrides for files that no longer exist.
self.titles.retain(|name, _| self.files.contains(name)); self.titles.retain(|name, _| self.files.contains(name));
self.detect_repo(&ws); self.detect_repo(&ws);
self.selected = None; self.selected = None;
self.buffer.clear(); self.buffer.clear();
self.header_stash = None;
self.dirty = false; self.dirty = false;
self.pending_delete = false; self.pending_delete = false;
self.clear_lt(); self.clear_lt();
self.session_start_counts = self.snapshot_counts(); self.session_start_counts = self.snapshot_counts();
self.file_meta = self.snapshot_file_meta(); self.file_meta = self.snapshot_file_meta();
self.rebuild_field_names(); self.rebuild_field_names();
self.load_characters();
self.autocomplete = None; self.autocomplete = None;
self.collapsed.clear(); self.collapsed.clear();
if !self.files.is_empty() { if let Some(idx) = first_listed(&self.files, self.manuscript_dir.as_deref()) {
self.select(0); self.select(idx);
} }
self.persist_order(); self.persist_order();
self.status = format!("{} file(s) in {}", self.files.len(), ws.display()); self.status = match &self.manuscript_dir {
Some(dir) => {
let chapters = self.manuscript_files().len();
format!(
"{chapters} chapter(s) in {dir}, {} reference file(s) — {}",
self.files.len() - chapters,
ws.display()
)
}
None => format!("{} file(s) in {}", self.files.len(), ws.display()),
};
}
/// Point the export at the opened workspace, which is the project root.
///
/// A file name the user picked deliberately is carried across; one that
/// merely echoed the previous project's folder is re-derived, so exports
/// land beside the book being worked on instead of piling up in the
/// project left behind.
pub(super) fn retarget_export(&mut self) {
let path = export_path_for(&self.config.workspace, &self.config.export_path);
if path != self.config.export_path {
self.config.export_path = path;
self.config.save();
}
self.export_input = self.config.export_path.display().to_string();
} }
/// Work out the git situation for a freshly opened workspace. /// Work out the git situation for a freshly opened workspace.
@@ -94,6 +127,39 @@ impl App {
self.status = "Not using the enclosing git repository".to_string(); self.status = "Not using the enclosing git repository".to_string();
} }
/// Work out whether the opened folder is a **project root** — a folder
/// holding the manuscript alongside characters, outline and the rest — and
/// if so, which of its subfolders is the manuscript.
///
/// The test is simply whether it has a child folder named like the
/// configured manuscript folder (`06-First Draft`), matched leniently by
/// [`order::dir_matches`]. `None` means the workspace is itself the
/// manuscript, which is how the app behaved before project mode and remains
/// the right answer for a plain folder of chapters.
pub(super) fn detect_manuscript_dir(&self, ws: &Path) -> Option<String> {
order::child_dir_matching(ws, self.config.project_open_subdir.trim())
}
/// Whether `path` (workspace-relative) is part of the manuscript proper:
/// ordered, numbered and exported. Everything else in a project — character
/// sheets, outline, scratch pad — is reference material that the app will
/// happily open and edit but leaves out of the book.
pub(super) fn is_manuscript(&self, path: &str) -> bool {
match &self.manuscript_dir {
Some(dir) => is_within(path, dir),
None => true,
}
}
/// The manuscript's files, in order, paired with their chapter index.
pub(super) fn manuscript_files(&self) -> Vec<(usize, &String)> {
self.files
.iter()
.filter(|path| self.is_manuscript(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
@@ -105,19 +171,56 @@ impl App {
.collect() .collect()
} }
/// Read every file and return its cached header info (slug/POV/goal/prose), /// Read every file and return its cached header info (slug/POV/goal/status
/// for files that carry anything worth showing in the list. /// and prose length). Every file is cached, not only the ones with a tooltip
/// to show, because the status column and the project word total need a
/// figure for each.
pub(super) fn snapshot_file_meta(&self) -> HashMap<String, FileMeta> { pub(super) fn snapshot_file_meta(&self) -> HashMap<String, FileMeta> {
self.files self.files
.iter() .iter()
.filter_map(|name| { .map(|name| {
let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
let meta = FileMeta::from_markdown(&text, &self.config.draft_marker); (
meta.has_display().then(|| (name.clone(), meta)) name.clone(),
FileMeta::from_markdown(&text, &self.config.draft_marker),
)
}) })
.collect() .collect()
} }
/// Prose words across the whole manuscript, counting the open file from the
/// buffer so the total moves as you type.
pub(super) fn manuscript_words(&self) -> usize {
let open = self.selected.and_then(|i| self.files.get(i));
self.manuscript_files()
.into_iter()
.map(|(_, name)| {
if Some(name) == open {
let h =
crate::preprocess::parse(&self.document(), &self.config.draft_marker);
count_words(&h.body)
} else {
self.file_meta.get(name).map_or(0, |m| m.prose_words)
}
})
.sum()
}
/// Every distinct `Status:` value in the manuscript, for the filter menu.
pub(super) fn known_statuses(&self) -> Vec<String> {
let mut seen: Vec<String> = Vec::new();
for name in self.files.iter() {
let Some(status) = self.file_meta.get(name).and_then(|m| m.status.clone()) else {
continue;
};
if !seen.iter().any(|s| s.eq_ignore_ascii_case(&status)) {
seen.push(status);
}
}
seen.sort_by_key(|s| s.to_lowercase());
seen
}
pub(super) fn persist_order(&self) { pub(super) fn persist_order(&self) {
let _ = order::write_order(self.workspace(), &self.files); let _ = order::write_order(self.workspace(), &self.files);
} }
@@ -146,24 +249,97 @@ impl App {
self.workspace().join(name) self.workspace().join(name)
} }
// ---- Collapsing the editorial header ------------------------------------
/// The whole document: the editor's text with the collapsed header, if any,
/// put back in front of it.
///
/// While the header is collapsed it is not in `buffer` at all, which is what
/// keeps every byte offset the editor works in — search matches, spelling
/// underlines, the caret — pointing at what is actually on screen. Anything
/// that wants the *file* rather than the view asks for this instead.
pub(super) fn document(&self) -> std::borrow::Cow<'_, str> {
match &self.header_stash {
Some(header) => std::borrow::Cow::Owned(format!("{header}{}", self.buffer)),
None => std::borrow::Cow::Borrowed(&self.buffer),
}
}
/// Lift the header out of the buffer, leaving the prose. Does nothing for a
/// document with no draft marker — there is nothing to collapse.
pub(super) fn collapse_header(&mut self) {
if self.header_stash.is_some() {
return;
}
let Some((header, body)) =
crate::preprocess::split_at_marker(&self.buffer, &self.config.draft_marker)
else {
return;
};
let (header, body) = (header.to_string(), body.to_string());
self.header_stash = Some(header);
self.buffer = body;
self.after_view_change();
}
/// Put the header back into the buffer.
pub(super) fn expand_header(&mut self) {
let Some(header) = self.header_stash.take() else {
return;
};
self.buffer.insert_str(0, &header);
self.after_view_change();
}
/// Re-collapse or expand to match the setting, after the buffer is replaced.
pub(super) fn apply_header_collapse(&mut self) {
if self.config.collapse_header {
self.collapse_header();
} else {
self.expand_header();
}
}
/// Everything keyed to the buffer's contents has to be recomputed when the
/// header moves in or out of it — but the file itself has not changed, so
/// this must not mark the document dirty.
fn after_view_change(&mut self) {
self.find_matches.clear();
self.find_active = 0;
self.find_needs_refresh = true;
self.spell_matches.clear();
self.spell_checked_text.clear();
self.spell_dirty = true;
self.spell_last_edit = None;
self.spell_menu = None;
self.clear_lt();
self.autocomplete = None;
}
/// How many header fields are hidden right now, for the toggle's label.
pub(super) fn hidden_field_count(&self) -> usize {
match &self.header_stash {
Some(header) => crate::preprocess::fields(header, "").len(),
None => 0,
}
}
/// Save the in-memory buffer to disk if it has unsaved changes. /// Save the in-memory buffer to disk if it has unsaved changes.
pub(super) fn save_current(&mut self) { pub(super) fn save_current(&mut self) {
if let Some(idx) = self.selected { if let Some(idx) = self.selected {
if self.dirty { if self.dirty {
if let Some(name) = self.files.get(idx).cloned() { if let Some(name) = self.files.get(idx).cloned() {
let path = self.path_for(&name); let path = self.path_for(&name);
match std::fs::write(&path, &self.buffer) { match std::fs::write(&path, self.document().as_bytes()) {
Ok(_) => { Ok(_) => {
self.dirty = false; self.dirty = false;
self.status = format!("Saved {name}"); self.status = format!("Saved {name}");
// Keep the file-list cache (slug/POV/goal/prose) in step. // Keep the file-list cache (slug/POV/goal/prose) in step.
let meta = let meta = FileMeta::from_markdown(
FileMeta::from_markdown(&self.buffer, &self.config.draft_marker); &self.document(),
if meta.has_display() { &self.config.draft_marker,
);
self.file_meta.insert(name, meta); self.file_meta.insert(name, meta);
} else {
self.file_meta.remove(&name);
}
self.merge_field_names_from_buffer(); self.merge_field_names_from_buffer();
} }
Err(e) => self.status = format!("Save failed: {e}"), Err(e) => self.status = format!("Save failed: {e}"),
@@ -193,7 +369,9 @@ impl App {
return; return;
}; };
let path = self.path_for(&name); let path = self.path_for(&name);
self.header_stash = None;
self.buffer = std::fs::read_to_string(&path).unwrap_or_default(); self.buffer = std::fs::read_to_string(&path).unwrap_or_default();
self.apply_header_collapse();
self.selected = Some(idx); self.selected = Some(idx);
self.dirty = false; self.dirty = false;
self.pending_delete = false; self.pending_delete = false;
@@ -316,9 +494,12 @@ impl App {
prune_empty_dirs(self.workspace(), parent_dir(&name)); prune_empty_dirs(self.workspace(), parent_dir(&name));
self.selected = None; self.selected = None;
self.buffer.clear(); self.buffer.clear();
self.header_stash = None;
self.dirty = false; self.dirty = false;
if !self.files.is_empty() { if let Some(next) =
self.select(idx.min(self.files.len() - 1)); nearest_listed(&self.files, self.manuscript_dir.as_deref(), idx)
{
self.select(next);
} }
self.status = format!("Deleted {name}"); self.status = format!("Deleted {name}");
} }
@@ -329,6 +510,54 @@ impl App {
self.pending_delete = false; self.pending_delete = false;
} }
/// Move the selected file into the project's archive folder: off the
/// manuscript, out of the panel, but still on disk.
///
/// Deleting is destructive and a superseded scene is often worth keeping;
/// the archive folder is hidden from the scan, so archiving a file makes it
/// disappear from the tree without losing it.
pub(super) fn archive_selected(&mut self) {
let Some(idx) = self.selected else { return };
let Some(name) = self.files.get(idx).cloned() else {
return;
};
let archive = self.archive_dir();
// Keep the file's shape inside the archive, so a scene from
// `Act 1/` lands in `10-Archive/Act 1/` rather than losing its place.
let dest = join_rel(&archive, &name);
if self.path_for(&dest).exists() {
self.status = format!("{dest} already exists");
return;
}
self.save_current();
if let Err(e) = self.relocate(&name, &dest) {
self.status = format!("Archive failed: {e}");
return;
}
self.files.remove(idx);
self.persist_order();
self.selected = None;
self.buffer.clear();
self.header_stash = None;
self.dirty = false;
if let Some(next) = nearest_listed(&self.files, self.manuscript_dir.as_deref(), idx) {
self.select(next);
}
self.status = format!("Archived {name} to {archive}");
}
/// The archive folder's name within the workspace: whatever the project
/// already calls it, otherwise the configured default.
pub(super) fn archive_dir(&self) -> String {
let configured = self.config.archive_folder.trim();
let role = if configured.is_empty() {
"Archive"
} else {
configured
};
order::child_dir_matching(self.workspace(), role).unwrap_or_else(|| role.to_string())
}
pub(super) fn rename_selected(&mut self) { pub(super) fn rename_selected(&mut self) {
let Some(idx) = self.selected else { return }; let Some(idx) = self.selected else { return };
// A path in the box (`part-2/ch-07`) both renames and moves the file. // A path in the box (`part-2/ch-07`) both renames and moves the file.
@@ -400,7 +629,7 @@ impl App {
/// chapter number when padding is enabled, otherwise 1 (no padding). /// chapter number when padding is enabled, otherwise 1 (no padding).
pub(super) fn index_pad_width(&self) -> usize { pub(super) fn index_pad_width(&self) -> usize {
if self.config.zero_pad_index { if self.config.zero_pad_index {
self.files.len().to_string().len().max(1) self.manuscript_files().len().to_string().len().max(1)
} else { } else {
1 1
} }
@@ -419,17 +648,93 @@ impl App {
/// Prose = the body below the draft marker, so header metadata isn't counted. /// Prose = the body below the draft marker, so header metadata isn't counted.
pub(super) fn current_goal(&self) -> Option<(crate::preprocess::WordGoal, usize)> { pub(super) fn current_goal(&self) -> Option<(crate::preprocess::WordGoal, usize)> {
self.selected?; self.selected?;
let header = crate::preprocess::parse(&self.buffer, &self.config.draft_marker); let header = crate::preprocess::parse(&self.document(), &self.config.draft_marker);
let goal = header.goal?; let goal = header.goal?;
Some((goal, count_words(&header.body))) Some((goal, count_words(&header.body)))
} }
/// Document properties for an export: the configured title and author, with
/// the title falling back to the project folder's own name.
pub(super) fn doc_meta(&self, chapters: &[Chapter]) -> odt::DocMeta {
let title = match self.config.manuscript_title.trim() {
"" => self
.workspace()
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Manuscript")
.to_string(),
set => set.to_string(),
};
odt::DocMeta {
title,
author: self.config.manuscript_author.trim().to_string(),
subject: String::new(),
keywords: String::new(),
word_count: chapters.iter().map(|c| count_words(&c.markdown)).sum(),
chapter_count: chapters.len(),
}
}
/// Export the manuscript as one `.odt` per chapter plus an `.odm` master
/// that links them, which is the shape the snowflake template expects.
///
/// The per-chapter files are ordinary documents and are the useful part.
/// The master is a shell: LibreOffice does not follow its section links on
/// load without being asked to update them, so treat it as a starting point
/// rather than as the assembled book.
pub(super) fn export_master(&mut self) {
self.save_current();
let chapters = self.collect_chapters();
if chapters.is_empty() {
self.status = "Nothing to export — the manuscript is empty".to_string();
return;
}
let meta = self.doc_meta(&chapters);
let out = PathBuf::from(self.export_input.trim()).with_extension("odm");
if let Some(parent) = out.parent() {
let _ = std::fs::create_dir_all(parent);
}
match odt::export_master(&chapters, &meta, &out) {
Ok(written) => {
self.status = format!(
"Wrote {} chapter file(s) beside {}",
written.len(),
out.display()
);
}
Err(e) => self.status = format!("Export failed: {e}"),
}
}
/// The manuscript as chapters, ready for either export path.
fn collect_chapters(&self) -> Vec<Chapter> {
let marker = self.config.draft_marker.clone();
let pad_width = self.index_pad_width();
self.manuscript_files()
.into_iter()
.map(|(i, name)| {
let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
let header = crate::preprocess::parse(&raw, &marker);
Chapter {
title: resolve_chapter_title(
self.titles.get(name).map(String::as_str),
header.title.as_deref(),
i,
pad_width,
),
slug: header.slug.clone(),
markdown: header.body,
}
})
.collect()
}
pub(super) fn export_odt(&mut self) { pub(super) fn export_odt(&mut self) {
self.save_current(); self.save_current();
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.files.iter().enumerate() { for (i, name) in self.manuscript_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.
@@ -453,7 +758,8 @@ impl App {
if let Some(parent) = out.parent() { if let Some(parent) = out.parent() {
let _ = std::fs::create_dir_all(parent); let _ = std::fs::create_dir_all(parent);
} }
match odt::export(&chapters, &out) { let meta = self.doc_meta(&chapters);
match odt::export(&chapters, &meta, &out) {
Ok(_) => { Ok(_) => {
self.config.export_path = out.clone(); self.config.export_path = out.clone();
self.config.save(); self.config.save();
@@ -640,6 +946,73 @@ impl App {
} }
} }
/// Index of the first file the panel lists, which is the one to open a
/// workspace on.
///
/// In a project the panel shows the manuscript, so landing on `files[0]` would
/// open whatever sorts first across the whole project — a scratch-pad note,
/// typically — and in a file the user cannot see in the tree. Reference files
/// are still the fallback, for a project whose manuscript folder is empty.
pub(super) fn first_listed(files: &[String], manuscript_dir: Option<&str>) -> Option<usize> {
let in_book = |name: &String| match manuscript_dir {
Some(dir) => is_within(name, dir),
None => true,
};
files
.iter()
.position(in_book)
.or_else(|| (!files.is_empty()).then_some(0))
}
/// The listed file nearest `idx` after a removal: the next one down, else the
/// last one before it, else whatever is left.
pub(super) fn nearest_listed(
files: &[String],
manuscript_dir: Option<&str>,
idx: usize,
) -> Option<usize> {
let in_book = |i: &usize| match manuscript_dir {
Some(dir) => is_within(&files[*i], dir),
None => true,
};
(idx..files.len())
.find(in_book)
.or_else(|| (0..idx.min(files.len())).rev().find(in_book))
.or_else(|| first_listed(files, manuscript_dir))
}
/// Where the manuscript exports to for `workspace`, given the `current` export
/// path.
///
/// The folder is always the workspace (the project root). The file name is kept
/// when it looks chosen — anything but the fallback `manuscript` or an echo of
/// the folder the file sits in, both of which we generate ourselves.
fn export_path_for(workspace: &Path, current: &Path) -> PathBuf {
let stem = current.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let old_folder = current
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("");
let generated = stem.is_empty()
|| stem.eq_ignore_ascii_case("manuscript")
|| stem.eq_ignore_ascii_case(old_folder);
let name = if generated {
let folder = workspace
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("manuscript");
format!("{folder}.odt")
} else {
current
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("manuscript.odt")
.to_string()
};
workspace.join(name)
}
/// Delete `dir` (workspace-relative) and every parent it leaves childless, so /// Delete `dir` (workspace-relative) and every parent it leaves childless, so
/// the tree stops drawing branches nothing lives in any more. `remove_dir` /// the tree stops drawing branches nothing lives in any more. `remove_dir`
/// refuses to touch a non-empty directory, which is exactly the guard wanted /// refuses to touch a non-empty directory, which is exactly the guard wanted
@@ -801,6 +1174,49 @@ mod tests {
assert!(!out.contains("{{"), "placeholder left unexpanded in {out:?}"); assert!(!out.contains("{{"), "placeholder left unexpanded in {out:?}");
} }
#[test]
fn opening_a_project_moves_a_generated_export_name_with_it() {
let out = export_path_for(
Path::new("/books/The Winter Gate"),
Path::new("/books/Salt Road/Salt Road.odt"),
);
assert_eq!(
out,
PathBuf::from("/books/The Winter Gate/The Winter Gate.odt")
);
}
#[test]
fn a_chosen_export_name_survives_the_move_to_the_new_root() {
let out = export_path_for(
Path::new("/books/The Winter Gate"),
Path::new("/books/Salt Road/submission draft.odt"),
);
assert_eq!(
out,
PathBuf::from("/books/The Winter Gate/submission draft.odt")
);
}
#[test]
fn the_default_export_name_is_re_derived_rather_than_kept() {
let out = export_path_for(
Path::new("/books/The Winter Gate"),
Path::new("/home/writer/Manuscript/manuscript.odt"),
);
assert_eq!(
out,
PathBuf::from("/books/The Winter Gate/The Winter Gate.odt")
);
}
#[test]
fn reopening_the_same_workspace_leaves_the_export_untouched() {
let ws = Path::new("/books/The Winter Gate");
let current = PathBuf::from("/books/The Winter Gate/submission draft.odt");
assert_eq!(export_path_for(ws, &current), current);
}
/// Build a throwaway workspace containing `files` and hand back its path. /// Build a throwaway workspace containing `files` and hand back its path.
fn scratch_ws(tag: &str, files: &[&str]) -> PathBuf { fn scratch_ws(tag: &str, files: &[&str]) -> PathBuf {
let dir = std::env::temp_dir().join(format!("md_manuscript_ws_{tag}")); let dir = std::env::temp_dir().join(format!("md_manuscript_ws_{tag}"));
@@ -876,4 +1292,63 @@ mod tests {
assert!(ws.is_dir()); assert!(ws.is_dir());
let _ = std::fs::remove_dir_all(&ws); let _ = std::fs::remove_dir_all(&ws);
} }
fn v(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
/// The real shape of the bug: the scratch pad sorts first, so opening a
/// project landed on a note that the panel does not even list.
#[test]
fn opening_a_project_lands_in_the_manuscript() {
let files = v(&[
"00-Scratch Pad/Generated plot points.md",
"03-Characters/ada.md",
"06-First Draft/Act 1/scene01.md",
"06-First Draft/draft_v1.md",
]);
assert_eq!(first_listed(&files, Some("06-First Draft")), Some(2));
}
#[test]
fn a_plain_folder_still_opens_on_its_first_file() {
let files = v(&["a.md", "b.md"]);
assert_eq!(first_listed(&files, None), Some(0));
assert_eq!(first_listed(&[], None), None);
}
/// A project whose manuscript folder holds nothing yet still has to open
/// something rather than nothing.
#[test]
fn an_empty_manuscript_falls_back_to_the_first_file() {
let files = v(&["00-Scratch Pad/note.md", "03-Characters/ada.md"]);
assert_eq!(first_listed(&files, Some("06-First Draft")), Some(0));
}
#[test]
fn after_a_removal_the_next_listed_file_is_chosen() {
let files = v(&[
"00-Scratch Pad/note.md",
"06-First Draft/a.md",
"06-First Draft/b.md",
]);
// Removing index 1 leaves the following manuscript file at index 1.
assert_eq!(nearest_listed(&files, Some("06-First Draft"), 1), Some(1));
// Removing the last one falls back to the previous manuscript file.
assert_eq!(nearest_listed(&files, Some("06-First Draft"), 3), Some(2));
}
#[test]
fn a_removal_never_lands_on_a_reference_file() {
let files = v(&["00-Scratch Pad/note.md", "06-First Draft/a.md"]);
// Index 2 is past the end; the search back must skip the scratch pad.
assert_eq!(nearest_listed(&files, Some("06-First Draft"), 2), Some(1));
}
#[test]
fn removing_the_only_manuscript_file_still_selects_something() {
let files = v(&["00-Scratch Pad/note.md"]);
assert_eq!(nearest_listed(&files, Some("06-First Draft"), 0), Some(0));
assert_eq!(nearest_listed(&[], Some("06-First Draft"), 0), None);
}
} }
+367
View File
@@ -0,0 +1,367 @@
//! The project's cast, read from its character sheets.
//!
//! A sheet is an ordinary markdown file whose level-1 heading is the character's
//! name and whose header fields describe them:
//!
//! ```text
//! # Bixby Reynolds
//!
//! ### The broken truth-seeker
//!
//! Age: 37
//! Description: An assistant philosophy professor
//! ```
//!
//! Both `# Bixby Reynolds` and `# Name: Bixby Reynolds` are in use, so both are
//! understood. Reading the cast lets the app do things it otherwise cannot: say
//! which scenes a character appears in, keep the summary grid in step with the
//! sheets, and stop underlining everybody's name.
use std::path::{Path, PathBuf};
/// One character, as described by their sheet.
#[derive(Debug, Clone)]
pub struct Character {
/// The name from the sheet's heading.
pub name: String,
/// The sheet itself, so the app can open it.
pub file: PathBuf,
/// The sheet's header fields, in the order written.
pub fields: Vec<crate::preprocess::Field>,
/// The descriptor heading under the name, where the template puts `### Slug`
/// and a filled sheet puts something like `### The broken truth-seeker`.
pub subtitle: Option<String>,
}
impl Character {
/// Look up one of the sheet's fields, case-insensitively.
pub fn field(&self, name: &str) -> Option<&str> {
self.fields
.iter()
.find(|f| f.name.eq_ignore_ascii_case(name))
.map(|f| f.value.as_str())
.filter(|v| !v.is_empty())
}
/// The short descriptor shown beside the name: a `Slug:` field, else the
/// descriptor heading under the name, else the opening of the description.
///
/// Both sheet styles are covered, since one writes `## Slug: The haunted
/// murderer` and the other writes it as a bare `### The broken truth-seeker`.
pub fn tagline(&self) -> Option<&str> {
self.field("Slug")
.or(self.subtitle.as_deref())
.or_else(|| self.field("Description"))
}
/// Whether `mention` names this character. A scene's `Characters:` line uses
/// whatever the author types — usually a first name — so any single part of
/// the full name counts, and vice versa.
pub fn is_named_by(&self, mention: &str) -> bool {
let mention = mention.trim();
if mention.is_empty() {
return false;
}
if self.name.eq_ignore_ascii_case(mention) {
return true;
}
// "Bixby" names "Bixby Reynolds"; "Bixby Reynolds" names "Bixby".
self.name
.split_whitespace()
.any(|part| part.eq_ignore_ascii_case(mention))
|| mention
.split_whitespace()
.any(|part| part.eq_ignore_ascii_case(&self.name))
}
}
/// Placeholder headings the template ships, which name no one.
const PLACEHOLDERS: &[&str] = &["name", "protagonist", "antagonist", "character"];
/// Section headings a sheet uses structurally, which are not descriptors.
const SECTION_HEADINGS: &[&str] = &["slug", "summary", "notes", "things to note"];
/// The descriptor heading beneath the name: the first heading below level one
/// that is neither a field line nor one of the sheet's structural sections.
pub fn subtitle(markdown: &str) -> Option<String> {
markdown.lines().find_map(|line| {
let trimmed = line.trim();
let hashes = trimmed.len() - trimmed.trim_start_matches('#').len();
if !(2..=6).contains(&hashes) {
return None;
}
let text = trimmed[hashes..].trim();
// A `## Slug: value` line is a field, and is read as one.
if text.is_empty() || text.contains(':') {
return None;
}
(!SECTION_HEADINGS.contains(&text.to_lowercase().as_str()))
.then(|| text.to_string())
})
}
/// The character's name from a sheet: its first level-1 heading, with a `Name:`
/// label removed. `None` for a sheet still carrying the template's placeholder.
pub fn heading_name(markdown: &str) -> Option<String> {
let heading = markdown
.lines()
.find_map(|line| line.trim().strip_prefix("# ").map(str::trim))?;
let name = match heading.split_once(':') {
Some((label, value)) if label.trim().eq_ignore_ascii_case("name") => value.trim(),
_ => heading,
};
let name = name.trim();
if name.is_empty() || PLACEHOLDERS.contains(&name.to_lowercase().as_str()) {
return None;
}
Some(name.to_string())
}
/// Read every character sheet at or below `dir`, sorted by name.
///
/// `marker` is the draft marker, so a sheet that happens to carry prose below
/// one does not have it read as fields.
pub fn read_sheets(dir: &Path, marker: &str) -> Vec<Character> {
let mut out = Vec::new();
for file in markdown_files_under(dir) {
let Ok(text) = std::fs::read_to_string(&file) else {
continue;
};
let Some(name) = heading_name(&text) else {
continue;
};
out.push(Character {
name,
file,
fields: crate::preprocess::fields(&text, marker),
subtitle: subtitle(&text),
});
}
out.sort_by_key(|c| c.name.to_lowercase());
out
}
/// Every `*.md` file at or below `dir`. Sheets are grouped into
/// `main_characters` / `supporting_characters`, so a flat read misses them.
fn markdown_files_under(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
// Guards against a symlink pointing back up the tree.
let mut budget = 512usize;
while let Some(current) = stack.pop() {
let Ok(read) = std::fs::read_dir(&current) else {
continue;
};
for entry in read.flatten() {
if budget == 0 {
return out;
}
budget -= 1;
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("md"))
{
out.push(path);
}
}
}
out.sort();
out
}
/// The project's existing character-grid file, if it has one: a markdown file
/// under `dir` whose name says "grid". Reusing it keeps the generated table
/// where the project already expects to find it.
pub fn grid_file(dir: &Path) -> Option<PathBuf> {
markdown_files_under(dir).into_iter().find(|p| {
p.file_stem()
.and_then(|s| s.to_str())
.is_some_and(|stem| stem.to_lowercase().contains("grid"))
})
}
/// Columns the generated grid carries, beyond the name.
pub const GRID_COLUMNS: &[&str] = &["Age", "Description", "Motivation"];
/// Render the cast as a markdown table, for the project's character grid.
///
/// The grid is a summary of the sheets, and a hand-maintained one drifts out of
/// date the moment a sheet changes — so it is generated instead. A markdown
/// table is used rather than the raw HTML the template ships, because the app
/// can render and diff markdown.
pub fn grid_markdown(characters: &[Character]) -> String {
let mut out = String::from(
"<!-- Generated from the character sheets by md-manuscript.\n \
Edits here are overwritten; change the sheets instead. -->\n\n\
# Character grid\n\n",
);
if characters.is_empty() {
out.push_str("_No character sheets found._\n");
return out;
}
out.push_str("| Name |");
for column in GRID_COLUMNS {
out.push_str(&format!(" {column} |"));
}
out.push_str("\n|---|");
out.push_str(&"---|".repeat(GRID_COLUMNS.len()));
out.push('\n');
for character in characters {
out.push_str(&format!("| {} |", escape_cell(&character.name)));
for column in GRID_COLUMNS {
let value = character.field(column).unwrap_or("");
out.push_str(&format!(" {} |", escape_cell(value)));
}
out.push('\n');
}
out
}
/// The grid is a quick-look summary, so a cell that runs to a paragraph is
/// trimmed to its opening.
const MAX_CELL_CHARS: usize = 110;
/// Make a value safe inside a markdown table cell: pipes would end the cell,
/// newlines would end the row, and a paragraph would make the table unreadable.
fn escape_cell(value: &str) -> String {
let flat = value.replace('|', "\\|").replace(['\n', '\r'], " ");
if flat.chars().count() <= MAX_CELL_CHARS {
return flat;
}
let mut out: String = flat.chars().take(MAX_CELL_CHARS).collect();
// Cut at a word boundary so the trim does not land mid-word.
if let Some(space) = out.rfind(' ') {
out.truncate(space);
}
out.push('…');
out
}
#[cfg(test)]
mod tests {
use super::*;
fn sheet(text: &str) -> Character {
Character {
name: heading_name(text).unwrap_or_default(),
file: PathBuf::from("x.md"),
fields: crate::preprocess::fields(text, "### Rough Draft:"),
subtitle: subtitle(text),
}
}
#[test]
fn a_name_is_read_from_either_heading_style() {
assert_eq!(
heading_name("# Bixby Reynolds\n\n### The broken truth-seeker\n").as_deref(),
Some("Bixby Reynolds")
);
assert_eq!(
heading_name("# Name: Jonathan Bronski\n").as_deref(),
Some("Jonathan Bronski")
);
}
#[test]
fn an_unfilled_template_sheet_names_no_one() {
assert_eq!(heading_name("# Name\n\n### Slug\n\nAge:\n"), None);
assert_eq!(heading_name("# Protagonist\n"), None);
assert_eq!(heading_name("no heading\n"), None);
assert_eq!(heading_name("## Not level one\n"), None);
}
#[test]
fn a_scene_mention_matches_by_any_part_of_the_name() {
let c = sheet("# Bixby Reynolds\n");
assert!(c.is_named_by("Bixby"));
assert!(c.is_named_by("bixby"));
assert!(c.is_named_by("Reynolds"));
assert!(c.is_named_by("Bixby Reynolds"));
assert!(!c.is_named_by("Paige"));
assert!(!c.is_named_by(""));
}
/// Two Reynoldses both answer to the surname, which is honest: the app
/// cannot know which one a bare "Reynolds" meant.
#[test]
fn a_shared_surname_matches_both_bearers() {
assert!(sheet("# Bixby Reynolds\n").is_named_by("Reynolds"));
assert!(sheet("# Paige Reynolds\n").is_named_by("Reynolds"));
}
#[test]
fn the_tagline_prefers_the_slug_then_the_heading_then_the_description() {
let with_slug = sheet("# A\n\n## Slug: the seeker\n\nDescription: a professor\n");
assert_eq!(with_slug.tagline(), Some("the seeker"));
// The other real sheet style puts the descriptor in a bare heading.
let with_heading = sheet("# A\n\n### The broken truth-seeker\n\nDescription: long\n");
assert_eq!(with_heading.tagline(), Some("The broken truth-seeker"));
let without = sheet("# A\n\nDescription: a professor\n");
assert_eq!(without.tagline(), Some("a professor"));
assert_eq!(sheet("# A\n").tagline(), None);
}
#[test]
fn structural_headings_are_not_descriptors() {
// The unfilled template leaves `### Slug` and `### Summary:` in place.
assert_eq!(subtitle("# A\n\n### Slug\n\n### Summary:\n"), None);
assert_eq!(subtitle("# A\n\n### Summary\n"), None);
// A level-one heading is the name, not a descriptor.
assert_eq!(subtitle("# A\n"), None);
}
#[test]
fn a_long_cell_is_trimmed_at_a_word_boundary() {
let long = "x".repeat(40) + " " + &"y".repeat(200);
let cast = vec![sheet(&format!("# A\n\nDescription: {long}\n"))];
let grid = grid_markdown(&cast);
let row = grid.lines().find(|l| l.starts_with("| A |")).unwrap();
assert!(row.contains('…'), "expected an ellipsis in {row}");
assert!(row.len() < 200, "cell was not trimmed: {}", row.len());
assert!(!row.contains("yyyyyyyyyyyyyyyyyyyy"));
}
#[test]
fn the_grid_has_a_row_per_character_and_a_header() {
let cast = vec![
sheet("# Bixby Reynolds\n\nAge: 37\nDescription: A professor\nMotivation: Grief\n"),
sheet("# Paige Reynolds\n\nAge: 37\n"),
];
let grid = grid_markdown(&cast);
assert!(grid.contains("| Name | Age | Description | Motivation |"));
assert!(grid.contains("| Bixby Reynolds | 37 | A professor | Grief |"));
// A sheet missing a field still gets a well-formed row.
assert!(grid.contains("| Paige Reynolds | 37 | | |"));
// Every row has the same number of separators as the header.
let bars: Vec<usize> = grid
.lines()
.filter(|l| l.starts_with('|'))
.map(|l| l.matches('|').count())
.collect();
assert!(bars.windows(2).all(|w| w[0] == w[1]), "ragged table: {bars:?}");
}
#[test]
fn a_pipe_in_a_value_cannot_break_the_table() {
let cast = vec![sheet("# A\n\nDescription: one | two\n")];
let grid = grid_markdown(&cast);
assert!(grid.contains("one \\| two"), "got {grid}");
}
#[test]
fn an_empty_cast_still_renders_something_readable() {
let grid = grid_markdown(&[]);
assert!(grid.contains("No character sheets found"));
}
#[test]
fn the_grid_says_it_is_generated() {
let grid = grid_markdown(&[sheet("# A\n")]);
assert!(grid.starts_with("<!--"), "a reader must know not to edit it");
assert!(grid.contains("change the sheets instead"));
}
}
+41
View File
@@ -11,6 +11,14 @@ pub struct Config {
/// Whether to show the live preview pane. /// Whether to show the live preview pane.
#[serde(default)] #[serde(default)]
pub show_preview: bool, pub show_preview: 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 /// Marker line separating a file's editorial header from its prose. Content
/// above it (except `# Title:` / `# Slug:` metadata) is dropped on export. /// above it (except `# Title:` / `# Slug:` metadata) is dropped on export.
/// An empty value disables header splitting. /// An empty value disables header splitting.
@@ -103,6 +111,22 @@ pub struct Config {
/// like the Mistral key. /// like the Mistral key.
#[serde(default)] #[serde(default)]
pub gitea_token: String, 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,
} }
/// Default cookiecutter template for **File ▸ New project…**. /// Default cookiecutter template for **File ▸ New project…**.
@@ -126,6 +150,17 @@ pub fn default_project_run_hooks() -> bool {
true 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. /// 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(|| {
@@ -202,6 +237,8 @@ impl Default for Config {
workspace, workspace,
export_path, export_path,
show_preview: false, show_preview: false,
collapse_header: false,
show_reference_files: false,
draft_marker: default_marker(), draft_marker: default_marker(),
zero_pad_index: false, zero_pad_index: false,
editor_zoom: 0.0, editor_zoom: 0.0,
@@ -226,6 +263,10 @@ impl Default for Config {
gitea_url: String::new(), gitea_url: String::new(),
gitea_user: String::new(), gitea_user: String::new(),
gitea_token: String::new(), gitea_token: String::new(),
hidden_folders: default_hidden_folders(),
archive_folder: default_archive_folder(),
manuscript_title: String::new(),
manuscript_author: String::new(),
} }
} }
} }
+31
View File
@@ -123,3 +123,34 @@ fn current_branch(workspace: &Path) -> String {
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.unwrap_or_else(|| "main".to_string()) .unwrap_or_else(|| "main".to_string())
} }
/// The unified diff of one file against the last commit, as raw diff text.
///
/// Revising is comparing: what changed in this scene since the last time it was
/// good? The repository already holds that answer, so the app asks git rather
/// than keeping snapshots of its own.
///
/// `rel` is relative to the *workspace*, which may sit below the repository
/// root, so the path is resolved through the work tree before being handed over.
pub fn diff_file(workspace: &Path, rel: &str) -> Result<String, String> {
let (ok, out) = run_git(
workspace,
&["--no-pager", "diff", "--no-color", "HEAD", "--", rel],
);
if !ok {
// A repository with no commits yet has no HEAD to compare against.
return Err(strip_command_echo(&out));
}
Ok(strip_command_echo(&out))
}
/// Whether the repository has at least one commit, and so something to diff.
pub fn has_commits(workspace: &Path) -> bool {
run_git(workspace, &["rev-parse", "--verify", "HEAD"]).0
}
/// Drop the `$ git …` line `run_git` prefixes for the log window; a diff view
/// wants the diff itself.
fn strip_command_echo(text: &str) -> String {
text.split_once('\n').map_or(text, |(_, rest)| rest).to_string()
}
+2
View File
@@ -3,6 +3,7 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod app; mod app;
mod characters;
mod config; mod config;
mod cookiecutter; mod cookiecutter;
mod gitsync; mod gitsync;
@@ -10,6 +11,7 @@ mod help;
mod langtool; mod langtool;
mod mistral; mod mistral;
mod odt; mod odt;
mod outline;
mod order; mod order;
mod preprocess; mod preprocess;
mod spell; mod spell;
+380 -5
View File
@@ -13,7 +13,7 @@
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use std::io::Write; use std::io::Write;
use std::path::Path; use std::path::{Path, PathBuf};
use zip::write::SimpleFileOptions; use zip::write::SimpleFileOptions;
/// One chapter to export. /// One chapter to export.
@@ -347,12 +347,55 @@ fn markdown_to_body(markdown: &str, heading_offset: u8) -> String {
const MIMETYPE: &str = "application/vnd.oasis.opendocument.text"; const MIMETYPE: &str = "application/vnd.oasis.opendocument.text";
/// The document properties written into an exported file: what a word processor
/// shows under File ▸ Properties, and what a submission system reads.
#[derive(Debug, Default, Clone)]
pub struct DocMeta {
pub title: String,
pub author: String,
pub subject: String,
pub keywords: String,
/// Statistics a reader expects to be filled in.
pub word_count: usize,
pub chapter_count: usize,
}
/// `meta.xml`: the document's title, author and statistics.
///
/// Without this member a word processor shows an exported manuscript as
/// untitled and authorless, which is not what you want to hand to an agent.
fn meta_xml(meta: &DocMeta) -> String {
let mut fields = String::new();
for (tag, value) in [
("dc:title", meta.title.trim()),
("dc:creator", meta.author.trim()),
("dc:subject", meta.subject.trim()),
("meta:keyword", meta.keywords.trim()),
("meta:initial-creator", meta.author.trim()),
] {
if !value.is_empty() {
fields.push_str(&format!(" <{tag}>{}</{tag}>\n", esc(value)));
}
}
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document-meta xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/" office:version="1.2">
<office:meta>
<meta:generator>md-manuscript</meta:generator>
{fields} <meta:document-statistic meta:word-count="{}" meta:page-count="{}"/>
</office:meta>
</office:document-meta>"#,
meta.word_count, meta.chapter_count
)
}
fn manifest_xml() -> String { fn manifest_xml() -> String {
r#"<?xml version="1.0" encoding="UTF-8"?> r#"<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2"> <manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">
<manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.text"/> <manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.text"/>
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/> <manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/> <manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>
</manifest:manifest>"# </manifest:manifest>"#
.to_string() .to_string()
} }
@@ -487,8 +530,9 @@ fn content_xml(chapters: &[Chapter]) -> String {
) )
} }
/// Write the chapters to an .odt file at `out_path`. /// Write the chapters to an .odt file at `out_path`, carrying `meta` as the
pub fn export(chapters: &[Chapter], out_path: &Path) -> std::io::Result<()> { /// document's properties.
pub fn export(chapters: &[Chapter], meta: &DocMeta, out_path: &Path) -> std::io::Result<()> {
let file = std::fs::File::create(out_path)?; let file = std::fs::File::create(out_path)?;
let mut zip = zip::ZipWriter::new(file); let mut zip = zip::ZipWriter::new(file);
@@ -506,6 +550,9 @@ pub fn export(chapters: &[Chapter], out_path: &Path) -> std::io::Result<()> {
.map_err(to_io)?; .map_err(to_io)?;
zip.write_all(manifest_xml().as_bytes())?; zip.write_all(manifest_xml().as_bytes())?;
zip.start_file("meta.xml", deflated).map_err(to_io)?;
zip.write_all(meta_xml(meta).as_bytes())?;
zip.start_file("styles.xml", deflated).map_err(to_io)?; zip.start_file("styles.xml", deflated).map_err(to_io)?;
zip.write_all(styles_xml().as_bytes())?; zip.write_all(styles_xml().as_bytes())?;
@@ -516,6 +563,157 @@ pub fn export(chapters: &[Chapter], out_path: &Path) -> std::io::Result<()> {
Ok(()) Ok(())
} }
// ---- Master documents -------------------------------------------------------
/// Media type of an OpenDocument **master** document (`.odm`) — a shell that
/// links subdocuments rather than holding the text itself.
const MASTER_MIMETYPE: &str = "application/vnd.oasis.opendocument.text-master";
/// Directory (beside the master) that the chapter subdocuments are written to.
pub const SUBDOC_DIR: &str = "chapters";
/// `content.xml` for a master document: one linked section per chapter.
fn master_content_xml(meta: &DocMeta, links: &[(String, String)]) -> String {
let mut sections = String::new();
if !meta.title.trim().is_empty() {
sections.push_str(&format!(
" <text:h text:style-name=\"ChapterTitle\" text:outline-level=\"1\">{}</text:h>\n",
esc(meta.title.trim())
));
}
if !meta.author.trim().is_empty() {
sections.push_str(&format!(
" <text:p text:style-name=\"ChapterSlug\">{}</text:p>\n",
esc(meta.author.trim())
));
}
for (name, href) in links {
sections.push_str(&format!(
" <text:section text:name=\"{}\" text:protected=\"false\">\n \
<text:section-source xlink:href=\"{}\" xlink:type=\"simple\" \
xlink:show=\"embed\" text:filter-name=\"writer8\"/>\n \
<text:p text:style-name=\"Text_20_body\"/>\n \
</text:section>\n",
esc(name),
esc(&uri_path(href)),
));
}
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" office:version="1.2">
<office:font-face-decls/>
<office:automatic-styles/>
<office:body>
<office:text>
{sections} </office:text>
</office:body>
</office:document-content>"#
)
}
/// Manifest for the master document package.
fn master_manifest_xml() -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">
<manifest:file-entry manifest:full-path="/" manifest:media-type="{MASTER_MIMETYPE}"/>
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>
</manifest:manifest>"#
)
}
/// Percent-encode a relative path for use as a URI, leaving the separators
/// alone. A raw space in an `xlink:href` is not a valid URI, and a reader that
/// cannot resolve the link silently shows an empty master document.
fn uri_path(path: &str) -> String {
let mut out = String::with_capacity(path.len());
for byte in path.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
out.push(byte as char)
}
other => out.push_str(&format!("%{other:02X}")),
}
}
out
}
/// A file name safe on every ordinary filesystem, for a chapter subdocument.
fn safe_file_stem(index: usize, title: &str) -> String {
let cleaned: String = title
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
c if c.is_control() => ' ',
c => c,
})
.collect();
let trimmed = cleaned.trim().trim_end_matches('.').trim();
let stem = if trimmed.is_empty() { "chapter" } else { trimmed };
// Numbered so the subdocuments sort in reading order in a file manager.
let stem: String = stem.chars().take(60).collect();
format!("{:02} {}", index + 1, stem.trim())
}
/// Write a master document at `out_path` plus one `.odt` per chapter in a
/// `chapters/` folder beside it.
///
/// This is what the snowflake template's `Full Text.odm` is for: a shell that
/// links the chapters, so a word processor can open the whole book while each
/// chapter stays an editable file of its own.
pub fn export_master(
chapters: &[Chapter],
meta: &DocMeta,
out_path: &Path,
) -> std::io::Result<Vec<PathBuf>> {
let parent = out_path.parent().unwrap_or(Path::new("."));
let subdir = parent.join(SUBDOC_DIR);
std::fs::create_dir_all(&subdir)?;
let mut links = Vec::new();
let mut written = Vec::new();
for (i, chapter) in chapters.iter().enumerate() {
let stem = safe_file_stem(i, &chapter.title);
let path = subdir.join(format!("{stem}.odt"));
// Each subdocument is a complete .odt carrying just its own chapter.
let chapter_meta = DocMeta {
title: chapter.title.clone(),
word_count: chapter.markdown.split_whitespace().count(),
chapter_count: 1,
..meta.clone()
};
export(std::slice::from_ref(chapter), &chapter_meta, &path)?;
links.push((
format!("Section{}", i + 1),
format!("{SUBDOC_DIR}/{stem}.odt"),
));
written.push(path);
}
let file = std::fs::File::create(out_path)?;
let mut zip = zip::ZipWriter::new(file);
let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
zip.start_file("mimetype", stored).map_err(to_io)?;
zip.write_all(MASTER_MIMETYPE.as_bytes())?;
let deflated =
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
zip.add_directory("META-INF/", deflated).map_err(to_io)?;
zip.start_file("META-INF/manifest.xml", deflated)
.map_err(to_io)?;
zip.write_all(master_manifest_xml().as_bytes())?;
zip.start_file("meta.xml", deflated).map_err(to_io)?;
zip.write_all(meta_xml(meta).as_bytes())?;
zip.start_file("styles.xml", deflated).map_err(to_io)?;
zip.write_all(styles_xml().as_bytes())?;
zip.start_file("content.xml", deflated).map_err(to_io)?;
zip.write_all(master_content_xml(meta, &links).as_bytes())?;
zip.finish().map_err(to_io)?;
Ok(written)
}
fn to_io(e: zip::result::ZipError) -> std::io::Error { fn to_io(e: zip::result::ZipError) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::Other, e) std::io::Error::new(std::io::ErrorKind::Other, e)
} }
@@ -584,14 +782,25 @@ Done.";
]; ];
let dir = std::env::temp_dir(); let dir = std::env::temp_dir();
let out = dir.join("md_manuscript_test.odt"); let out = dir.join("md_manuscript_test.odt");
export(&chapters, &out).expect("export ok"); let meta = DocMeta {
title: "The Winter Gate".to_string(),
author: "Lane Wight".to_string(),
..DocMeta::default()
};
export(&chapters, &meta, &out).expect("export ok");
// Re-open the archive and check required members exist with mimetype first. // Re-open the archive and check required members exist with mimetype first.
let file = std::fs::File::open(&out).unwrap(); let file = std::fs::File::open(&out).unwrap();
let mut zip = zip::ZipArchive::new(file).unwrap(); let mut zip = zip::ZipArchive::new(file).unwrap();
assert_eq!(zip.file_names().next().unwrap(), "mimetype"); assert_eq!(zip.file_names().next().unwrap(), "mimetype");
let names: Vec<String> = zip.file_names().map(|s| s.to_string()).collect(); let names: Vec<String> = zip.file_names().map(|s| s.to_string()).collect();
for required in ["mimetype", "META-INF/manifest.xml", "styles.xml", "content.xml"] { for required in [
"mimetype",
"META-INF/manifest.xml",
"styles.xml",
"content.xml",
"meta.xml",
] {
assert!(names.iter().any(|n| n == required), "missing {required}"); assert!(names.iter().any(|n| n == required), "missing {required}");
} }
use std::io::Read; use std::io::Read;
@@ -625,3 +834,169 @@ Done.";
assert!(styles.contains("fo:break-before=\"page\"")); assert!(styles.contains("fo:break-before=\"page\""));
} }
} }
#[cfg(test)]
mod meta_tests {
use super::*;
#[test]
fn document_properties_are_written() {
let meta = DocMeta {
title: "The Winter Gate".to_string(),
author: "Lane Wight".to_string(),
word_count: 1234,
chapter_count: 7,
..DocMeta::default()
};
let xml = meta_xml(&meta);
assert!(xml.contains("<dc:title>The Winter Gate</dc:title>"));
assert!(xml.contains("<dc:creator>Lane Wight</dc:creator>"));
assert!(xml.contains("<meta:initial-creator>Lane Wight</meta:initial-creator>"));
assert!(xml.contains(r#"meta:word-count="1234""#));
assert!(xml.contains(r#"meta:page-count="7""#));
}
/// A blank field must be omitted, not written as an empty element — an empty
/// `dc:creator` shows as an author named "" rather than as no author.
#[test]
fn blank_properties_are_left_out() {
let xml = meta_xml(&DocMeta {
title: "Only a title".to_string(),
..DocMeta::default()
});
assert!(xml.contains("<dc:title>"));
assert!(!xml.contains("<dc:creator>"), "got {xml}");
assert!(!xml.contains("<dc:subject>"));
}
#[test]
fn properties_are_xml_escaped() {
let xml = meta_xml(&DocMeta {
title: "Cats & Dogs <draft>".to_string(),
..DocMeta::default()
});
assert!(xml.contains("Cats &amp; Dogs &lt;draft&gt;"), "got {xml}");
}
#[test]
fn the_manifest_lists_every_member_written() {
let manifest = manifest_xml();
for member in ["content.xml", "styles.xml", "meta.xml"] {
assert!(
manifest.contains(&format!(r#"manifest:full-path="{member}""#)),
"{member} missing from the manifest"
);
}
}
}
#[cfg(test)]
mod master_tests {
use super::*;
fn chapters() -> Vec<Chapter> {
vec![
Chapter {
title: "The Gate".to_string(),
slug: Some("she finds it".to_string()),
markdown: "Ada stood before the gate.".to_string(),
},
Chapter {
title: "Aftermath: part two/three".to_string(),
slug: None,
markdown: "Nothing was the same.".to_string(),
},
]
}
#[test]
fn a_master_links_one_section_per_chapter() {
let dir = std::env::temp_dir().join("md_manuscript_master");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("book.odm");
let meta = DocMeta {
title: "The Winter Gate".to_string(),
author: "Lane Wight".to_string(),
..DocMeta::default()
};
let written = export_master(&chapters(), &meta, &out).expect("master written");
assert_eq!(written.len(), 2);
for path in &written {
assert!(path.is_file(), "{path:?} missing");
}
let file = std::fs::File::open(&out).unwrap();
let mut zip = zip::ZipArchive::new(file).unwrap();
// The mimetype must be first and must say "master".
assert_eq!(zip.file_names().next().unwrap(), "mimetype");
use std::io::Read;
let mut mime = String::new();
zip.by_name("mimetype").unwrap().read_to_string(&mut mime).unwrap();
assert_eq!(mime, MASTER_MIMETYPE);
let mut content = String::new();
zip.by_name("content.xml")
.unwrap()
.read_to_string(&mut content)
.unwrap();
assert_eq!(content.matches("<text:section ").count(), 2);
assert!(
content.contains(r#"xlink:href="chapters/01%20The%20Gate.odt""#),
"the href must be a valid URI; got {content}"
);
assert!(content.contains(r#"xlink:type="simple""#));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_chapter_title_cannot_produce_an_illegal_file_name() {
assert_eq!(safe_file_stem(0, "The Gate"), "01 The Gate");
assert_eq!(
safe_file_stem(1, "Aftermath: part two/three"),
"02 Aftermath- part two-three"
);
// A title that is only punctuation still yields a usable name.
assert_eq!(safe_file_stem(2, " "), "03 chapter");
assert_eq!(safe_file_stem(9, "Ten."), "10 Ten");
// And a very long title is cut to something a filesystem accepts.
let long = safe_file_stem(0, &"x".repeat(200));
assert!(long.len() < 80, "got {} chars", long.len());
}
#[test]
fn the_master_manifest_matches_what_is_written() {
let manifest = master_manifest_xml();
assert!(manifest.contains(MASTER_MIMETYPE));
for member in ["content.xml", "styles.xml", "meta.xml"] {
assert!(manifest.contains(&format!(r#"manifest:full-path="{member}""#)));
}
}
#[test]
fn section_names_are_unique() {
let links: Vec<(String, String)> = (0..3)
.map(|i| (format!("Section{}", i + 1), format!("chapters/{i}.odt")))
.collect();
let xml = master_content_xml(&DocMeta::default(), &links);
for name in ["Section1", "Section2", "Section3"] {
assert_eq!(xml.matches(&format!("text:name=\"{name}\"")).count(), 1);
}
}
}
#[cfg(test)]
mod uri_tests {
use super::*;
#[test]
fn a_path_becomes_a_valid_uri_without_losing_its_separators() {
assert_eq!(uri_path("chapters/01 The Gate.odt"), "chapters/01%20The%20Gate.odt");
assert_eq!(uri_path("plain.odt"), "plain.odt");
// Separators and the unreserved set survive untouched.
assert_eq!(uri_path("a/b-c_d.e~f"), "a/b-c_d.e~f");
// Anything else, including non-ASCII, is escaped byte by byte.
assert_eq!(uri_path("caf\u{e9}.odt"), "caf%C3%A9.odt");
assert_eq!(uri_path("a?b#c.odt"), "a%3Fb%23c.odt");
}
}
+139 -12
View File
@@ -53,15 +53,25 @@ const MAX_DEPTH: usize = 8;
/// List every `*.md` file at or below the workspace, as workspace-relative /// List every `*.md` file at or below the workspace, as workspace-relative
/// paths with `/` separators (e.g. `part-1/ch-03.md`). /// paths with `/` separators (e.g. `part-1/ch-03.md`).
fn list_markdown_files(workspace: &Path) -> Vec<String> { ///
/// `hidden` names folders the user has chosen to keep out of the panel entirely
/// (an archive of dead drafts, say), matched by [`dir_matches`] so a numeric
/// prefix is optional.
fn list_markdown_files(workspace: &Path, hidden: &[String]) -> Vec<String> {
let mut files = Vec::new(); let mut files = Vec::new();
collect_markdown(workspace, "", 0, &mut files); collect_markdown(workspace, "", 0, hidden, &mut files);
files files
} }
/// Recursive worker for [`list_markdown_files`]. `prefix` is the path of `dir` /// Recursive worker for [`list_markdown_files`]. `prefix` is the path of `dir`
/// relative to the workspace ("" at the top). /// relative to the workspace ("" at the top).
fn collect_markdown(dir: &Path, prefix: &str, depth: usize, out: &mut Vec<String>) { fn collect_markdown(
dir: &Path,
prefix: &str,
depth: usize,
hidden: &[String],
out: &mut Vec<String>,
) {
let Ok(entries) = std::fs::read_dir(dir) else { let Ok(entries) = std::fs::read_dir(dir) else {
return; return;
}; };
@@ -76,9 +86,10 @@ fn collect_markdown(dir: &Path, prefix: &str, depth: usize, out: &mut Vec<String
let path = entry.path(); let path = entry.path();
let rel = join_rel(prefix, &name); let rel = join_rel(prefix, &name);
if path.is_dir() { if path.is_dir() {
let skipped = SKIP_DIRS.iter().any(|d| d.eq_ignore_ascii_case(&name)); let skipped = SKIP_DIRS.iter().any(|d| d.eq_ignore_ascii_case(&name))
|| hidden.iter().any(|h| dir_matches(&name, h));
if !skipped && depth < MAX_DEPTH { if !skipped && depth < MAX_DEPTH {
collect_markdown(&path, &rel, depth + 1, out); collect_markdown(&path, &rel, depth + 1, hidden, out);
} }
} else if path } else if path
.extension() .extension()
@@ -162,6 +173,40 @@ pub fn sanitize_rel_path(input: &str) -> Option<String> {
Some(out) Some(out)
} }
/// Compare a directory name against a role name, ignoring case and any leading
/// numeric prefix: `10-Archive`, `10 Archive` and `archive` all match `Archive`.
/// The snowflake layout numbers its folders to order them, but the number is
/// presentation, not identity.
pub fn dir_matches(name: &str, role: &str) -> bool {
strip_order_prefix(name).eq_ignore_ascii_case(strip_order_prefix(role))
}
/// Drop a leading `NN-` / `NN ` / `NN_` ordering prefix from a folder name.
fn strip_order_prefix(name: &str) -> &str {
let trimmed = name.trim();
let rest = trimmed.trim_start_matches(|c: char| c.is_ascii_digit());
// Only a real separator counts, so `2061` stays `2061` rather than becoming
// an empty name.
match rest.strip_prefix(['-', '_', ' ']) {
Some(after) if !after.trim().is_empty() => after.trim(),
_ => trimmed,
}
}
/// The direct child of `dir` whose name matches `role`, if there is one.
pub fn child_dir_matching(dir: &Path, role: &str) -> Option<String> {
let role = role.trim();
if role.is_empty() {
return None;
}
std::fs::read_dir(dir)
.ok()?
.flatten()
.filter(|e| e.path().is_dir())
.filter_map(|e| e.file_name().to_str().map(str::to_string))
.find(|name| dir_matches(name, role))
}
/// One node of the folder tree that [`tree_order`] sorts. /// One node of the folder tree that [`tree_order`] sorts.
enum Node { enum Node {
File { name: String, rank: usize }, File { name: String, rank: usize },
@@ -245,9 +290,9 @@ pub fn tree_order(paths: &[String]) -> Vec<String> {
/// ///
/// The result is then normalised into folder-tree order, so subdirectories /// The result is then normalised into folder-tree order, so subdirectories
/// always read as coherent blocks however the saved order was written. /// always read as coherent blocks however the saved order was written.
pub fn resolve_order(workspace: &Path) -> Vec<String> { pub fn resolve_order(workspace: &Path, hidden: &[String]) -> Vec<String> {
let saved = read_saved_order(workspace); let saved = read_saved_order(workspace);
let mut present = list_markdown_files(workspace); let mut present = list_markdown_files(workspace, hidden);
present.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); present.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
let mut result: Vec<String> = Vec::new(); let mut result: Vec<String> = Vec::new();
@@ -372,7 +417,7 @@ mod tests {
"nested", "nested",
&["top.md", "part-1/ch-01.md", "part-1/chapter-02/scene-a.md"], &["top.md", "part-1/ch-01.md", "part-1/chapter-02/scene-a.md"],
); );
let mut found = list_markdown_files(&ws); let mut found = list_markdown_files(&ws, &[]);
found.sort(); found.sort();
assert_eq!( assert_eq!(
found, found,
@@ -399,7 +444,7 @@ mod tests {
"notes.txt", "notes.txt",
], ],
); );
let mut found = list_markdown_files(&ws); let mut found = list_markdown_files(&ws, &[]);
found.sort(); found.sort();
assert_eq!(found, v(&["keep.md", "p/deep.md"])); assert_eq!(found, v(&["keep.md", "p/deep.md"]));
let _ = std::fs::remove_dir_all(&ws); let _ = std::fs::remove_dir_all(&ws);
@@ -412,7 +457,7 @@ mod tests {
let ok = "a/".repeat(MAX_DEPTH) + "deep.md"; let ok = "a/".repeat(MAX_DEPTH) + "deep.md";
let buried = "a/".repeat(MAX_DEPTH + 1) + "buried.md"; let buried = "a/".repeat(MAX_DEPTH + 1) + "buried.md";
let ws = scratch("depth", &[&ok, &buried]); let ws = scratch("depth", &[&ok, &buried]);
let found = list_markdown_files(&ws); let found = list_markdown_files(&ws, &[]);
assert!(found.contains(&ok), "expected {ok} in {found:?}"); assert!(found.contains(&ok), "expected {ok} in {found:?}");
assert!(!found.contains(&buried), "{buried} is past the cap"); assert!(!found.contains(&buried), "{buried} is past the cap");
let _ = std::fs::remove_dir_all(&ws); let _ = std::fs::remove_dir_all(&ws);
@@ -433,7 +478,7 @@ mod tests {
// pulled together where its earliest saved member sat. `a.md` was never // pulled together where its earliest saved member sat. `a.md` was never
// in the saved order, so it lands last. // in the saved order, so it lands last.
assert_eq!( assert_eq!(
resolve_order(&ws), resolve_order(&ws, &[]),
v(&["part-1/ch-02.md", "part-1/ch-01.md", "b.md", "a.md"]) v(&["part-1/ch-02.md", "part-1/ch-01.md", "b.md", "a.md"])
); );
let _ = std::fs::remove_dir_all(&ws); let _ = std::fs::remove_dir_all(&ws);
@@ -445,7 +490,7 @@ mod tests {
write_order(&ws, &v(&["a.md"])).unwrap(); write_order(&ws, &v(&["a.md"])).unwrap();
write_titles(&ws, &HashMap::new()).unwrap(); write_titles(&ws, &HashMap::new()).unwrap();
// order.json/titles.json are not markdown, so they never join the list. // order.json/titles.json are not markdown, so they never join the list.
assert_eq!(resolve_order(&ws), v(&["a.md"])); assert_eq!(resolve_order(&ws, &[]), v(&["a.md"]));
let _ = std::fs::remove_dir_all(&ws); let _ = std::fs::remove_dir_all(&ws);
} }
@@ -522,4 +567,86 @@ mod tests {
// A name that is nothing but the extension leaves an empty stem. // A name that is nothing but the extension leaves an empty stem.
assert_eq!(sanitize_rel_path("p/.md"), None); assert_eq!(sanitize_rel_path("p/.md"), None);
} }
// ---- Project folder roles ----------------------------------------------
#[test]
fn a_numeric_prefix_is_presentation_not_identity() {
assert!(dir_matches("10-Archive", "Archive"));
assert!(dir_matches("Archive", "Archive"));
assert!(dir_matches("archive", "ARCHIVE"));
assert!(dir_matches("03-Characters", "Characters"));
assert!(dir_matches("06-First Draft", "06-First Draft"));
assert!(dir_matches("06-First Draft", "First Draft"));
assert!(dir_matches("6 First Draft", "First Draft"));
assert!(dir_matches("06_First Draft", "First Draft"));
}
#[test]
fn folders_that_merely_look_similar_do_not_match() {
assert!(!dir_matches("10-Archives", "Archive"));
assert!(!dir_matches("04-Outline", "Archive"));
assert!(!dir_matches("character_grid", "Characters"));
// An all-digit name keeps its digits rather than becoming empty.
assert!(!dir_matches("2061", "Archive"));
assert!(dir_matches("2061", "2061"));
}
#[test]
fn hidden_folders_are_kept_out_of_the_scan() {
let ws = scratch(
"hidden",
&[
"06-First Draft/ch-01.md",
"03-Characters/ada.md",
"10-Archive/old/dead-draft.md",
"10-Archive/also-dead.md",
],
);
// Nothing hidden: the archive's files are ordinary files.
assert_eq!(list_markdown_files(&ws, &[]).len(), 4);
// Hiding it by role name drops the whole subtree, prefix and all.
let hidden = vec!["Archive".to_string()];
let mut kept = list_markdown_files(&ws, &hidden);
kept.sort();
assert_eq!(kept, v(&["03-Characters/ada.md", "06-First Draft/ch-01.md"]));
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn resolve_order_honours_the_hidden_list_too() {
let ws = scratch("hidden_order", &["a.md", "10-Archive/b.md"]);
assert_eq!(resolve_order(&ws, &["Archive".to_string()]), v(&["a.md"]));
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn the_manuscript_folder_is_found_by_role_name() {
let ws = scratch(
"project",
&["06-First Draft/ch-01.md", "03-Characters/ada.md"],
);
// The configured name matches with or without its number.
assert_eq!(
child_dir_matching(&ws, "06-First Draft").as_deref(),
Some("06-First Draft")
);
assert_eq!(
child_dir_matching(&ws, "First Draft").as_deref(),
Some("06-First Draft")
);
// A folder the project does not have is not invented.
assert_eq!(child_dir_matching(&ws, "09-Covers"), None);
assert_eq!(child_dir_matching(&ws, ""), None);
let _ = std::fs::remove_dir_all(&ws);
}
/// Opening the manuscript folder itself must not look like a project root,
/// or its own chapters would be treated as reference material.
#[test]
fn a_plain_chapter_folder_is_not_a_project_root() {
let ws = scratch("plain", &["ch-01.md", "ch-02.md"]);
assert_eq!(child_dir_matching(&ws, "06-First Draft"), None);
let _ = std::fs::remove_dir_all(&ws);
}
} }
+223
View File
@@ -0,0 +1,223 @@
//! Reading the state of a project's outline.
//!
//! The snowflake template's outline files are scaffolds made entirely of HTML
//! comments, one per story beat:
//!
//! ```text
//! ## Act I Setup (≈ 25 % of the story)
//!
//! <!-- Opening Image / Hook: A vivid snapshot of the ordinary world. -->
//!
//! <!-- Inciting Incident: An event that disrupts the status-quo. -->
//! ```
//!
//! The app strips comments everywhere else, which means these files render and
//! export as blank — there is nothing to see and no way to tell how much of the
//! outline is actually written. This module reads the scaffold back: the beats
//! it names, and which of them have prose under them yet.
/// One beat of an outline, and whether it has been written.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Beat {
/// The heading this beat sits under, e.g. `Act I Setup`.
pub section: String,
/// The beat's name, taken from the start of its prompt comment.
pub name: String,
/// Whether any prose has been written for it.
pub filled: bool,
}
/// Read an outline file's beats and their state.
///
/// A beat is "filled" when there is ordinary text between its prompt comment
/// and the next prompt or heading — which is exactly where you would type it.
pub fn beats(markdown: &str) -> Vec<Beat> {
let mut out: Vec<Beat> = Vec::new();
let mut section = String::new();
let mut rest = markdown;
// Text seen since the last prompt, which decides whether it is filled.
let mut pending_prose = String::new();
while let Some(start) = rest.find("<!--") {
let before = &rest[..start];
// Anything before this comment belongs to the previous beat.
pending_prose.push_str(before);
if let Some(heading) = last_heading(before) {
// A heading closes the previous beat and opens a new section.
settle(&mut out, &mut pending_prose);
section = heading;
}
settle(&mut out, &mut pending_prose);
let Some(end) = rest[start + 4..].find("-->") else {
break;
};
let body = &rest[start + 4..start + 4 + end];
if let Some(name) = prompt_name(body) {
out.push(Beat {
section: section.clone(),
name,
filled: false,
});
}
rest = &rest[start + 4 + end + 3..];
}
pending_prose.push_str(rest);
if let Some(heading) = last_heading(rest) {
settle(&mut out, &mut pending_prose);
let _ = heading; // a trailing heading opens nothing
}
settle(&mut out, &mut pending_prose);
out
}
/// Credit whatever prose has accumulated to the most recent beat, then clear it.
fn settle(beats: &mut [Beat], pending: &mut String) {
if has_prose(pending) {
if let Some(last) = beats.last_mut() {
last.filled = true;
}
}
pending.clear();
}
/// Whether a run of text contains anything a writer would call content —
/// headings and blank lines do not count.
fn has_prose(text: &str) -> bool {
text.lines()
.map(str::trim)
.any(|line| !line.is_empty() && !line.starts_with('#'))
}
/// The last markdown heading in a run of text, if any.
fn last_heading(text: &str) -> Option<String> {
text.lines().rev().find_map(|line| {
let trimmed = line.trim();
let hashes = trimmed.len() - trimmed.trim_start_matches('#').len();
if hashes == 0 {
return None;
}
let name = trimmed[hashes..].trim();
(!name.is_empty()).then(|| name.to_string())
})
}
/// The beat name at the start of a prompt comment: everything before its first
/// colon, which is how the template writes them (`Midpoint: A major turning…`).
fn prompt_name(body: &str) -> Option<String> {
let text = body.trim();
if text.is_empty() {
return None;
}
let name = match text.split_once(':') {
Some((name, _)) => name.trim(),
// A comment with no colon is a note, not a beat prompt.
None => return None,
};
// Prompt names are labels; a long one is a sentence that happened to have
// a colon in it.
if name.is_empty() || name.split_whitespace().count() > 8 {
return None;
}
Some(name.to_string())
}
/// How many of `beats` are written, and how many there are.
pub fn progress(beats: &[Beat]) -> (usize, usize) {
(beats.iter().filter(|b| b.filled).count(), beats.len())
}
#[cfg(test)]
mod tests {
use super::*;
const SCAFFOLD: &str = "\
## Act I Setup ( 25 % of the story)
<!-- Opening Image / Hook: A vivid snapshot of the ordinary world. -->
<!-- Inciting Incident: An event that disrupts the status-quo. -->
## Act II Confrontation
<!-- Midpoint: A major turning point. -->
";
#[test]
fn an_untouched_scaffold_has_no_beats_written() {
let beats = beats(SCAFFOLD);
assert_eq!(beats.len(), 3);
assert!(beats.iter().all(|b| !b.filled));
assert_eq!(progress(&beats), (0, 3));
}
#[test]
fn beat_names_and_their_sections_are_read() {
let beats = beats(SCAFFOLD);
assert_eq!(beats[0].name, "Opening Image / Hook");
assert_eq!(beats[0].section, "Act I Setup (≈ 25 % of the story)");
assert_eq!(beats[1].name, "Inciting Incident");
assert_eq!(beats[2].name, "Midpoint");
assert_eq!(beats[2].section, "Act II Confrontation");
}
#[test]
fn prose_after_a_prompt_marks_it_written() {
let written = "\
## Act I
<!-- Opening Image: the prompt. -->
Ada stands on the ice, counting bolts.
<!-- Inciting Incident: the prompt. -->
";
let beats = beats(written);
assert_eq!(progress(&beats), (1, 2));
assert!(beats[0].filled, "the beat with prose under it");
assert!(!beats[1].filled, "the one still empty");
}
#[test]
fn a_heading_does_not_count_as_prose() {
let text = "<!-- A: prompt -->\n\n## Act II\n\n<!-- B: prompt -->\n";
let beats = beats(text);
assert_eq!(progress(&beats), (0, 2));
}
#[test]
fn prose_after_the_last_prompt_still_counts() {
let text = "<!-- Climax: prompt -->\n\nShe opens the gate.\n";
let beats = beats(text);
assert_eq!(progress(&beats), (1, 1));
}
#[test]
fn a_comment_without_a_colon_is_a_note_not_a_beat() {
let text = "<!-- just a note to self -->\n\n<!-- Climax: prompt -->\n";
let beats = beats(text);
assert_eq!(beats.len(), 1);
assert_eq!(beats[0].name, "Climax");
}
#[test]
fn an_unterminated_comment_does_not_hang_or_panic() {
let beats = beats("<!-- Climax: prompt -->\n\n<!-- broken");
assert_eq!(beats.len(), 1);
}
#[test]
fn a_file_with_no_comments_yields_nothing() {
assert!(beats("# Just prose\n\nWords.\n").is_empty());
assert!(beats("").is_empty());
}
/// Prose written *before* the first prompt belongs to no beat and must not
/// be credited to one that comes later.
#[test]
fn prose_before_the_first_prompt_credits_nothing() {
let text = "Some preamble.\n\n<!-- Climax: prompt -->\n";
assert_eq!(progress(&beats(text)), (0, 1));
}
}
+289
View File
@@ -88,6 +88,132 @@ pub fn strip_comments(s: &str) -> String {
out out
} }
/// Split a document at its draft marker into `(header, body)`, where the header
/// includes the marker line itself and its newline.
///
/// The split is exact: `header + body` is the original text, byte for byte, so
/// a caller can lift the header out and put it back without touching what the
/// author wrote. `None` when the document has no marker to split at.
pub fn split_at_marker<'a>(text: &'a str, marker: &str) -> Option<(&'a str, &'a str)> {
let marker_norm = marker.trim().to_ascii_lowercase();
if marker_norm.is_empty() {
return None;
}
let mut offset = 0usize;
for line in text.split_inclusive('\n') {
if line.trim().to_ascii_lowercase().starts_with(&marker_norm) {
let end = offset + line.len();
return Some((&text[..end], &text[end..]));
}
offset += line.len();
}
None
}
/// One `#* Name: value` line from a document's editorial header.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Field {
/// The field name as written, e.g. `Word Count Target`.
pub name: String,
/// Everything after the colon, trimmed. May be empty for an unfilled field.
pub value: String,
}
/// Every `#* Name: value` line in a document's header, in the order written.
///
/// This is the generic form of what [`parse`] pulls out for the few fields the
/// exporter cares about: the scene card, the status column and the character
/// index all read fields the exporter has no opinion about, so they read them
/// from here rather than each re-implementing the line format.
///
/// Only the header is considered — lines above `marker`, or the whole document
/// when there is none — so a `## Setting:` written inside the prose is not
/// mistaken for card metadata.
pub fn fields(markdown: &str, marker: &str) -> Vec<Field> {
let text = strip_comments(markdown);
let marker_norm = marker.trim().to_ascii_lowercase();
let mut out = Vec::new();
for line in text.lines() {
if !marker_norm.is_empty()
&& line.trim().to_ascii_lowercase().starts_with(&marker_norm)
{
break;
}
if let Some(field) = field_line(line) {
out.push(field);
}
}
out
}
/// Look up one field by name, case-insensitively. Empty values give `None`, so
/// an unfilled `## POV:` reads the same as an absent one.
pub fn field(markdown: &str, marker: &str, name: &str) -> Option<String> {
fields(markdown, marker)
.into_iter()
.find(|f| f.name.eq_ignore_ascii_case(name.trim()))
.map(|f| f.value)
.filter(|v| !v.is_empty())
}
/// Split a comma-separated field value into its items, dropping blanks — the
/// form `## Characters: Bixby, Mom, Kayla` uses.
pub fn value_items(value: &str) -> Vec<String> {
value
.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
.map(str::to_string)
.collect()
}
/// If `line` is a header field, split it into name and value.
///
/// Two forms are in use and both are accepted, because real files mix them:
/// scene cards write `## POV: Bixby`, while character sheets often write a bare
/// `Age: 37`. The bare form is the looser one, so it carries a stricter test —
/// its name may only be letters, spaces, hyphens and apostrophes — which keeps
/// ordinary prose out. A line of dialogue like `Bixby: run` would still be read
/// as a field; only the header is scanned, so that matters just for documents
/// with no draft marker, where the header is the whole file.
fn field_line(line: &str) -> Option<Field> {
let trimmed = line.trim_start();
let hashes = trimmed.len() - trimmed.trim_start_matches('#').len();
let (rest, headed) = if hashes > 0 {
let after_hash = &trimmed[hashes..];
if !after_hash.starts_with(char::is_whitespace) {
return None;
}
(after_hash.trim(), true)
} else {
// A bare field must start the line, so an indented or wrapped line of
// prose is never mistaken for one.
if line.starts_with(char::is_whitespace) {
return None;
}
(trimmed, false)
};
let (name, value) = rest.split_once(':')?;
let name = name.trim();
// A field name is a short label, not a sentence. The longest in real use is
// three words ("Word Count Target"), so four leaves headroom while rejecting
// most prose headings that happen to contain a colon.
if name.is_empty() || name.split_whitespace().count() > 4 || name.ends_with('.') {
return None;
}
if !headed
&& !name
.chars()
.all(|c| c.is_alphabetic() || c == ' ' || c == '-' || c == '\'')
{
return None;
}
Some(Field {
name: name.to_string(),
value: value.trim().to_string(),
})
}
/// If `line` is a recognised metadata line — `# Title:`, `# Slug:`, or /// If `line` is a recognised metadata line — `# Title:`, `# Slug:`, or
/// `## Word Count Target:` — return which one and its value. Matches one or more /// `## Word Count Target:` — return which one and its value. Matches one or more
/// leading `#` followed by whitespace, case-insensitively, so the field can sit /// leading `#` followed by whitespace, case-insensitively, so the field can sit
@@ -284,3 +410,166 @@ mod tests {
assert_eq!(parse(md2, MARKER).pov, None); assert_eq!(parse(md2, MARKER).pov, None);
} }
} }
#[cfg(test)]
mod field_tests {
use super::*;
/// A real scene card from the snowflake plot folder.
const CARD: &str = "\
# Title:
# Slug: 2026 - Bixby
## POV: Bixby
## Characters: Bixby, Mom, Kayla
## Setting: The Old Reynold's House
## Word Count Target: 1500 - 2000
### Rough Draft:
She stood there. The setting: a house. Not a field: prose.
";
#[test]
fn every_header_field_is_read_in_order() {
let names: Vec<String> = fields(CARD, "### Rough Draft:")
.into_iter()
.map(|f| f.name)
.collect();
assert_eq!(
names,
["Title", "Slug", "POV", "Characters", "Setting", "Word Count Target"]
);
}
#[test]
fn a_field_is_found_by_name_whatever_its_case() {
assert_eq!(field(CARD, "### Rough Draft:", "POV").as_deref(), Some("Bixby"));
assert_eq!(field(CARD, "### Rough Draft:", "pov").as_deref(), Some("Bixby"));
assert_eq!(
field(CARD, "### Rough Draft:", "Setting").as_deref(),
Some("The Old Reynold's House")
);
// An unfilled field reads as absent rather than as an empty string.
assert_eq!(field(CARD, "### Rough Draft:", "Title"), None);
assert_eq!(field(CARD, "### Rough Draft:", "Nope"), None);
}
/// The body is where prose lives; a colon in it is not card metadata.
#[test]
fn prose_below_the_marker_is_not_scanned() {
assert_eq!(field(CARD, "### Rough Draft:", "The setting"), None);
let names: Vec<String> = fields(CARD, "### Rough Draft:")
.into_iter()
.map(|f| f.name)
.collect();
assert!(!names.iter().any(|n| n.eq_ignore_ascii_case("Not a field")));
}
#[test]
fn a_comma_list_splits_into_items() {
assert_eq!(value_items("Bixby, Mom, Kayla"), ["Bixby", "Mom", "Kayla"]);
assert_eq!(value_items(" Bixby ,, Kayla "), ["Bixby", "Kayla"]);
assert!(value_items("").is_empty());
assert_eq!(value_items("Bixby"), ["Bixby"]);
}
#[test]
fn a_prose_heading_is_not_a_field() {
// A heading long enough to be a sentence is a heading, not metadata.
assert!(field_line("## And then she said to her: run").is_none());
assert!(field_line("## A very long heading indeed that goes on: yes").is_none());
// The longest field name actually in use still parses.
assert_eq!(
field_line("## Word Count Target: 1500").map(|f| f.name),
Some("Word Count Target".to_string())
);
assert!(field_line("## No colon here").is_none());
// A bare field line is accepted — character sheets are written that way.
assert_eq!(
field_line("Age: 37"),
Some(Field { name: "Age".into(), value: "37".into() })
);
// But not one whose label could not be a label.
assert!(field_line("At 17, Bixby was hurt: badly").is_none());
assert!(field_line(" indented: value").is_none());
assert!(field_line("2026 - Bixby: something").is_none());
assert!(field_line("#NoSpace: value").is_none());
// But a short label is.
assert_eq!(
field_line("## POV: Bixby"),
Some(Field { name: "POV".into(), value: "Bixby".into() })
);
}
#[test]
fn comments_are_stripped_before_fields_are_read() {
let text = "<!-- ## POV: Ghost -->\n## POV: Bixby\n";
assert_eq!(field(text, "", "POV").as_deref(), Some("Bixby"));
}
#[test]
fn without_a_marker_the_whole_document_is_scanned() {
assert_eq!(field("## POV: Ada\n\nprose\n", "", "POV").as_deref(), Some("Ada"));
}
}
#[cfg(test)]
mod split_tests {
use super::*;
const DOC: &str = "# Title: A\n\n## POV: Ada\n\n### Rough Draft:\n\nShe waited.\n";
#[test]
fn the_split_is_lossless() {
let (header, body) = split_at_marker(DOC, "### Rough Draft:").unwrap();
assert_eq!(format!("{header}{body}"), DOC, "rejoining must be exact");
assert!(header.ends_with("### Rough Draft:\n"));
assert_eq!(body, "\nShe waited.\n");
}
#[test]
fn the_marker_is_matched_loosely_like_everywhere_else() {
// Case and trailing text on the marker line are tolerated.
let doc = "# A\n### rough draft: \n\nprose\n";
let (header, body) = split_at_marker(doc, "### Rough Draft:").unwrap();
assert!(header.ends_with("### rough draft: \n"));
assert_eq!(body, "\nprose\n");
}
#[test]
fn a_document_without_a_marker_does_not_split() {
assert!(split_at_marker("# A\n\nprose\n", "### Rough Draft:").is_none());
// An empty marker disables splitting, as it disables header parsing.
assert!(split_at_marker(DOC, "").is_none());
assert!(split_at_marker(DOC, " ").is_none());
}
#[test]
fn only_the_first_marker_splits() {
let doc = "# A\n### Rough Draft:\nfirst\n### Rough Draft:\nsecond\n";
let (header, body) = split_at_marker(doc, "### Rough Draft:").unwrap();
assert_eq!(header, "# A\n### Rough Draft:\n");
assert!(body.starts_with("first\n"));
}
#[test]
fn a_marker_on_the_last_line_leaves_an_empty_body() {
let doc = "# A\n### Rough Draft:";
let (header, body) = split_at_marker(doc, "### Rough Draft:").unwrap();
assert_eq!(header, doc);
assert_eq!(body, "");
}
#[test]
fn multibyte_text_splits_on_a_character_boundary() {
let doc = "# Título: Café\n### Rough Draft:\nprosa — sí\n";
let (header, body) = split_at_marker(doc, "### Rough Draft:").unwrap();
assert_eq!(format!("{header}{body}"), doc);
assert_eq!(body, "prosa — sí\n");
}
}
+318 -10
View File
@@ -13,7 +13,7 @@
use crate::langtool::Match; use crate::langtool::Match;
use spellbook::Dictionary; use spellbook::Dictionary;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::path::PathBuf; use std::path::PathBuf;
// --- Bundled dictionaries (SCOWL-derived, permissive license; see the files // --- Bundled dictionaries (SCOWL-derived, permissive license; see the files
@@ -139,6 +139,160 @@ fn search_dirs() -> Vec<PathBuf> {
dirs dirs
} }
// ---- The project word list --------------------------------------------------
//
// Character names, place names and invented terms are unknown to any Hunspell
// dictionary, so without somewhere to record them a novel's cast is underlined
// on every line forever. The list lives in the workspace beside `order.json`
// and `titles.json`, so it is committed to git and travels with the project.
/// Name of the file (inside the workspace) that stores the accepted words.
pub const WORDLIST_FILE: &str = "wordlist.json";
/// Words the spell checker should accept on top of the loaded dictionary.
///
/// Matching is case-insensitive, and a listed word also covers its possessive
/// and plural forms — the tokenizer hands over `Bixby's` as a single token, so
/// a bare `Bixby` would otherwise not cover it.
#[derive(Debug, Default, Clone)]
pub struct WordList {
/// Display forms, sorted and deduplicated case-insensitively.
words: Vec<String>,
/// Lowercased forms, for lookup.
lookup: HashSet<String>,
}
impl WordList {
/// Build from any iterator of words, discarding blanks and duplicates.
pub fn from_words<I, S>(words: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut list = WordList::default();
for word in words {
list.insert(word.as_ref());
}
list
}
/// Add a word. Returns whether it was new.
pub fn insert(&mut self, word: &str) -> bool {
let trimmed = word.trim();
if trimmed.is_empty() {
return false;
}
let lower = trimmed.to_lowercase();
if !self.lookup.insert(lower) {
return false;
}
self.words.push(trimmed.to_string());
self.words
.sort_by_key(|w| (w.to_lowercase(), w.to_string()));
true
}
/// Remove a word (case-insensitively). Returns whether it was there.
pub fn remove(&mut self, word: &str) -> bool {
let lower = word.trim().to_lowercase();
if !self.lookup.remove(&lower) {
return false;
}
self.words.retain(|w| w.to_lowercase() != lower);
true
}
/// Whether the checker should accept this token.
pub fn accepts(&self, word: &str) -> bool {
if self.lookup.is_empty() {
return false;
}
stems(word).any(|stem| self.lookup.contains(&stem.to_lowercase()))
}
/// The accepted words in display order.
pub fn words(&self) -> &[String] {
&self.words
}
pub fn len(&self) -> usize {
self.words.len()
}
pub fn is_empty(&self) -> bool {
self.words.is_empty()
}
}
/// A token and the forms of it a listed word should also cover: the possessive
/// (`Bixby's`), the plural (`Bixbys`) and the plural possessive (`Bixbys'`).
fn stems(word: &str) -> impl Iterator<Item = &str> {
let mut out: Vec<&str> = vec![word];
// Possessive first, then whatever plural is left underneath it.
let base = word
.strip_suffix("'s")
.or_else(|| word.strip_suffix("\u{2019}s"))
.or_else(|| word.strip_suffix('\''))
.or_else(|| word.strip_suffix('\u{2019}'))
.unwrap_or(word);
if base != word {
out.push(base);
}
if let Some(singular) = base.strip_suffix('s').filter(|s| s.len() >= 2) {
out.push(singular);
}
out.into_iter()
}
/// Read the workspace's word list. Missing or invalid gives an empty list, so a
/// hand-edited file can never stop the checker from running.
pub fn read_wordlist(workspace: &std::path::Path) -> WordList {
let path = workspace.join(WORDLIST_FILE);
let words: Vec<String> = match std::fs::read_to_string(&path) {
Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
Err(_) => Vec::new(),
};
WordList::from_words(words)
}
/// Persist the word list as a sorted JSON array, so git diffs stay readable.
pub fn write_wordlist(workspace: &std::path::Path, list: &WordList) -> std::io::Result<()> {
let path = workspace.join(WORDLIST_FILE);
let text = serde_json::to_string_pretty(list.words()).unwrap_or_else(|_| "[]".to_string());
std::fs::write(path, text)
}
// ---- Harvesting names from character sheets ---------------------------------
/// Whether a directory name looks like a project's character folder, allowing
/// for the numeric prefixes the snowflake layout uses (`03-Characters`).
pub fn is_characters_dir(name: &str) -> bool {
crate::order::dir_matches(name, "Characters")
|| crate::order::dir_matches(name, "Character")
}
/// Pull the name words out of a character sheet, so the cast stops being
/// underlined. Each part is listed separately, since prose says "Bixby" far
/// more often than "Bixby Reynolds".
///
/// The name itself comes from [`crate::characters::heading_name`], which is
/// also what the Characters window reads — one rule for what a sheet is called.
pub fn names_from_sheet(markdown: &str) -> Vec<String> {
let Some(name) = crate::characters::heading_name(markdown) else {
return Vec::new();
};
name.split_whitespace()
.map(|word| {
word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '\u{2019}')
})
.filter(|word| {
word.chars().filter(|c| c.is_alphabetic()).count() >= 2
&& word.chars().next().is_some_and(char::is_uppercase)
})
.map(str::to_string)
.collect()
}
/// How many distinct misspellings we compute suggestions for per check. Beyond /// How many distinct misspellings we compute suggestions for per check. Beyond
/// this the words are still underlined but carry no suggestions (a right-click /// this the words are still underlined but carry no suggestions (a right-click
/// falls back to computing them on demand). Keeps a document full of unknown /// falls back to computing them on demand). Keeps a document full of unknown
@@ -146,14 +300,16 @@ fn search_dirs() -> Vec<PathBuf> {
const MAX_SUGGEST_WORDS: usize = 250; const MAX_SUGGEST_WORDS: usize = 250;
/// Spell-check the prose in `text`, returning a spelling [`Match`] for every /// Spell-check the prose in `text`, returning a spelling [`Match`] for every
/// unknown word (byte offsets into `text`, best suggestions first). /// unknown word (byte offsets into `text`, best suggestions first). Words in
pub fn check(dict: &Dictionary, text: &str) -> Vec<Match> { /// `extra` — the project's own names and invented terms — are accepted as if
/// they were in the dictionary.
pub fn check(dict: &Dictionary, extra: &WordList, text: &str) -> Vec<Match> {
let mut out = Vec::new(); let mut out = Vec::new();
let mut cache: HashMap<&str, Vec<String>> = HashMap::new(); let mut cache: HashMap<&str, Vec<String>> = HashMap::new();
let mut suggested = 0usize; let mut suggested = 0usize;
for (offset, word) in prose_words(text) { for (offset, word) in prose_words(text) {
if should_skip(word) || dict.check(word) { if should_skip(word) || dict.check(word) || extra.accepts(word) {
continue; continue;
} }
let replacements = if let Some(cached) = cache.get(word) { let replacements = if let Some(cached) = cache.get(word) {
@@ -303,8 +459,8 @@ mod tests {
.load() .load()
.unwrap(); .unwrap();
// "realise" is British; "color" (American) is not en-GB. // "realise" is British; "color" (American) is not en-GB.
assert!(check(&dict, "realise").is_empty()); assert!(check(&dict, &WordList::default(), "realise").is_empty());
assert_eq!(check(&dict, "color").len(), 1); assert_eq!(check(&dict, &WordList::default(), "color").len(), 1);
} }
#[test] #[test]
@@ -349,7 +505,7 @@ mod tests {
fn check_flags_misspellings_with_correct_offsets() { fn check_flags_misspellings_with_correct_offsets() {
let dict = en_ca(); let dict = en_ca();
let text = "The quikc brown fox."; let text = "The quikc brown fox.";
let matches = check(&dict, text); let matches = check(&dict, &WordList::default(), text);
assert_eq!(matches.len(), 1, "only 'quikc' is misspelled"); assert_eq!(matches.len(), 1, "only 'quikc' is misspelled");
let m = &matches[0]; let m = &matches[0];
assert_eq!(&text[m.start..m.end], "quikc"); assert_eq!(&text[m.start..m.end], "quikc");
@@ -366,8 +522,8 @@ mod tests {
fn check_respects_canadian_spelling() { fn check_respects_canadian_spelling() {
let dict = en_ca(); let dict = en_ca();
// "colour" is correct in en-CA; "color" is not. // "colour" is correct in en-CA; "color" is not.
assert!(check(&dict, "colour").is_empty()); assert!(check(&dict, &WordList::default(), "colour").is_empty());
assert_eq!(check(&dict, "color").len(), 1); assert_eq!(check(&dict, &WordList::default(), "color").len(), 1);
} }
#[test] #[test]
@@ -376,7 +532,7 @@ mod tests {
// The é is two bytes; a misspelling after it must still map to the right // The é is two bytes; a misspelling after it must still map to the right
// bytes (whether or not "café" itself is in the dictionary). // bytes (whether or not "café" itself is in the dictionary).
let text = "café qmzxk"; let text = "café qmzxk";
let matches = check(&dict, text); let matches = check(&dict, &WordList::default(), text);
let target = matches let target = matches
.iter() .iter()
.find(|m| text.get(m.start..m.end) == Some("qmzxk")); .find(|m| text.get(m.start..m.end) == Some("qmzxk"));
@@ -389,4 +545,156 @@ mod tests {
.collect::<Vec<_>>() .collect::<Vec<_>>()
); );
} }
// ---- The project word list ---------------------------------------------
#[test]
fn a_listed_word_is_accepted_whatever_its_case() {
let list = WordList::from_words(["Bixby"]);
assert!(list.accepts("Bixby"));
assert!(list.accepts("bixby"));
assert!(list.accepts("BIXBY"));
assert!(!list.accepts("Bixbee"));
}
/// The tokenizer keeps the apostrophe, so `Bixby's` arrives as one token; a
/// bare `Bixby` in the list has to cover it or the possessive stays flagged.
#[test]
fn a_listed_name_covers_its_possessive_and_plural() {
let list = WordList::from_words(["Bixby", "Reynolds"]);
assert!(list.accepts("Bixby's"));
assert!(list.accepts("Bixby\u{2019}s"), "curly apostrophe too");
assert!(list.accepts("Bixbys"));
assert!(list.accepts("Bixbys'"));
// A name that already ends in s keeps working in every form.
assert!(list.accepts("Reynolds"));
assert!(list.accepts("Reynolds'"));
assert!(list.accepts("Reynolds's"));
}
#[test]
fn an_empty_list_accepts_nothing() {
let list = WordList::default();
assert!(!list.accepts("Bixby"));
assert!(!list.accepts(""));
assert!(list.is_empty());
}
#[test]
fn adding_is_idempotent_and_case_insensitive() {
let mut list = WordList::default();
assert!(list.insert("Bixby"));
assert!(!list.insert("Bixby"), "the same word twice is not new");
assert!(!list.insert("bixby"), "nor is it new in another case");
assert!(!list.insert(" "), "and blanks are not words");
assert_eq!(list.len(), 1);
}
#[test]
fn removing_takes_a_word_back_out() {
let mut list = WordList::from_words(["Bixby", "Paige"]);
assert!(list.remove("bixby"), "removal is case-insensitive too");
assert!(!list.accepts("Bixby"));
assert!(list.accepts("Paige"));
assert!(!list.remove("Bixby"), "already gone");
assert_eq!(list.len(), 1);
}
#[test]
fn words_come_back_sorted_for_a_readable_diff() {
let list = WordList::from_words(["Paige", "bixby", "Kayla"]);
assert_eq!(list.words(), ["bixby", "Kayla", "Paige"]);
}
#[test]
fn the_word_list_round_trips_through_the_workspace() {
let dir = std::env::temp_dir().join("md_manuscript_wordlist");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
// Nothing on disk yet is an empty list, not an error.
assert!(read_wordlist(&dir).is_empty());
let list = WordList::from_words(["Bixby", "Oyelaran"]);
write_wordlist(&dir, &list).unwrap();
let back = read_wordlist(&dir);
assert_eq!(back.words(), ["Bixby", "Oyelaran"]);
// Stored as a plain JSON array, so a git diff reads well.
let raw = std::fs::read_to_string(dir.join(WORDLIST_FILE)).unwrap();
assert!(raw.trim_start().starts_with('['), "got {raw}");
// A corrupt file must not stop the checker running.
std::fs::write(dir.join(WORDLIST_FILE), "not json").unwrap();
assert!(read_wordlist(&dir).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_checker_accepts_listed_words() {
let dict = Dictionary::new(EN_CA_AFF, EN_CA_DIC).expect("dictionary parses");
let text = "Bixby watched Kayla sleep.";
// Without the list, the invented names are flagged.
let flagged = check(&dict, &WordList::default(), text);
let words: Vec<&str> = flagged.iter().map(|m| &text[m.start..m.end]).collect();
assert!(words.contains(&"Bixby"), "expected Bixby flagged, got {words:?}");
// With them listed, nothing is left.
let list = WordList::from_words(["Bixby", "Kayla"]);
assert!(
check(&dict, &list, text).is_empty(),
"listed names must not be flagged"
);
// A real misspelling still is.
let typo = check(&dict, &list, "Bixby wathced Kayla.");
assert_eq!(typo.len(), 1);
assert_eq!(&"Bixby wathced Kayla."[typo[0].start..typo[0].end], "wathced");
}
// ---- Harvesting names from character sheets ----------------------------
#[test]
fn a_character_folder_is_recognised_with_or_without_its_prefix() {
assert!(is_characters_dir("03-Characters"));
assert!(is_characters_dir("Characters"));
assert!(is_characters_dir("characters"));
assert!(is_characters_dir("3 Character"));
assert!(!is_characters_dir("03-Outline"));
assert!(!is_characters_dir("character_grid"));
}
/// Both heading styles are in use in real sheets.
#[test]
fn names_are_read_from_either_heading_style() {
assert_eq!(
names_from_sheet("# Bixby Reynolds\n\n### The broken truth-seeker\n"),
["Bixby", "Reynolds"]
);
assert_eq!(
names_from_sheet("# Name: Jonathan Bronski\n\n## Slug: The haunted murderer\n"),
["Jonathan", "Bronski"]
);
}
#[test]
fn an_unfilled_template_sheet_yields_no_names() {
let template = "# Name\n\n### Slug\n\nAge:\n\nDescription:\n";
assert!(names_from_sheet(template).is_empty());
assert!(names_from_sheet("# Protagonist\n").is_empty());
assert!(names_from_sheet("no heading at all\n").is_empty());
}
#[test]
fn only_the_top_level_heading_is_read() {
// The sheet's own sections must not be mistaken for names.
let sheet = "# Paige Reynolds\n\n### The Indoctrinated Traveller\n\n## Slug: x\n";
assert_eq!(names_from_sheet(sheet), ["Paige", "Reynolds"]);
}
#[test]
fn lowercase_and_tiny_words_in_a_name_are_skipped() {
// Particles and initials are not worth listing on their own.
assert_eq!(
names_from_sheet("# Bixby de Reynolds Jr\n"),
["Bixby", "Reynolds", "Jr"]
);
}
} }