From d29aa664da7d1e652ead5fed101ad66da6731a30 Mon Sep 17 00:00:00 2001 From: landon Date: Mon, 5 Oct 2026 20:00:36 -0500 Subject: [PATCH] Parse a scene's summary as a real list of bullet points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A frontmatter `summary:` list written as a multi-line flow sequence (one bullet per line, closing bracket on its own line — the natural way to hand-write several sentences) used to vanish: no line in it has a colon or a leading `-`, so the parser read none of the lines after the opening `summary: [` and the field ended up holding a literal "[". Even written on one line, `flow_list_items` split on every comma, which cuts a prose bullet in two if it has one of its own. `Header` now has a `summary: Vec`, read by a parser that joins a flow sequence's continuation lines before splitting it on commas outside of quotes, so each bullet survives whole. Also added `summary: []` to the bundled scene template and the in-app default new-file template, so new scenes offer the field. No new UI surfaces this yet (corkboard/synopsis still show only the slug) — out of scope for the parsing fix itself. Co-Authored-By: Claude Sonnet 5 --- src/config.rs | 1 + src/preprocess.rs | 192 +++++++++++++++++- .../05-First Draft/_template.md | 1 + 3 files changed, 187 insertions(+), 7 deletions(-) diff --git a/src/config.rs b/src/config.rs index 1cc4b36..ffd359f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -273,6 +273,7 @@ pub fn default_new_file_template() -> String { "status: draft", "pov: ", "characters: []", + "summary: []", "word_count_target: ", "---", "", diff --git a/src/preprocess.rs b/src/preprocess.rs index 2e870f3..25841f1 100644 --- a/src/preprocess.rs +++ b/src/preprocess.rs @@ -33,6 +33,12 @@ pub struct Header { pub pov: Option, /// Word-count target from a `Word Count Target:` line, if present and parsable. pub goal: Option, + /// Bullet points from a frontmatter `summary` list, in writing order. Each + /// item keeps its own punctuation intact — unlike a generic [`field`]'s + /// comma-joined value, nothing here is lost if a bullet has a comma of its + /// own. Empty when there's no frontmatter or no `summary` key (YAML-only; + /// the legacy marker-based header has no list syntax to borrow). + pub summary: Vec, /// The prose body, with comments, the header block, and metadata lines removed. pub body: String, } @@ -207,7 +213,8 @@ fn yaml_fields(frontmatter: &str) -> Vec { // Only a key written with nothing after it can be opening a block list, so // a stray `- ` cannot append itself to a scalar above it. let mut collecting = false; - for line in frontmatter.lines() { + let mut lines = frontmatter.lines(); + while let Some(line) = lines.next() { if line.trim() == "---" { continue; } @@ -223,6 +230,7 @@ fn yaml_fields(frontmatter: &str) -> Vec { continue; } if let Some((name, value)) = parse_yaml_field(line) { + let value = join_flow_continuation(value, &mut lines); let value = flow_list_items(&value); collecting = value.is_empty(); out.push(Field { name, value }); @@ -231,6 +239,79 @@ fn yaml_fields(frontmatter: &str) -> Vec { out } +/// Net count of un-quoted `[`/`]` in `s`, positive while a flow sequence begun +/// on an earlier line is still open. +fn bracket_depth(s: &str) -> i32 { + let mut depth = 0; + let mut in_quote = false; + let mut quote_char = '\0'; + for c in s.chars() { + match c { + '"' | '\'' if !in_quote => { + in_quote = true; + quote_char = c; + } + c2 if in_quote && c2 == quote_char => in_quote = false, + '[' if !in_quote => depth += 1, + ']' if !in_quote => depth -= 1, + _ => {} + } + } + depth +} + +/// Pull in whatever further lines a flow sequence needs to balance its +/// brackets, so `key: [\n "a",\n "b",\n]` — how a multi-line list is often +/// hand-written, one bullet per line — reads the same as `key: [a, b]` on one +/// line. A value that was already balanced (or never opened a list) passes +/// through untouched, and an unterminated one is returned as far as it got. +fn join_flow_continuation<'a>(mut value: String, lines: &mut impl Iterator) -> String { + while bracket_depth(&value) > 0 { + match lines.next() { + Some(cont) => { + value.push(' '); + value.push_str(cont.trim()); + } + None => break, + } + } + value +} + +/// Split a flow sequence's inner text on commas that are not inside a quoted +/// item, so `"a, b"` keeps its own comma rather than being cut in two by it — +/// unlike a bare name, prose can't be trusted to never contain one. A trailing +/// comma (the only way to end a list spread one item per line, and a habit +/// many writers bring from other languages) leaves no empty item behind. +fn split_flow_items(inner: &str) -> Vec { + let mut items = Vec::new(); + let mut current = String::new(); + let mut in_quote = false; + let mut quote_char = '\0'; + for c in inner.chars() { + match c { + '"' | '\'' if !in_quote => { + in_quote = true; + quote_char = c; + current.push(c); + } + c2 if in_quote && c2 == quote_char => { + in_quote = false; + current.push(c2); + } + ',' if !in_quote => { + items.push(current.trim().to_string()); + current.clear(); + } + _ => current.push(c), + } + } + if !current.trim().is_empty() { + items.push(current.trim().to_string()); + } + items.into_iter().map(|s| unquote(&s)).filter(|s| !s.is_empty()).collect() +} + /// Look one frontmatter key up, trying each alias in turn. Empty reads as absent, /// the same way [`field`] treats an unfilled `## POV:`. fn yaml_value(fields: &[Field], names: &[&str]) -> Option { @@ -260,12 +341,7 @@ fn flow_list_items(value: &str) -> String { let Some(inner) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) else { return value.to_string(); }; - inner - .split(',') - .map(|item| unquote(item.trim())) - .filter(|item| !item.is_empty()) - .collect::>() - .join(", ") + split_flow_items(inner).join(", ") } /// Strip one pair of matching surrounding quotes. @@ -399,6 +475,7 @@ pub fn parse(markdown: &str, marker: &str) -> Header { slug: yaml_value(&fields, &["slug"]), pov: yaml_value(&fields, &["pov"]), goal, + summary: summary_items(frontmatter), body: body.trim().to_string(), }; } @@ -457,10 +534,50 @@ pub fn parse(markdown: &str, marker: &str) -> Header { slug: slug.filter(|s| !s.is_empty()), pov: pov.filter(|s| !s.is_empty()), goal, + summary: Vec::new(), body: body_lines.join("\n").trim().to_string(), } } +/// Every bullet of a frontmatter `summary` list, in writing order — see +/// [`Header::summary`]. Supports both YAML list forms, including a flow +/// sequence spread over several lines (`summary: [\n "...",\n]`). +fn summary_items(frontmatter: &str) -> Vec { + let mut lines = frontmatter.lines().peekable(); + while let Some(line) = lines.next() { + if line.trim() == "---" { + continue; + } + if block_list_item(line).is_some() { + continue; // belongs to some other key's block list; keep scanning + } + let Some((key, value)) = parse_yaml_field(line) else { + continue; + }; + if !key.eq_ignore_ascii_case("summary") { + continue; + } + if value.is_empty() { + // A bare `summary:` opens a block list on the following lines. + let mut items = Vec::new(); + while let Some(next) = lines.peek().copied() { + let Some(item) = block_list_item(next) else { + break; + }; + items.push(item); + lines.next(); + } + return items; + } + let value = join_flow_continuation(value, &mut lines); + let Some(inner) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) else { + return Vec::new(); // a scalar, not a list + }; + return split_flow_items(inner); + } + Vec::new() +} + /// Parse a YAML field line like `key: value` or `key: "value"`. /// Returns (key, value) if the line looks like a YAML field. /// Strips inline comments (everything after # that's not in quotes). @@ -708,6 +825,67 @@ mod tests { assert_eq!(field(empty, "", "characters"), None); } + #[test] + fn a_flow_list_item_keeps_its_own_comma() { + // `flow_list_items`' old naive `split(',')` cut "a, b" into two items; + // prose can't promise to never contain a comma the way a name can. + let md = r#"--- +summary: ["First, then second.", "Third."] +--- + +Prose. +"#; + assert_eq!(parse(md, "").summary, ["First, then second.", "Third."]); + } + + #[test] + fn a_summary_list_spread_over_several_lines_is_read_whole() { + // The shape a hand-written multi-line flow sequence actually takes: + // the opening `[` ends its line, one bullet per line after that, the + // closing `]` alone on the last. Each line used to vanish (no colon, + // so `parse_yaml_field` ignored it, and it doesn't start with `-` so + // `block_list_item` ignored it too) and `summary` read as a literal "[". + let md = "---\n\ + title: \"A Title\"\n\ + summary: [\n\ + \t\"Jonah is waiting for the bus.\",\n\ + \t\"We see the loneliness of his existence.\",\n\ + \t\"Jonah goes to the lab and does a favour for a co-worker\",\n\ + ]\n\ + pov: \"Jonah\"\n\ + ---\n\n\ + Prose.\n"; + let h = parse(md, ""); + assert_eq!( + h.summary, + [ + "Jonah is waiting for the bus.", + "We see the loneliness of his existence.", + "Jonah goes to the lab and does a favour for a co-worker", + ] + ); + // A key after the multi-line list is still read correctly. + assert_eq!(h.pov.as_deref(), Some("Jonah")); + assert_eq!(h.title.as_deref(), Some("A Title")); + } + + #[test] + fn a_block_style_summary_list_is_read_too() { + let md = "---\nsummary:\n - First point.\n - \"Second, with a comma.\"\nsetting: kitchen\n---\n\nProse.\n"; + let h = parse(md, ""); + assert_eq!(h.summary, ["First point.", "Second, with a comma."]); + } + + #[test] + fn summary_is_empty_when_absent_or_a_bare_empty_list() { + assert!(parse("---\ntitle: x\n---\n\nProse.\n", "").summary.is_empty()); + assert!(parse("---\nsummary: []\n---\n\nProse.\n", "").summary.is_empty()); + // Legacy marker-based header: no list syntax exists there. + assert!(parse("# Title: x\n### Rough Draft:\n\nProse.", "### Rough Draft:") + .summary + .is_empty()); + } + #[test] fn frontmatter_keys_may_carry_underscores() { // `field_line`'s bare form allows only letters, so snake_case keys were diff --git a/templates/snowflake/{{ cookiecutter.project_name }}/05-First Draft/_template.md b/templates/snowflake/{{ cookiecutter.project_name }}/05-First Draft/_template.md index 02e7abc..ab94003 100644 --- a/templates/snowflake/{{ cookiecutter.project_name }}/05-First Draft/_template.md +++ b/templates/snowflake/{{ cookiecutter.project_name }}/05-First Draft/_template.md @@ -11,6 +11,7 @@ status: draft pov: "" characters: [] setting: "" +summary: [] date: "" time: "" conflict: ""