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:
@@ -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
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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('\\', "/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(¤t) 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
@@ -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
@@ -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, ¤t), 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(¤t) 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 & Dogs <draft>"), "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
@@ -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
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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"]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user