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:
@@ -88,6 +88,132 @@ pub fn strip_comments(s: &str) -> String {
|
||||
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
|
||||
/// `## Word Count Target:` — return which one and its value. Matches one or more
|
||||
/// leading `#` followed by whitespace, case-insensitively, so the field can sit
|
||||
@@ -284,3 +410,166 @@ mod tests {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user