Flatten deleted text variables in table cells and notes

Found while testing crates/engine/src/cmd/variables.rs:49

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-15 05:43:47 +00:00
co-authored by Claude Haiku 5.5
parent a930276fd7
commit 96c62b84ce
+105 -12
View File
@@ -48,21 +48,11 @@ pub fn specs() -> Vec<CommandSpec> {
let n = d.text_variables.len();
let ids: Vec<_> = d.stories.keys().copied().collect();
for sid in ids {
let Some(st) = d.story(sid) else { continue };
if !st.text.chars().any(|c| designcraft_doc::vars::var_index(c).is_some_and(|k| k >= i)) {
if !d.story(sid).is_some_and(|st| needs_unvar(st, i, 0)) {
continue;
}
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let target = var_char(i).unwrap_or(' ').to_string();
while let Some(pos) = st.text.find(&target) {
st.replace(pos..pos + target.len(), &value);
}
for k in i + 1..n {
let (from, to) = (var_char(k).unwrap_or(' ').to_string(), var_char(k - 1).unwrap_or(' ').to_string());
while let Some(pos) = st.text.find(&from) {
st.replace(pos..pos + from.len(), &to);
}
}
unvar(st, i, n, &value, 0);
}
d.text_variables.remove(i);
Ok(Value::Null)
@@ -86,6 +76,63 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Nesting limit for tables in cells and notes (hostile documents).
const MAX_DEPTH: usize = 32;
/// Whether `st` or a table cell or foot/endnote nested in it holds an instance of variable `i`
/// or a later variable.
fn needs_unvar(st: &designcraft_doc::Story, i: usize, depth: usize) -> bool {
if depth > MAX_DEPTH {
return false;
}
st.text.chars().any(|c| designcraft_doc::vars::var_index(c).is_some_and(|k| k >= i))
|| st.tables.values().any(|t| t.cells.iter().any(|c| needs_unvar(&c.text, i, depth + 1)))
|| st.notes.iter().chain(st.endnotes.iter()).any(|n| needs_unvar(&n.text, i, depth + 1))
}
/// Replaces variable `i`'s instances in `st` with `value` and moves the instances of variables
/// `i + 1..n` down by one, including table cells and foot/endnotes nested in the story. Only
/// touches (and unshares) the parts that hold such instances; a story whose nested text changed
/// gets a new revision.
fn unvar(st: &mut designcraft_doc::Story, i: usize, n: usize, value: &str, depth: usize) {
if depth > MAX_DEPTH {
return;
}
if st.text.chars().any(|c| designcraft_doc::vars::var_index(c).is_some_and(|k| k >= i)) {
let target = var_char(i).unwrap_or(' ').to_string();
while let Some(pos) = st.text.find(&target) {
st.replace(pos..pos + target.len(), value);
}
for k in i + 1..n {
let (from, to) = (var_char(k).unwrap_or(' ').to_string(), var_char(k - 1).unwrap_or(' ').to_string());
while let Some(pos) = st.text.find(&from) {
st.replace(pos..pos + from.len(), &to);
}
}
}
let mut nested = false;
for t in st.tables.values_mut() {
if !t.cells.iter().any(|c| needs_unvar(&c.text, i, depth + 1)) {
continue;
}
for c in &mut std::sync::Arc::make_mut(t).cells {
if needs_unvar(&c.text, i, depth + 1) {
unvar(&mut c.text, i, n, value, depth + 1);
}
}
nested = true;
}
for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
if needs_unvar(&note.text, i, depth + 1) {
unvar(&mut std::sync::Arc::make_mut(note).text, i, n, value, depth + 1);
nested = true;
}
}
if nested {
st.rev = st.rev.wrapping_add(1);
}
}
fn define(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("variables.define", "missing name"))?.to_string();
let fmt = || str_param(p, "format").unwrap_or("MM/dd/yy").to_string();
@@ -218,6 +265,52 @@ mod tests {
assert_eq!(shown(3), "Epsilon".len(), "the fourth page (named 2) carries it");
}
/// Deleting a variable flattens its instances in table cells too and moves the instances of
/// later variables down, so cells keep showing the same text.
#[test]
fn delete_variable_reaches_table_cells() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("variables.define", &json!({"name": "A", "type": "custom", "text": "Alpha"})).unwrap();
let b = s.execute("variables.define", &json!({"name": "B", "type": "custom", "text": "Beta"})).unwrap()["index"].as_u64().unwrap() as usize;
let a = s.doc().unwrap().doc.variable("A").unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": ""})).unwrap();
let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap();
s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap();
let text = format!("{}|{}", var_char(a).unwrap(), var_char(b).unwrap());
s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": text})).unwrap();
s.execute("variables.delete", &json!({"name": "A"})).unwrap();
let d = &s.doc().unwrap().doc;
let t = d.story(sid).unwrap().tables.values().next().unwrap();
let nb = d.variable("B").unwrap();
assert_eq!(t.cells[0].text.text, format!("Alpha|{}", var_char(nb).unwrap()));
}
/// Deleting a variable flattens its instances in footnotes and endnotes too and moves the
/// instances of later variables down.
#[test]
fn delete_variable_reaches_notes() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("variables.define", &json!({"name": "A", "type": "custom", "text": "Alpha"})).unwrap();
let b = s.execute("variables.define", &json!({"name": "B", "type": "custom", "text": "Beta"})).unwrap()["index"].as_u64().unwrap() as usize;
let a = s.doc().unwrap().doc.variable("A").unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "x"})).unwrap();
let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
let text = format!("{}|{}", var_char(a).unwrap(), var_char(b).unwrap());
s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 1})).unwrap();
s.execute("footnote.insert", &json!({"text": text})).unwrap();
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap();
s.execute("endnote.insert", &json!({"text": text})).unwrap();
s.execute("variables.delete", &json!({"name": "A"})).unwrap();
let d = &s.doc().unwrap().doc;
let st = d.story(sid).unwrap();
let want = format!("Alpha|{}", var_char(d.variable("B").unwrap()).unwrap());
assert_eq!(st.notes[0].text.text, want);
assert_eq!(st.endnotes[0].text.text, want);
}
#[test]
fn running_header_on_parent_page_follows_headings() {
let mut s = Session::new();