1//! Page versions against OneNote 2010's own (`corpus/page-versions/native`): listing them,
2//! reading them, and Restore Version and Delete Version storing what OneNote stores.
3
4use onestore::{
5 Arena, ExGuid, ObjectData, PageVersion, PropertySets, RevisionIndex, Section, Store,
6 document::Document,
7 op::{Edit, Op, PageOp, SectionOp},
8 page::{Page, PageObject},
9};
10use std::collections::BTreeSet;
11
12const AT: u64 = 134_040_000_000_000_000;
13const NATIVE: &str = "corpus/page-versions/native";
14/// The version history's context.
15const HISTORY: &str = "{7111497F-1B6B-4209-9491-C98B04CF4C5A},1";
16
17fn read(path: &str) -> Vec<u8> {
18 std::fs::read(format!("{}/../../{path}", env!("CARGO_MANIFEST_DIR"))).unwrap()
19}
20
21fn step(n: u32) -> Vec<u8> {
22 read(&format!("{NATIVE}/step-{n:02}/notebook/History.one"))
23}
24
25fn id(text: &str) -> ExGuid {
26 text.parse().unwrap()
27}
28
29/// The page every step holds, and the version OneNote made when a second author edited it.
30fn page() -> ExGuid {
31 id("{55479BA2-FB3A-45EB-A43D-EDB93DA0593D},1")
32}
33
34fn texts(page: &Page) -> Vec<String> {
35 page.objects
36 .iter()
37 .flat_map(|object| match object {
38 PageObject::Outline(outline) => outline.paragraphs.clone(),
39 _ => Vec::new(),
40 })
41 .filter_map(|paragraph| paragraph.text().map(|text| text.text.text().to_owned()))
42 .collect()
43}
44
45fn versions(image: &[u8]) -> Vec<PageVersion> {
46 let arena = Arena::default();
47 let mut section = Section::open(&arena, image.to_vec()).unwrap();
48 section
49 .versions()
50 .unwrap()
51 .into_iter()
52 .find(|(listed, _)| *listed == page())
53 .map_or(Vec::new(), |(_, versions)| versions)
54}
55
56/// What a revision of `space` changed: its declared objects' types, sorted, with whether
57/// each was new.
58fn changed(index: &RevisionIndex<'_>, space: ExGuid, rid: ExGuid) -> Vec<(u32, bool)> {
59 let revision = index.resolve(space, rid).unwrap();
60 let base = index.spaces[&space].revisions[&rid]
61 .dependency
62 .map(|dependency| index.resolve(space, dependency).unwrap());
63 let mut changed: Vec<(u32, bool)> = revision
64 .objects
65 .iter()
66 .filter_map(|(id, object)| {
67 let old = base.as_ref().and_then(|base| base.objects.get(id));
68 let same = old.is_some_and(|old| {
69 old.reference_count == object.reference_count && old.data == object.data
70 });
71 (!same).then_some((object.jcid, old.is_none()))
72 })
73 .collect();
74 changed.sort();
75 changed
76}
77
78/// Property identities of an object's root set.
79fn properties(object: &onestore::Object<'_>) -> Vec<u32> {
80 let ObjectData::Properties(bytes) = object.data else {
81 panic!()
82 };
83 PropertySets::parse(bytes).unwrap().sets[0]
84 .iter()
85 .map(|property| property.id)
86 .collect()
87}
88
89/// How an edit left the page's revisions, from `before` to `after`: the page revision's
90/// dependency (named by what it was before), the history's, which revisions became versions,
91/// the declared object types of the page and history revisions, the history's children
92/// count and the proxy's properties, and whether the page metadata and the section's copy
93/// say the page has versions.
94#[derive(Debug, PartialEq)]
95struct Shape {
96 page_depends_on: &'static str,
97 history_depends_on_previous: bool,
98 labelled: Vec<&'static str>,
99 page_changed: Vec<(u32, bool)>,
100 history_changed: Vec<(u32, bool)>,
101 listed: usize,
102 proxy: Vec<u32>,
103 flagged: [bool; 2],
104}
105
106fn shape(before: &[u8], after: &[u8]) -> Shape {
107 let (old_store, new_store) = (Store::parse(before).unwrap(), Store::parse(after).unwrap());
108 let (old, new) = (
109 RevisionIndex::parse(&old_store).unwrap(),
110 RevisionIndex::parse(&new_store).unwrap(),
111 );
112 new.validate_current().unwrap();
113 Document::parse(&new).unwrap();
114 let space = page();
115 let history = id(HISTORY);
116 let (old_labels, new_labels) = (&old.spaces[&space].labels, &new.spaces[&space].labels);
117 let old_page = old_labels[&(ExGuid::default(), 1)];
118 let new_page = new_labels[&(ExGuid::default(), 1)];
119 let name = |rid: ExGuid| -> &'static str {
120 if rid == old_page {
121 "the page"
122 } else if old_labels.get(&(history, 1)) == Some(&rid) {
123 "the history"
124 } else if old_labels.iter().any(|((context, role), labelled)| {
125 *role == 1 && *context != ExGuid::default() && *labelled == rid
126 }) {
127 "a version"
128 } else {
129 "another revision"
130 }
131 };
132 let page_depends_on = if new_page == old_page {
133 "unchanged"
134 } else {
135 new.spaces[&space].revisions[&new_page]
136 .dependency
137 .map_or("none", name)
138 };
139 let (old_history, new_history) = (old_labels[&(history, 1)], new_labels[&(history, 1)]);
140 let labelled = new_labels
141 .iter()
142 .filter(|((context, role), _)| {
143 *role == 1
144 && *context != ExGuid::default()
145 && *context != history
146 && !old_labels.contains_key(&(*context, 1))
147 })
148 .map(|(_, rid)| name(*rid))
149 .collect();
150 let history_revision = new.resolve(space, new_history).unwrap();
151 let root = &history_revision.objects[&history_revision.roots[&1]];
152 let listed = root.references().unwrap().objects;
153 let proxy = listed.first().map_or(Vec::new(), |proxy| {
154 properties(&history_revision.objects[proxy])
155 });
156 let metadata = |index: &RevisionIndex<'_>| -> [bool; 2] {
157 let revision = index.resolve_active(space).unwrap();
158 let own = properties(&revision.objects[&revision.roots[&2]]).contains(&0x88003462);
159 // The section's copy, named by the page's space (MS-ONE 2.2.81).
160 let section = index.resolve_active(index.root).unwrap();
161 let copy = properties(&section.objects[&id("{77EF5B93-CD3A-0705-1329-3A15E7846CD5},1")])
162 .contains(&0x88003462);
163 [own, copy]
164 };
165 Shape {
166 page_depends_on,
167 history_depends_on_previous: new_history != old_history
168 && new.spaces[&space].revisions[&new_history].dependency == Some(old_history),
169 labelled,
170 page_changed: if new_page == old_page {
171 Vec::new()
172 } else {
173 changed(&new, space, new_page)
174 },
175 history_changed: if new_history == old_history {
176 Vec::new()
177 } else {
178 changed(&new, space, new_history)
179 },
180 listed: listed.len(),
181 proxy,
182 flagged: metadata(&new),
183 }
184}
185
186fn apply(image: &[u8], author: &str, ops: Vec<Op>) -> Vec<u8> {
187 let arena = Arena::default();
188 let mut section = Section::open(&arena, image.to_vec()).unwrap();
189 section.apply(author, &Edit { at: AT, ops }).unwrap();
190 section.seal().unwrap().unwrap();
191 section.image()
192}
193
194#[test]
195fn native_versions_are_listed_newest_first_with_their_authors() {
196 assert_eq!(versions(&step(1)), []);
197 let virtual_version = PageVersion {
198 context: id("{2F6B7C6D-DF95-0F56-085E-74357323A760},1"),
199 modified: Some(0x01dd4ee2641a3f00),
200 author: Some("virtual".into()),
201 };
202 assert_eq!(versions(&step(2)), std::slice::from_ref(&virtual_version));
203 // A Restore Version made the page as it stood the newest version.
204 let restored = versions(&step(4));
205 assert_eq!(
206 restored,
207 [
208 PageVersion {
209 context: id("{783B5BD2-65DB-082D-3AAC-47135BA38EA9},1"),
210 modified: Some(0x01dd4ee27e541d00),
211 author: Some("Other Person".into()),
212 },
213 virtual_version.clone(),
214 ]
215 );
216 assert_eq!(versions(&step(5)), [virtual_version]);
217 assert_eq!(versions(&step(6)), []);
218 // Optimizing keeps a version and the history as checkpoints.
219 assert_eq!(versions(&step(10)).len(), 1);
220}
221
222#[test]
223fn a_version_reads_as_the_page_it_was() {
224 let arena = Arena::default();
225 let section = Section::open(&arena, step(1)).unwrap();
226 let before = section.page(page()).unwrap();
227 let arena = Arena::default();
228 let mut section = Section::open(&arena, step(2)).unwrap();
229 let context = section.versions().unwrap()[0].1[0].context;
230 let version = section.version(page(), context).unwrap();
231 assert_eq!(version, before);
232 assert_eq!(texts(&version), ["First state. Typed by virtual."]);
233 assert_eq!(
234 texts(&section.page(page()).unwrap()),
235 ["First state. Typed by virtual.", "Second author line."]
236 );
237 assert!(section.version(page(), id(HISTORY)).is_err());
238}
239
240/// Restore Version stores what OneNote 2010's does (`step-03` to `step-04`): the page's next
241/// revision builds on the version's, the page as it stood becomes the newest version under
242/// the context its revision derives, the history lists it first, and the revision metadata
243/// names who restored it. `ONESTORE_VERSIONS_EXPORT` names a directory for the notebooks,
244/// for a cold read.
245#[test]
246fn restoring_a_version_stores_what_onenote_stores() {
247 let before = step(3);
248 let restored_version = versions(&before)[0].context;
249 let arena = Arena::default();
250 let section = Section::open(&arena, before.clone()).unwrap();
251 let standing = section.page(page()).unwrap();
252 let version = section.version(page(), restored_version).unwrap();
253 let ours = apply(
254 &before,
255 "Other Person",
256 vec![Op::Section(SectionOp::RestoreVersion {
257 page: page(),
258 version: restored_version,
259 guid: [7; 16],
260 })],
261 );
262 let expected = shape(&before, &step(4));
263 assert_eq!(expected.page_depends_on, "a version");
264 assert_eq!(shape(&before, &ours), expected);
265
266 let arena = Arena::default();
267 let mut reopened = Section::open(&arena, ours.clone()).unwrap();
268 assert_eq!(reopened.page(page()).unwrap().objects, version.objects);
269 let listed = reopened.versions().unwrap().remove(0).1;
270 assert_eq!(listed.len(), 2);
271 assert_eq!(listed[1].context, restored_version);
272 // As OneNote names it (`step-04`): the page's revision, its bits flipped by a salt.
273 let store = Store::parse(&before).unwrap();
274 let standing_rid = RevisionIndex::parse(&store)
275 .unwrap()
276 .active(page())
277 .unwrap();
278 let salt = |a: ExGuid, b: ExGuid| -> Vec<u8> {
279 a.guid.iter().zip(b.guid).map(|(a, b)| a ^ b).collect()
280 };
281 assert_eq!(
282 salt(listed[0].context, standing_rid),
283 salt(
284 id("{783B5BD2-65DB-082D-3AAC-47135BA38EA9},1"),
285 id("{E569A5AE-EA67-4CB8-A9AC-55880C342743},1")
286 )
287 );
288 assert_eq!(listed[0].author.as_deref(), Some("Other Person"));
289 assert_eq!(
290 reopened.version(page(), listed[0].context).unwrap(),
291 standing
292 );
293
294 if let Some(output) = std::env::var_os("ONESTORE_VERSIONS_EXPORT") {
295 export(&output, "restore", &ours);
296 }
297}
298
299/// Delete Version stores what OneNote 2010's does: the history stops listing the version
300/// (`step-04` to `step-05`), and once none is left the page's metadata and the section's copy
301/// stop saying it has versions (`step-05` to `step-06`).
302#[test]
303fn deleting_versions_stores_what_onenote_stores() {
304 let before = step(4);
305 let newest = versions(&before)[0].context;
306 let delete = |image: &[u8], versions: Vec<ExGuid>| {
307 apply(
308 image,
309 "Other Person",
310 vec![Op::Section(SectionOp::DeleteVersions {
311 page: page(),
312 versions,
313 })],
314 )
315 };
316 let ours = delete(&before, vec![newest]);
317 assert_eq!(shape(&before, &ours), shape(&before, &step(5)));
318 let emptied = delete(&step(5), vec![versions(&step(5))[0].context]);
319 let expected = shape(&step(5), &step(6));
320 assert_eq!(expected.flagged, [false, false]);
321 assert_eq!(shape(&step(5), &emptied), expected);
322 // Every version at once, as Delete All Versions in Section asks.
323 let all = versions(&before)
324 .into_iter()
325 .map(|version| version.context)
326 .collect();
327 assert_eq!(versions(&delete(&before, all)), []);
328 // A version already gone refuses the edit.
329 let arena = Arena::default();
330 let mut section = Section::open(&arena, step(5)).unwrap();
331 let refused = section.apply(
332 "Other Person",
333 &Edit {
334 at: AT,
335 ops: vec![Op::Section(SectionOp::DeleteVersions {
336 page: page(),
337 versions: vec![newest],
338 })],
339 },
340 );
341 assert_eq!(
342 refused,
343 Err(onestore::op::OpError::TargetUnavailable(newest))
344 );
345
346 if let Some(output) = std::env::var_os("ONESTORE_VERSIONS_EXPORT") {
347 export(&output, "delete", &ours);
348 export(&output, "delete-all", &emptied);
349 }
350}
351
352/// Edits not yet sealed become a version of their own when a restore follows them in the
353/// same batch, and restores stack: every state the page passed through stays listed.
354#[test]
355fn unsaved_edits_and_stacked_restores_become_versions() {
356 let before = step(2);
357 let arena = Arena::default();
358 let mut section = Section::open(&arena, before.clone()).unwrap();
359 let first = section.versions().unwrap()[0].1[0].context;
360 let text = section
361 .page(page())
362 .unwrap()
363 .objects
364 .iter()
365 .find_map(|object| match object {
366 PageObject::Outline(outline) => outline.paragraphs.last()?.text().map(|text| text.id),
367 _ => None,
368 });
369 let edit = |ops| Edit { at: AT, ops };
370 section
371 .apply(
372 "Clover Snow",
373 &edit(vec![Op::Page {
374 space: page(),
375 op: PageOp::Text {
376 text: text.unwrap(),
377 range: 0..6,
378 with: "Unsaved".into(),
379 },
380 }]),
381 )
382 .unwrap();
383 let edited = section.page(page()).unwrap();
384 let restore = |version, guid| {
385 edit(vec![Op::Section(SectionOp::RestoreVersion {
386 page: page(),
387 version,
388 guid,
389 })])
390 };
391 section
392 .apply("Clover Snow", &restore(first, [1; 16]))
393 .unwrap();
394 let listed = section.versions().unwrap()[0].1.clone();
395 assert_eq!(listed.len(), 2);
396 assert_eq!(section.version(page(), listed[0].context).unwrap(), edited);
397 // The unsaved state is still being saved, so it cannot be restored yet; the stored one can.
398 assert!(
399 section
400 .apply("Clover Snow", &restore(listed[0].context, [2; 16]))
401 .is_err()
402 );
403 section
404 .apply("Clover Snow", &restore(first, [3; 16]))
405 .unwrap();
406 assert_eq!(section.versions().unwrap()[0].1.len(), 3);
407 section.seal().unwrap().unwrap();
408 let image = section.image();
409
410 let arena = Arena::default();
411 let mut reopened = Section::open(&arena, image.clone()).unwrap();
412 let store = Store::parse(&image).unwrap();
413 let index = RevisionIndex::parse(&store).unwrap();
414 index.validate_current().unwrap();
415 Document::parse(&index).unwrap();
416 let listed = reopened.versions().unwrap().remove(0).1;
417 assert_eq!(listed.len(), 3);
418 let pages: Vec<Page> = listed
419 .iter()
420 .map(|version| reopened.version(page(), version.context).unwrap())
421 .collect();
422 let version = reopened.version(page(), first).unwrap();
423 assert_eq!(pages[1], edited);
424 assert_eq!(pages[0].objects, version.objects);
425 assert_eq!(reopened.page(page()).unwrap().objects, version.objects);
426 // The restored page edits on as any other.
427 let text = texts(&reopened.page(page()).unwrap());
428 assert_eq!(text, ["First state. Typed by virtual."]);
429}
430
431/// An edit that fails after a restore leaves the section as it was before the edit.
432#[test]
433fn a_failed_edit_undoes_its_restore() {
434 let before = step(2);
435 let arena = Arena::default();
436 let mut section = Section::open(&arena, before.clone()).unwrap();
437 let first = section.versions().unwrap()[0].1[0].context;
438 let page_before = section.page(page()).unwrap();
439 let refused = section.apply(
440 "Clover Snow",
441 &Edit {
442 at: AT,
443 ops: vec![
444 Op::Section(SectionOp::RestoreVersion {
445 page: page(),
446 version: first,
447 guid: [1; 16],
448 }),
449 Op::Section(SectionOp::DeleteVersions {
450 page: page(),
451 versions: vec![id(HISTORY)],
452 }),
453 ],
454 },
455 );
456 assert!(refused.is_err());
457 assert_eq!(section.page(page()).unwrap(), page_before);
458 assert_eq!(section.versions().unwrap()[0].1.len(), 1);
459 assert!(section.seal().unwrap().is_none());
460 assert!(section.image() == before);
461}
462
463#[test]
464fn private_notebook_versions_read() {
465 let path = format!(
466 "{}/../../corpus/private/current/Art/Album.one",
467 env!("CARGO_MANIFEST_DIR")
468 );
469 let Ok(image) = std::fs::read(path) else {
470 return;
471 };
472 let arena = Arena::default();
473 let mut section = Section::open(&arena, image).unwrap();
474 let listed = section.versions().unwrap();
475 assert!(
476 listed
477 .iter()
478 .map(|(_, versions)| versions.len())
479 .sum::<usize>()
480 >= 10
481 );
482 let contexts: BTreeSet<ExGuid> = listed
483 .iter()
484 .flat_map(|(_, versions)| versions.iter().map(|version| version.context))
485 .collect();
486 assert_eq!(
487 contexts.len(),
488 listed.iter().map(|(_, v)| v.len()).sum::<usize>()
489 );
490 for (page, versions) in &listed {
491 for version in versions {
492 section.version(*page, version.context).unwrap();
493 }
494 }
495}
496
497fn export(output: &std::ffi::OsStr, name: &str, image: &[u8]) {
498 let directory = std::path::Path::new(output).join(name);
499 std::fs::create_dir_all(&directory).unwrap();
500 std::fs::write(directory.join("History.one"), image).unwrap();
501 std::fs::write(
502 directory.join("Open Notebook.onetoc2"),
503 read(&format!("{NATIVE}/step-01/notebook/Open Notebook.onetoc2")),
504 )
505 .unwrap();
506}