Review: compare with a revised copy and accept its slides

review.compare {path | name, data} reads a revised presentation (native or
PPTX) and lists slide-level revisions against the open one: inserted,
deleted and changed slides, with what changed (text, text formatting,
moved, resized, rotated, formatting, picture, objects inserted/deleted,
layout, notes, transition, animations, background, hidden). Slides match by
id, or by position when the decks share none (fixed for the session so
accepting doesn't switch the matching half way). Shapes match by id, then
name.

review.accept {index | all} applies revisions as one undo step: a revised
slide's media is copied (remapped on an id clash), its shape ids kept unless
another slide uses them (animations follow), its layout matched by id, then
name. review.revisions and review.endCompare complete the set.

Review tab: Compare (file picker), Accept All, End Review; the Revisions pane
lists changes with Go-to and Accept. Parity row: Compare presentations M → P.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-13 22:49:55 +03:00
co-authored by Cursor
parent 457406a7a5
commit 5acf9eeebf
10 changed files with 685 additions and 5 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ fn slides_scope(s: &Session, p: &Value) -> bool {
|| s.active().is_some_and(|d| d.selection.shapes.is_empty() && d.selection.text.is_none() && !d.selection.slides.is_empty())
}
fn media_of_shapes(shapes: &[Shape], out: &mut Vec<MediaId>) {
pub(crate) fn media_of_shapes(shapes: &[Shape], out: &mut Vec<MediaId>) {
deckcraft_model::walk(shapes, &mut |s, _| {
match &s.kind {
deckcraft_model::ShapeKind::Picture { fill } => out.push(fill.media),
+81
View File
@@ -16,6 +16,10 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("comment.resolve", "Resolve Thread", ["Comments"], None, "{index, resolved?: bool}", has_slide, resolve),
cmd!(query "comment.list", "Comments", [], None, "{slide?} → comments", has_slide, list),
cmd!(query "review.accessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → [{slide, shape, issue}]", has_doc, accessibility),
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.endCompare", "End Review", ["Review", "Compare"], None, "{}", comparing, end_compare),
cmd!(query "review.spelling", "Spelling", ["Review", "Proofing"], Some("F7"), "{} → [{slide, shape, word}] (words not in the built-in list)", has_doc, spelling),
cmd!(noundo "show.fromStart", "Play from Start", ["Slide Show", "Start Slide Show"], Some("Cmd+Shift+Enter"), "{}", has_slide, |s, _| start(s, 0)),
cmd!(noundo "show.fromCurrent", "Play from Current Slide", ["Slide Show", "Start Slide Show"], Some("Cmd+Enter"), "{}", has_slide, |s, _| {
@@ -264,3 +268,80 @@ fn custom_show(s: &mut Session, p: &Value) -> Result<Value> {
})?;
ok()
}
fn comparing(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|d| d.compare.is_some()) { Ok(()) } else { Err("not comparing".into()) }
}
fn revised_bytes(p: &Value) -> Result<(String, Vec<u8>)> {
if let Some(data) = str_param(p, "data") {
let bytes = base64_decode(data).ok_or_else(|| bad("review.compare", "invalid base64 `data`"))?;
return Ok((str_param(p, "name").unwrap_or("Revised").to_string(), bytes));
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(path) = str_param(p, "path") {
let bytes = std::fs::read(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
return Ok((path.to_string(), bytes));
}
Err(bad("review.compare", "give `path` or `data`"))
}
fn revisions_json(st: &crate::DocState) -> Result<Value> {
let c = st.compare.as_ref().ok_or_else(|| bad("review.revisions", "not comparing"))?;
let name = std::path::Path::new(&c.name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| c.name.clone());
Ok(json!({"name": name, "changes": crate::compare::diff(&st.doc, &c.revised, c.by_position), "byPosition": c.by_position}))
}
fn compare(s: &mut Session, p: &Value) -> Result<Value> {
let (name, bytes) = revised_bytes(p)?;
let revised = super::file::open_presentation(&name, &bytes)?;
let st = s.doc_mut()?;
let by_position = crate::compare::unrelated(&st.doc, &revised);
st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position });
s.ui_requests.push(UiRequest::Pane { id: "revisions".into() });
revisions_json(s.doc()?)
}
fn revisions(s: &mut Session, _: &Value) -> Result<Value> {
revisions_json(s.doc()?)
}
fn accept(s: &mut Session, p: &Value) -> Result<Value> {
let (rev, by_pos) = s.doc()?.compare.as_ref().map(|c| (c.revised.clone(), c.by_position)).ok_or_else(|| bad("review.accept", "not comparing"))?;
let all = bool_param(p, "all").unwrap_or(false);
let index = usize_param(p, "index");
if !all && index.is_none() {
return Err(bad("review.accept", "give `index` or `all`"));
}
s.edit(|doc, sel| {
let mut accepted = 0usize;
// Each accept shifts indices, so the list is recomputed; bounded by the slide counts.
let limit = doc.slides.len().saturating_add(rev.slides.len()).saturating_add(1);
for _ in 0..limit {
let changes = crate::compare::diff(doc, &rev, by_pos);
let c = if all {
changes.into_iter().find(|c| !(c.kind == crate::compare::ChangeKind::Deleted && doc.slides.len() <= 1))
} else {
changes.into_iter().nth(index.unwrap_or(0))
};
let Some(c) = c else { break };
let Some(at) = crate::compare::accept(doc, &rev, &c) else { break };
accepted += 1;
sel.slide = at;
sel.shapes.clear();
sel.text = None;
if !all {
break;
}
}
if accepted == 0 {
return Err(bad("review.accept", "no such revision"));
}
Ok(json!({"accepted": accepted}))
})
}
fn end_compare(s: &mut Session, _: &Value) -> Result<Value> {
s.doc_mut()?.compare = None;
ok()
}
+449
View File
@@ -0,0 +1,449 @@
//! Review › Compare: the differences between the open presentation and a revised copy of it, slide
//! by slide, and accepting a revised slide into the open one.
//!
//! Slides match by id (a revised copy of the same deck keeps them); when the two decks share no
//! slide id at all, by position. Shapes match by id, then by name.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use deckcraft_model::{Fill, MediaId, Presentation, Shape, ShapeId, ShapeKind, Slide, SlideId};
use serde::Serialize;
/// The revised presentation a document is being compared with.
#[derive(Clone, Debug)]
pub struct Compare {
pub name: String,
pub revised: Arc<Presentation>,
/// Slides match by position (the decks shared no slide id when the compare started); fixed
/// for the session so accepting slides doesn't switch the matching half way.
pub by_position: bool,
}
/// Whether `cur` and `rev` share no slide id (then slides match by position).
pub fn unrelated(cur: &Presentation, rev: &Presentation) -> bool {
let ids: HashSet<SlideId> = rev.slides.iter().map(|s| s.id).collect();
!cur.slides.iter().any(|s| ids.contains(&s.id))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum ChangeKind {
/// Only in the revised deck.
Inserted,
/// Only in the open deck.
Deleted,
/// In both, different.
Changed,
}
/// One slide-level revision.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Change {
pub kind: ChangeKind,
/// Index in the open deck (for inserted: where it would go).
pub slide: usize,
/// Index in the revised deck (none for deleted).
pub revised: Option<usize>,
/// What differs, readable: `Title 1: text`, `Picture 3 inserted`, `notes`…
pub details: Vec<String>,
}
const MAX_DETAILS: usize = 50;
/// Slide pairs (open index, revised index).
fn pairs(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<(usize, usize)> {
if by_position {
return (0..cur.slides.len().min(rev.slides.len())).map(|i| (i, i)).collect();
}
let rev_ids: HashMap<SlideId, usize> = rev.slides.iter().enumerate().map(|(i, s)| (s.id, i)).collect();
cur.slides.iter().enumerate().filter_map(|(i, s)| rev_ids.get(&s.id).map(|j| (i, *j))).collect()
}
pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<Change> {
let pairs = pairs(cur, rev, by_position);
let cur_matched: HashSet<usize> = pairs.iter().map(|p| p.0).collect();
let rev_matched: HashSet<usize> = pairs.iter().map(|p| p.1).collect();
let mut out = vec![];
for (i, j) in &pairs {
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 });
}
}
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)] });
}
}
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.sort_by_key(|c| (c.slide, c.revised.unwrap_or(0)));
out
}
fn slide_title(s: &Slide) -> String {
let t = s
.shapes
.iter()
.find(|x| x.ph.as_ref().is_some_and(|p| matches!(p.kind, deckcraft_model::PhType::Title | deckcraft_model::PhType::CtrTitle)))
.and_then(|x| x.text.as_ref())
.map(|b| b.text())
.unwrap_or_default();
let t = t.trim();
if t.is_empty() { "Untitled slide".into() } else { t.chars().take(60).collect() }
}
fn shape_label(s: &Shape) -> String {
if s.name.trim().is_empty() { format!("Shape {}", s.id.0) } else { s.name.clone() }
}
fn media_bytes(p: &Presentation, id: MediaId) -> Option<&[u8]> {
p.media(id).map(|m| m.data.as_slice())
}
/// A shape without its ids (shape and media), for comparing across decks.
fn plain_shape(s: &Shape) -> Shape {
let mut c = s.clone();
fn rec(shapes: &mut [Shape]) {
for s in shapes {
s.id = ShapeId(0);
if let Some(ch) = s.children_mut() {
rec(ch);
}
}
}
rec(std::slice::from_mut(&mut c));
c
}
fn shape_details(cp: &Presentation, a: &Shape, rp: &Presentation, b: &Shape, out: &mut Vec<String>) {
let label = shape_label(b);
let text = |s: &Shape| s.text.as_ref().map(|t| t.text()).unwrap_or_default();
let mut what = vec![];
if text(a) != text(b) {
what.push("text");
} else if a.text != b.text {
what.push("text formatting");
}
match (&a.xfrm, &b.xfrm) {
(Some(x), Some(y)) if (x.x, x.y) != (y.x, y.y) && (x.w, x.h) == (y.w, y.h) => what.push("moved"),
(Some(x), Some(y)) if (x.w, x.h) != (y.w, y.h) => what.push("resized"),
(x, y) if x.as_ref().map(|x| x.rot) != y.as_ref().map(|y| y.rot) => what.push("rotated"),
_ => {}
}
if a.fill != b.fill || a.line != b.line || a.style != b.style || a.effects != b.effects {
what.push("formatting");
}
let pic = |p: &Presentation, s: &Shape| match &s.kind {
ShapeKind::Picture { fill } => media_bytes(p, fill.media).map(|b| b.to_vec()),
_ => None,
};
if pic(cp, a) != pic(rp, b) {
what.push("picture");
}
if what.is_empty() {
let (mut x, mut y) = (plain_shape(a), plain_shape(b));
x.text = None;
y.text = None;
if let (ShapeKind::Picture { .. }, ShapeKind::Picture { .. }) = (&x.kind, &y.kind) {
x.kind = ShapeKind::Shape;
y.kind = ShapeKind::Shape;
}
if x != y {
what.push("properties");
}
}
if !what.is_empty() {
out.push(format!("{label}: {}", what.join(", ")));
}
}
fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) -> Vec<String> {
let mut out = vec![];
let mut used: HashSet<usize> = HashSet::new();
for sa in &a.shapes {
let k = b
.shapes
.iter()
.position(|x| x.id == sa.id)
.or_else(|| b.shapes.iter().enumerate().position(|(k, x)| !used.contains(&k) && x.name == sa.name && !x.name.is_empty()));
match k {
Some(k) if !used.contains(&k) => {
used.insert(k);
if let Some(sb) = b.shapes.get(k) {
shape_details(cp, sa, rp, sb, &mut out);
}
}
_ => out.push(format!("{} deleted", shape_label(sa))),
}
}
for (k, sb) in b.shapes.iter().enumerate() {
if !used.contains(&k) {
out.push(format!("{} inserted", shape_label(sb)));
}
}
let order = |s: &Slide| s.shapes.iter().map(|x| x.name.clone()).collect::<Vec<_>>();
if out.is_empty() && order(a) != order(b) {
out.push("order of objects".into());
}
let layout_name = |p: &Presentation, s: &Slide| p.layout(s.layout).map(|(_, l)| l.name.clone());
if layout_name(cp, a) != layout_name(rp, b) {
out.push("layout".into());
}
if a.notes.text() != b.notes.text() {
out.push("notes".into());
}
if a.transition != b.transition {
out.push("transition".into());
}
if a.animations.len() != b.animations.len()
|| a.animations
.iter()
.zip(&b.animations)
.any(|(x, y)| (&x.effect, x.class, x.start, x.duration_ms, x.delay_ms) != (&y.effect, y.class, y.start, y.duration_ms, y.delay_ms))
{
out.push("animations".into());
}
if a.background != b.background {
out.push("background".into());
}
if a.hidden != b.hidden {
out.push(if b.hidden { "hidden" } else { "shown" }.into());
}
out.truncate(MAX_DETAILS);
out
}
fn max_shape_id(shapes: &[Shape], m: &mut u32) {
deckcraft_model::walk(shapes, &mut |s, _| *m = (*m).max(s.id.0));
}
/// The revised slide made to live in `doc`: its media copied over (remapped on an id clash), shape
/// ids kept unless another slide of `doc` uses them (then fresh, animations following), and its
/// layout matched by id, then by name, then the first layout.
pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacing: Option<SlideId>) -> Slide {
let mut sl = slide.clone();
// Media.
let mut mids = vec![];
crate::cmd::edit::media_of_shapes(&sl.shapes, &mut mids);
let mut media_map: HashMap<MediaId, MediaId> = HashMap::new();
for id in mids {
let Some(m) = rev.media(id) else { continue };
match doc.media(id) {
Some(x) if x.data == m.data => {}
None => doc.media.push(m.clone()),
Some(_) => {
let new = doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone());
media_map.insert(id, new);
}
}
}
// Shape ids used elsewhere in the open deck.
let mut taken: HashSet<u32> = HashSet::new();
for s in doc.slides.iter().filter(|s| Some(s.id) != replacing) {
deckcraft_model::walk(&s.shapes, &mut |x, _| {
taken.insert(x.id.0);
});
}
let mut id_map: HashMap<ShapeId, ShapeId> = HashMap::new();
fn rec(
doc: &mut Presentation,
shapes: &mut [Shape],
taken: &HashSet<u32>,
ids: &mut HashMap<ShapeId, ShapeId>,
media: &HashMap<MediaId, MediaId>,
depth: usize,
) {
if depth > 64 {
return;
}
for s in shapes {
if taken.contains(&s.id.0) {
let new = ShapeId(doc.alloc_id());
ids.insert(s.id, new);
s.id = new;
}
let remap = |m: &mut MediaId| {
if let Some(n) = media.get(m) {
*m = *n;
}
};
match &mut s.kind {
ShapeKind::Picture { fill } => remap(&mut fill.media),
ShapeKind::Media(m) => {
remap(&mut m.media);
if let Some(p) = m.poster.as_mut() {
remap(p);
}
}
_ => {}
}
if let Some(Fill::Picture(pf)) = &mut s.fill {
remap(&mut pf.media);
}
if let Some(ch) = s.children_mut() {
rec(doc, ch, taken, ids, media, depth + 1);
}
}
}
rec(doc, &mut sl.shapes, &taken, &mut id_map, &media_map, 0);
for a in &mut sl.animations {
if let Some(n) = id_map.get(&a.shape) {
a.shape = *n;
}
if let Some(t) = a.trigger.as_mut()
&& let Some(n) = id_map.get(t)
{
*t = *n;
}
}
let mut max = 0;
max_shape_id(&sl.shapes, &mut max);
doc.next_id = doc.next_id.max(max);
// Layout.
if doc.layout(sl.layout).is_none() {
let name = rev.layout(slide.layout).map(|(_, l)| l.name.clone());
let by_name = name.and_then(|n| doc.masters.iter().flat_map(|m| &m.layouts).find(|l| l.name == n).map(|l| l.id));
sl.layout = by_name.or_else(|| doc.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id)).unwrap_or_default();
}
match replacing {
Some(id) => sl.id = id,
None => {
if doc.slides.iter().any(|s| s.id == sl.id) || sl.id.0 == 0 {
sl.id = SlideId(doc.alloc_id());
}
doc.next_id = doc.next_id.max(sl.id.0);
}
}
sl
}
/// Apply `change` (from [`diff`] of `doc` against `rev`) to `doc`. Returns the slide index to show.
pub fn accept(doc: &mut Presentation, rev: &Presentation, change: &Change) -> Option<usize> {
match change.kind {
ChangeKind::Changed => {
let b = rev.slides.get(change.revised?)?.clone();
let id = doc.slides.get(change.slide)?.id;
let sl = adopt(doc, rev, &b, Some(id));
*doc.slides.get_mut(change.slide)? = Arc::new(sl);
Some(change.slide)
}
ChangeKind::Inserted => {
let b = rev.slides.get(change.revised?)?.clone();
let sl = adopt(doc, rev, &b, None);
let at = change.slide.min(doc.slides.len());
doc.slides.insert(at, Arc::new(sl));
Some(at)
}
ChangeKind::Deleted => {
if change.slide >= doc.slides.len() || doc.slides.len() <= 1 {
return None;
}
doc.slides.remove(change.slide);
Some(change.slide.min(doc.slides.len().saturating_sub(1)))
}
}
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use crate::Session;
/// The sample deck, and a revised copy of it as base64 `.deckcraft` bytes: slide 1's title
/// retyped, slide 2 deleted, a new slide at the end, a shape moved on slide 3.
fn decks() -> (Session, String, usize) {
let mut s = Session::new();
crate::sample::open_sample(&mut s).unwrap();
let n = s.doc().unwrap().doc.slides.len();
let orig = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
// Make the revision in a second document.
s.execute("file.openBytes", &json!({"name": "rev.deckcraft", "data": orig})).unwrap();
let first = s.doc().unwrap().doc.slides[0].shapes.iter().find(|x| x.text.is_some()).unwrap().id.0;
s.execute("edit.select", &json!({"ids": [first]})).unwrap();
s.execute("text.edit", &json!({"id": first})).unwrap();
s.execute("edit.selectAll", &json!({})).unwrap();
s.execute("text.insert", &json!({"text": "Revised title"})).unwrap();
s.execute("text.exit", &json!({})).unwrap();
s.execute("slide.delete", &json!({"index": 1})).unwrap();
s.execute("slide.go", &json!({"index": 1})).ok();
let moved = s.doc().unwrap().doc.slides[1].shapes[0].id.0;
s.execute("shape.move", &json!({"ids": [moved], "dx": 30, "dy": 0})).ok();
s.execute("slide.go", &json!({"index": n - 2})).ok();
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Brand new", "body": "x"})).unwrap();
let rev = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
s.close_document(1);
s.set_active(0);
(s, rev, n)
}
fn kinds(v: &Value) -> Vec<String> {
v["changes"].as_array().unwrap().iter().map(|c| c["kind"].as_str().unwrap().to_string()).collect()
}
#[test]
fn lists_inserted_deleted_and_changed_slides() {
let (mut s, rev, n) = decks();
let v = s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap();
let k = kinds(&v);
assert!(k.contains(&"deleted".into()) && k.contains(&"inserted".into()) && k.contains(&"changed".into()), "{v}");
let ch = &v["changes"].as_array().unwrap()[0];
assert_eq!(ch["slide"], 0);
assert!(ch["details"].to_string().contains("text"), "{ch}");
let ins = v["changes"].as_array().unwrap().iter().find(|c| c["kind"] == "inserted").unwrap();
assert_eq!(ins["details"][0], "Brand new");
let at = ins["slide"].as_u64().unwrap() as usize;
assert_eq!(at, n, "after the open slide matching the revised one before it");
assert_eq!(s.doc().unwrap().doc.slides.len(), n, "comparing alone changes nothing");
}
#[test]
fn accept_one_then_all_makes_the_decks_equal_and_undoes() {
let (mut s, rev, n) = decks();
s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap();
let before = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len();
s.execute("review.accept", &json!({"index": 0})).unwrap();
let after = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len();
assert_eq!(after, before - 1);
assert!(s.doc().unwrap().doc.slides[0].shapes.iter().any(|x| x.text.as_ref().is_some_and(|t| t.text().contains("Revised title"))));
s.execute("review.accept", &json!({"all": true})).unwrap();
let left = s.execute("review.revisions", &json!({})).unwrap();
assert!(left["changes"].as_array().unwrap().is_empty(), "{left}");
assert!(s.doc().unwrap().doc.validate().is_empty(), "{:?}", s.doc().unwrap().doc.validate());
assert_eq!(s.doc().unwrap().doc.slides.len(), n);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().len(), before);
s.execute("review.endCompare", &json!({})).unwrap();
assert!(s.execute("review.revisions", &json!({})).is_err());
assert!(s.execute("review.accept", &json!({"all": true})).is_err());
}
#[test]
fn unrelated_decks_match_by_position_and_bad_input_fails() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "title", "title": "A"})).unwrap();
let mut other = Session::with_new();
other.execute("slide.new", &json!({"layout": "title", "title": "B"})).unwrap();
other.execute("slide.new", &json!({"layout": "title", "title": "C"})).unwrap();
let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string();
let v = s.execute("review.compare", &json!({"name": "o.pptx", "data": data})).unwrap();
assert!(kinds(&v).contains(&"inserted".into()), "{v}");
s.execute("review.accept", &json!({"all": true})).unwrap();
assert!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().is_empty());
assert!(s.doc().unwrap().doc.validate().is_empty());
assert!(s.execute("review.compare", &json!({"data": "!!"})).is_err());
assert!(s.execute("review.compare", &json!({"name": "x.bin", "data": "AAAA"})).is_err());
assert!(s.execute("review.compare", &json!({})).is_err());
assert!(s.execute("review.accept", &json!({"index": 999})).is_err());
}
}
+4
View File
@@ -8,6 +8,7 @@
#![forbid(unsafe_code)]
pub mod cmd;
pub mod compare;
pub mod connect;
pub mod fonts;
pub mod guard;
@@ -173,6 +174,8 @@ pub struct DocState {
pub missing_fonts: Vec<String>,
/// An input method's composition in progress ([`Composition`]).
pub composition: Option<Composition>,
/// Review › Compare in progress: the revised copy.
pub compare: Option<compare::Compare>,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -192,6 +195,7 @@ impl DocState {
title,
missing_fonts: Vec::new(),
composition: None,
compare: None,
}
}
pub fn is_dirty(&self) -> bool {