| 1 | use onestore::{ |
| 2 | Arena, ConflictPage, ExGuid, ObjectData, PageCreation, PropertySets, RevisionIndex, Section, |
| 3 | Store, Value, |
| 4 | op::{Edit, Op, PageOp, SectionOp}, |
| 5 | page::{Page, PageObject}, |
| 6 | }; |
| 7 | use std::collections::BTreeMap; |
| 8 | |
| 9 | const AT: u64 = 134_030_000_000_000_000; |
| 10 | |
| 11 | fn 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. |
| 16 | fn 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. |
| 45 | fn 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 | |
| 61 | fn 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] |
| 82 | fn 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`. |
| 142 | fn 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] |
| 162 | fn 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] |
| 303 | fn 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 | } |