1 Commits

Author SHA1 Message Date
landon d29aa664da Parse a scene's summary as a real list of bullet points
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<String>`, 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 <noreply@anthropic.com>
2026-10-05 20:00:36 -05:00
3 changed files with 187 additions and 7 deletions
+1
View File
@@ -273,6 +273,7 @@ pub fn default_new_file_template() -> String {
"status: draft", "status: draft",
"pov: ", "pov: ",
"characters: []", "characters: []",
"summary: []",
"word_count_target: ", "word_count_target: ",
"---", "---",
"", "",
+185 -7
View File
@@ -33,6 +33,12 @@ pub struct Header {
pub pov: Option<String>, pub pov: Option<String>,
/// Word-count target from a `Word Count Target:` line, if present and parsable. /// Word-count target from a `Word Count Target:` line, if present and parsable.
pub goal: Option<WordGoal>, pub goal: Option<WordGoal>,
/// 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<String>,
/// The prose body, with comments, the header block, and metadata lines removed. /// The prose body, with comments, the header block, and metadata lines removed.
pub body: String, pub body: String,
} }
@@ -207,7 +213,8 @@ fn yaml_fields(frontmatter: &str) -> Vec<Field> {
// Only a key written with nothing after it can be opening a block list, so // 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. // a stray `- ` cannot append itself to a scalar above it.
let mut collecting = false; let mut collecting = false;
for line in frontmatter.lines() { let mut lines = frontmatter.lines();
while let Some(line) = lines.next() {
if line.trim() == "---" { if line.trim() == "---" {
continue; continue;
} }
@@ -223,6 +230,7 @@ fn yaml_fields(frontmatter: &str) -> Vec<Field> {
continue; continue;
} }
if let Some((name, value)) = parse_yaml_field(line) { if let Some((name, value)) = parse_yaml_field(line) {
let value = join_flow_continuation(value, &mut lines);
let value = flow_list_items(&value); let value = flow_list_items(&value);
collecting = value.is_empty(); collecting = value.is_empty();
out.push(Field { name, value }); out.push(Field { name, value });
@@ -231,6 +239,79 @@ fn yaml_fields(frontmatter: &str) -> Vec<Field> {
out 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<Item = &'a str>) -> 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<String> {
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, /// Look one frontmatter key up, trying each alias in turn. Empty reads as absent,
/// the same way [`field`] treats an unfilled `## POV:`. /// the same way [`field`] treats an unfilled `## POV:`.
fn yaml_value(fields: &[Field], names: &[&str]) -> Option<String> { fn yaml_value(fields: &[Field], names: &[&str]) -> Option<String> {
@@ -260,12 +341,7 @@ fn flow_list_items(value: &str) -> String {
let Some(inner) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) else { let Some(inner) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) else {
return value.to_string(); return value.to_string();
}; };
inner split_flow_items(inner).join(", ")
.split(',')
.map(|item| unquote(item.trim()))
.filter(|item| !item.is_empty())
.collect::<Vec<_>>()
.join(", ")
} }
/// Strip one pair of matching surrounding quotes. /// Strip one pair of matching surrounding quotes.
@@ -399,6 +475,7 @@ pub fn parse(markdown: &str, marker: &str) -> Header {
slug: yaml_value(&fields, &["slug"]), slug: yaml_value(&fields, &["slug"]),
pov: yaml_value(&fields, &["pov"]), pov: yaml_value(&fields, &["pov"]),
goal, goal,
summary: summary_items(frontmatter),
body: body.trim().to_string(), body: body.trim().to_string(),
}; };
} }
@@ -457,10 +534,50 @@ pub fn parse(markdown: &str, marker: &str) -> Header {
slug: slug.filter(|s| !s.is_empty()), slug: slug.filter(|s| !s.is_empty()),
pov: pov.filter(|s| !s.is_empty()), pov: pov.filter(|s| !s.is_empty()),
goal, goal,
summary: Vec::new(),
body: body_lines.join("\n").trim().to_string(), 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<String> {
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"`. /// Parse a YAML field line like `key: value` or `key: "value"`.
/// Returns (key, value) if the line looks like a YAML field. /// Returns (key, value) if the line looks like a YAML field.
/// Strips inline comments (everything after # that's not in quotes). /// Strips inline comments (everything after # that's not in quotes).
@@ -708,6 +825,67 @@ mod tests {
assert_eq!(field(empty, "", "characters"), None); 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] #[test]
fn frontmatter_keys_may_carry_underscores() { fn frontmatter_keys_may_carry_underscores() {
// `field_line`'s bare form allows only letters, so snake_case keys were // `field_line`'s bare form allows only letters, so snake_case keys were
@@ -11,6 +11,7 @@ status: draft
pov: "" pov: ""
characters: [] characters: []
setting: "" setting: ""
summary: []
date: "" date: ""
time: "" time: ""
conflict: "" conflict: ""