1use onestore::{
2 Arena, ConflictPage, ExGuid, ObjectData, PageCreation, PropertySets, RevisionIndex, Section,
3 Store, Value,
4 op::{Edit, Op, PageOp, SectionOp},
5 page::{Page, PageObject},
6};
7use std::collections::BTreeMap;
8
9const AT: u64 = 134_030_000_000_000_000;
10
11fn read(path: &str) -> Vec<u8> {
12 std::fs::read(format!("{}/../../{path}", env!("CARGO_MANIFEST_DIR"))).unwrap()
13}
14
15/// Each object's jcid and root-set property identities, by space and object.
16fn objects(image: &[u8]) -> BTreeMap<ExGuid, BTreeMap<ExGuid, (u32, Vec<u32>)>> {
17 let store = Store::parse(image).unwrap();
18 let index = RevisionIndex::parse(&store).unwrap();
19 index
20 .spaces
21 .keys()
22 .map(|space| {
23 let revision = index.resolve_active(*space).unwrap();
24 let objects = revision
25 .objects
26 .iter()
27 .map(|(id, object)| {
28 let ids = match object.data {
29 ObjectData::Properties(bytes) => PropertySets::parse(bytes).unwrap().sets
30 [0]
31 .iter()
32 .map(|property| property.id)
33 .collect(),
34 _ => Vec::new(),
35 };
36 (*id, (object.jcid, ids))
37 })
38 .collect();
39 (*space, objects)
40 })
41 .collect()
42}
43
44/// The object-space references a property holds.
45fn spaces(image: &[u8], space: ExGuid, object: ExGuid, property: u32) -> usize {
46 let store = Store::parse(image).unwrap();
47 let index = RevisionIndex::parse(&store).unwrap();
48 let revision = index.resolve_active(space).unwrap();
49 let ObjectData::Properties(bytes) = revision.objects[&object].data else {
50 panic!()
51 };
52 PropertySets::parse(bytes).unwrap().sets[0]
53 .iter()
54 .find(|field| field.id == property)
55 .map_or(0, |field| match &field.value {
56 Value::References { compact_ids, .. } => compact_ids.len() / 4,
57 _ => panic!(),
58 })
59}
60
61fn texts(page: &Page) -> Vec<(ExGuid, String)> {
62 page.objects
63 .iter()
64 .flat_map(|object| match object {
65 PageObject::Outline(outline) => outline.paragraphs.clone(),
66 PageObject::Title(title) => title
67 .outlines
68 .iter()
69 .flat_map(|outline| outline.paragraphs.clone())
70 .collect(),
71 _ => Vec::new(),
72 })
73 .filter_map(|paragraph| {
74 paragraph
75 .text()
76 .map(|text| (text.id, text.text.text().to_owned()))
77 })
78 .collect()
79}
80
81#[test]
82fn native_conflict_pages_are_listed_under_their_page_last_first() {
83 let arena = Arena::default();
84 let mut section = Section::open(
85 &arena,
86 read("corpus/collaboration/round-01/offline/notebook/synthetic.one"),
87 )
88 .unwrap();
89 let pages = section.pages().unwrap();
90 let conflicts = section.conflicts().unwrap();
91 assert_eq!(conflicts.len(), 1);
92 let (page, listed) = &conflicts[0];
93 assert_eq!(*page, pages[0].0);
94 let [
95 ConflictPage {
96 space,
97 title,
98 user,
99 created,
100 objects,
101 },
102 ] = listed.as_slice()
103 else {
104 panic!("{listed:?}")
105 };
106 assert_eq!(
107 (title.as_str(), user.as_str(), *created),
108 (
109 "Native edited while disconnected.",
110 "snow",
111 Some(0x01dd3d10d7afd9e0)
112 )
113 );
114 let conflict = section.page(*space).unwrap();
115 let marked: Vec<_> = texts(&conflict)
116 .into_iter()
117 .filter(|(id, _)| objects.contains(id))
118 .map(|(_, text)| text)
119 .collect();
120 assert_eq!(marked, ["Native edited while disconnected."]);
121
122 // Three clients' conflict pages: OneNote lists the one stored last first.
123 let arena = Arena::default();
124 let mut section = Section::open(
125 &arena,
126 read("corpus/conflict-page/native-three/notebook/synthetic.one"),
127 )
128 .unwrap();
129 let users: Vec<_> = section.conflicts().unwrap()[0]
130 .1
131 .iter()
132 .map(|page| page.user.clone())
133 .collect();
134 assert_eq!(
135 users,
136 ["ONE-M6-053BD357", "ONE-M6-9451B6C6", "ONE-M6-DDE10E0D"]
137 );
138}
139
140/// The conflict page a merge makes of `local`, the version of `space`'s page it could not
141/// take, marking the text object `conflicting`.
142fn conflict(space: ExGuid, local: &Page, conflicting: ExGuid, user: &str) -> Op {
143 let titled = local
144 .objects
145 .iter()
146 .any(|object| matches!(object, PageObject::Title(_)));
147 let mut objects = [conflicting];
148 let page = local.copy_with(&mut objects).unwrap();
149 Op::Section(SectionOp::Conflict {
150 of: space,
151 creation: PageCreation::new(None, titled.then_some(local.title.as_str()), user).unwrap(),
152 page,
153 objects: objects.to_vec(),
154 })
155}
156
157/// A conflict page written through ops stores what OneNote 2010 writes for one
158/// (`corpus/collaboration/round-01/offline`): its own space under the page's manifest, with
159/// conflict metadata, a read-only deletable conflict page and the conflicting text marked.
160/// `ONESTORE_CONFLICT_EXPORT` names a directory to write the notebook to, for a cold read.
161#[test]
162fn a_conflict_page_is_stored_as_onenote_stores_one() {
163 let notebook = "corpus/native-ink/cold-ui-ink/notebook";
164 let source = read(&format!("{notebook}/synthetic.one"));
165 let arena = Arena::default();
166 let mut section = Section::open(&arena, source.clone()).unwrap();
167 let space = section.pages().unwrap()[0].0;
168 let base = section.page(space).unwrap();
169 let (text, _) = texts(&base)
170 .into_iter()
171 .find(|(_, text)| text.starts_with("Fictitious: café"))
172 .unwrap();
173 // Both machines renamed the paragraph's first word, within its Latin run.
174 let replace = |with: &str| Edit {
175 at: AT,
176 ops: vec![Op::Page {
177 space,
178 op: PageOp::Text {
179 text,
180 range: 0..10,
181 with: with.into(),
182 },
183 }],
184 };
185 section.apply("Remote", &replace("Remote")).unwrap();
186 let scratch = Arena::default();
187 let mut local = Section::open(&scratch, source).unwrap();
188 local.apply("Clover Snow", &replace("Snowbound")).unwrap();
189 let local = local.page(space).unwrap();
190 let op = conflict(space, &local, text, "Clover Snow");
191 let Op::Section(SectionOp::Conflict { creation, .. }) = &op else {
192 unreachable!()
193 };
194 let created = creation.space();
195 section
196 .apply(
197 "Clover Snow",
198 &Edit {
199 at: AT + 10_000_000,
200 ops: vec![op.clone()],
201 },
202 )
203 .unwrap();
204 section.seal().unwrap();
205 let image = section.image();
206
207 let arena = Arena::default();
208 let mut reopened = Section::open(&arena, image.clone()).unwrap();
209 assert_eq!(reopened.pages().unwrap().len(), 1);
210 let listed = reopened.conflicts().unwrap();
211 assert_eq!(listed.len(), 1);
212 assert_eq!(listed[0].0, space);
213 assert_eq!(
214 listed[0].1,
215 [ConflictPage {
216 space: created,
217 title: "Snowbound: café, 東京, مرحبا".into(),
218 user: "Clover Snow".into(),
219 created: listed[0].1[0].created,
220 objects: listed[0].1[0].objects.clone(),
221 }]
222 );
223 let kept = reopened.page(created).unwrap();
224 let marked: Vec<_> = texts(&kept)
225 .into_iter()
226 .filter(|(id, _)| listed[0].1[0].objects.contains(id))
227 .map(|(_, text)| text)
228 .collect();
229 assert_eq!(marked, ["Snowbound: café, 東京, مرحبا"]);
230 assert!(
231 texts(&reopened.page(space).unwrap())
232 .iter()
233 .any(|(_, text)| text == "Remote: café, 東京, مرحبا")
234 );
235
236 // The stored form, property by property, as OneNote writes it.
237 let stored = objects(&image);
238 let conflict_space = &stored[&created];
239 let store = Store::parse(&image).unwrap();
240 let index = RevisionIndex::parse(&store).unwrap();
241 let roots = index.resolve_active(created).unwrap().roots;
242 let (jcid, metadata) = &conflict_space[&roots[&2]];
243 assert_eq!(*jcid, 0x20038);
244 assert_eq!(
245 metadata,
246 &[
247 0x1c001cf3, 0x1c001c30, 0x14001dff, 0x14001d82, 0x1400348b, 0x18001c65, 0x1c001d9e,
248 0x1c001d9f
249 ]
250 );
251 let page_node = conflict_space
252 .values()
253 .find(|(jcid, _)| *jcid == 0x6000b)
254 .unwrap();
255 for flag in [0x88001cde, 0x88001d0c, 0x88001d7c] {
256 assert!(page_node.1.contains(&flag), "{flag:#x}");
257 }
258 let marked: Vec<_> = conflict_space
259 .iter()
260 .filter(|(_, (_, ids))| ids.contains(&0x88001d96))
261 .map(|(_, (jcid, ids))| (*jcid, ids.contains(&0x88001ddb)))
262 .collect();
263 assert_eq!(marked, [(0x6000e, true)]);
264 let main = &stored[&space];
265 let main_roots = index.resolve_active(space).unwrap().roots;
266 assert_eq!(spaces(&image, space, main_roots[&1], 0x2c001d63), 1);
267 assert!(main[&main_roots[&2]].1.contains(&0x88001d97));
268 assert_eq!(
269 main.values()
270 .filter(|(jcid, ids)| *jcid == 0x20038 && ids.contains(&0x1c001d9e))
271 .count(),
272 1
273 );
274 assert_eq!(
275 stored[&index.root]
276 .values()
277 .filter(|(jcid, ids)| *jcid == 0x20030 && ids.contains(&0x88001d97))
278 .count(),
279 1
280 );
281
282 if let Some(output) = std::env::var_os("ONESTORE_CONFLICT_EXPORT") {
283 let output = std::path::Path::new(&output);
284 std::fs::create_dir_all(output).unwrap();
285 std::fs::write(output.join("synthetic.one"), &image).unwrap();
286 std::fs::copy(
287 format!(
288 "{}/../../{notebook}/Open Notebook.onetoc2",
289 env!("CARGO_MANIFEST_DIR")
290 ),
291 output.join("Open Notebook.onetoc2"),
292 )
293 .unwrap();
294 }
295}
296
297/// Deleting a conflict page stores what OneNote 2010's Delete Conflict Page does
298/// (`corpus/conflict-page/native-delete`): the page's manifest stops listing the conflict
299/// page, keeping its metadata copy, and the page and the section's copy of its metadata no
300/// longer say it has conflict pages; the conflict page's space is left alone.
301/// `ONESTORE_CONFLICT_DELETE_EXPORT` names a directory to write the notebook to.
302#[test]
303fn deleting_a_conflict_page_stores_what_onenote_stores() {
304 let notebook = "corpus/conflict-page/native/conflict/notebook";
305 let arena = Arena::default();
306 let mut section = Section::open(&arena, read(&format!("{notebook}/synthetic.one"))).unwrap();
307 let (page, listed) = section.conflicts().unwrap().remove(0);
308 let conflict = listed[0].space;
309 section
310 .apply(
311 "Clover Snow",
312 &Edit {
313 at: AT,
314 ops: vec![Op::Section(SectionOp::Delete(vec![conflict]))],
315 },
316 )
317 .unwrap();
318 section.seal().unwrap();
319 let image = section.image();
320 let arena = Arena::default();
321 let mut reopened = Section::open(&arena, image.clone()).unwrap();
322 assert!(reopened.conflicts().unwrap().is_empty());
323 assert_eq!(reopened.pages().unwrap().len(), 1);
324
325 // Each space's objects and their property identities, as OneNote left them.
326 let native = read("corpus/conflict-page/native-delete/notebook/synthetic.one");
327 let shapes = |image: &[u8]| {
328 let store = Store::parse(image).unwrap();
329 let index = RevisionIndex::parse(&store).unwrap();
330 let root = index.root;
331 let roots = index.resolve_active(page).unwrap().roots;
332 let objects = objects(image);
333 let manifest = objects[&page][&roots[&1]].clone();
334 let metadata = objects[&page][&roots[&2]].clone();
335 let flagged = objects[&root]
336 .values()
337 .filter(|(_, ids)| ids.contains(&0x88001d97))
338 .count();
339 (manifest, metadata, flagged, objects[&conflict].clone())
340 };
341 let (manifest, metadata, flagged, space) = shapes(&image);
342 let (native_manifest, native_metadata, native_flagged, native_space) = shapes(&native);
343 assert_eq!(
344 manifest
345 .1
346 .iter()
347 .filter(|id| **id != 0x3400347b)
348 .collect::<Vec<_>>(),
349 [&0x24001c1f, &0x24003442]
350 );
351 assert_eq!(
352 native_manifest
353 .1
354 .iter()
355 .filter(|id| **id != 0x3400347b)
356 .collect::<Vec<_>>(),
357 [&0x24001c1f, &0x24003442]
358 );
359 assert!(!metadata.1.contains(&0x88001d97) && !native_metadata.1.contains(&0x88001d97));
360 assert_eq!((flagged, native_flagged), (0, 0));
361 assert_eq!(space, native_space);
362
363 if let Some(output) = std::env::var_os("ONESTORE_CONFLICT_DELETE_EXPORT") {
364 let output = std::path::Path::new(&output);
365 std::fs::create_dir_all(output).unwrap();
366 std::fs::write(output.join("synthetic.one"), &image).unwrap();
367 std::fs::copy(
368 format!(
369 "{}/../../{notebook}/Open Notebook.onetoc2",
370 env!("CARGO_MANIFEST_DIR")
371 ),
372 output.join("Open Notebook.onetoc2"),
373 )
374 .unwrap();
375 }
376}