[JJ1] review.compare says which side each change is on

Each change now carries number (the editor's slide number beside the 0-based
slide) and onlyIn (deck or file; absent for a change in both). Shape details
say (only in the deck) / (only in the file). The docs say review.accept takes
the file's side.

PowerPoint: Compare marks the other file's differences as revisions that
Accept applies and Reject discards, so accepting takes the file's side; kept.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Zach Berry
2026-10-27 18:24:31 -05:00
co-authored by Claude Sonnet 5.5
parent 55e5ee56b4
commit 48535be970
2 changed files with 54 additions and 8 deletions
+11 -3
View File
@@ -58,9 +58,17 @@ pub fn specs() -> Vec<CommandSpec> {
has_text,
insert_synonym
),
cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare with a revised copy → {name, changes}", has_doc, compare),
cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide, revised, details}]}", has_doc, revisions),
cmd!("review.accept", "Accept Revision", ["Review", "Compare"], None, "{index} | {all: true}", comparing, accept),
cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare the open deck with another file → {name, changes: [{kind, slide, number, onlyIn?, revised, details}]}; each change is described from the open deck: `onlyIn: \"deck\"` is in the deck and not in the file, `onlyIn: \"file\"` is in the file and not in the deck, `changed` is in both and differs; `slide` is 0-based, `number` the editor's slide number; review.accept takes the FILE's side, so it deletes what is only in the deck (Accept All keeps the deck's last remaining slide rather than deleting it)", has_doc, compare),
cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide (0-based), number (editor's), onlyIn?: deck|file, revised, details}]}; `onlyIn: deck` = only in the open deck, `file` = only in the compared file; accepting takes the file's side", has_doc, revisions),
cmd!(
"review.accept",
"Accept Revision",
["Review", "Compare"],
None,
"{index} | {all: true} — accept the compared file's side of a change (or all): what is only in the file is added, what is only in the open deck is deleted (all: true leaves the deck's last remaining slide in place), a changed slide becomes the file's",
comparing,
accept
),
cmd!(
noundo "review.reject",
"Reject Revision",
+43 -5
View File
@@ -92,6 +92,12 @@ pub struct Change {
pub kind: ChangeKind,
/// Index in the open deck (for inserted: where it would go).
pub slide: usize,
/// The editor's number of that slide (`slide` + 1).
pub number: usize,
/// Which side has it alone: `deck` (the open presentation; accepting deletes it) or `file`
/// (the compared file; accepting adds it). Absent when both have it and differ.
#[serde(skip_serializing_if = "Option::is_none")]
pub only_in: Option<&'static str>,
/// Index in the revised deck (none for deleted).
pub revised: Option<usize>,
/// What differs, readable: `Title 1: text`, `Picture 3 inserted`, `notes`…
@@ -118,19 +124,33 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<Ch
let (Some(a), Some(b)) = (cur.slides.get(*i), rev.slides.get(*j)) else { continue };
let details = slide_details(cur, a, rev, b);
if !details.is_empty() {
out.push(Change { kind: ChangeKind::Changed, slide: *i, revised: Some(*j), details });
out.push(Change { kind: ChangeKind::Changed, slide: *i, number: i.saturating_add(1), only_in: None, revised: Some(*j), details });
}
}
for (i, s) in cur.slides.iter().enumerate() {
if !cur_matched.contains(&i) {
out.push(Change { kind: ChangeKind::Deleted, slide: i, revised: None, details: vec![slide_title(s)] });
out.push(Change {
kind: ChangeKind::Deleted,
slide: i,
number: i.saturating_add(1),
only_in: Some("deck"),
revised: None,
details: vec![slide_title(s)],
});
}
}
for (j, s) in rev.slides.iter().enumerate() {
if !rev_matched.contains(&j) {
// After the open slide that matches the revised slide before it.
let at = (0..j).rev().find_map(|k| pairs.iter().find(|p| p.1 == k).map(|p| p.0 + 1)).unwrap_or(0);
out.push(Change { kind: ChangeKind::Inserted, slide: at, revised: Some(j), details: vec![slide_title(s)] });
out.push(Change {
kind: ChangeKind::Inserted,
slide: at,
number: at.saturating_add(1),
only_in: Some("file"),
revised: Some(j),
details: vec![slide_title(s)],
});
}
}
out.sort_by_key(|c| (c.slide, c.revised.unwrap_or(0)));
@@ -230,12 +250,12 @@ fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) ->
shape_details(cp, sa, rp, sb, &mut out);
}
}
_ => out.push(format!("{} deleted", shape_label(sa))),
_ => out.push(format!("{} deleted (only in the deck)", shape_label(sa))),
}
}
for (k, sb) in b.shapes.iter().enumerate() {
if !used.contains(&k) {
out.push(format!("{} inserted", shape_label(sb)));
out.push(format!("{} inserted (only in the file)", shape_label(sb)));
}
}
let order = |s: &Slide| s.shapes.iter().map(|x| x.name.clone()).collect::<Vec<_>>();
@@ -494,6 +514,24 @@ mod tests {
assert_eq!(bytes(back.media), b"open background");
}
#[test]
fn each_change_says_which_side_it_is_on() {
let (mut s, rev, _) = decks();
let v = s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap();
for c in v["changes"].as_array().unwrap() {
assert_eq!(c["number"].as_u64().unwrap(), c["slide"].as_u64().unwrap() + 1, "{c}");
match c["kind"].as_str().unwrap() {
"deleted" => assert_eq!(c["onlyIn"], "deck", "{c}"),
"inserted" => assert_eq!(c["onlyIn"], "file", "{c}"),
_ => assert!(c.get("onlyIn").is_none(), "{c}"),
}
}
// A shape only in the open deck reads as only in the deck.
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 40.0, 40.0]})).unwrap();
let v = s.execute("review.revisions", &json!({})).unwrap();
assert!(v.to_string().contains("deleted (only in the deck)"), "{v}");
}
#[test]
fn accept_one_then_all_makes_the_decks_equal_and_undoes() {
let (mut s, rev, n) = decks();