| 1 | use notebook::session::Notebook; |
| 2 | use std::io::Write; |
| 3 | |
| 4 | /// Each file's header names its parent TOC and carries the name CRC OneNote wrote for the |
| 5 | /// same name in `corpus/notebook-edit/{structured,deleted}/cold`. |
| 6 | fn placement(root: &std::path::Path, expected: &[(&str, u32)]) { |
| 7 | let header = |path: std::path::PathBuf| { |
| 8 | onestore::Store::parse(&std::fs::read(path).unwrap()) |
| 9 | .unwrap() |
| 10 | .header |
| 11 | }; |
| 12 | for (path, crc) in expected { |
| 13 | let (folder, name) = path.rsplit_once('/').unwrap_or(("", path)); |
| 14 | let parent = header(root.join(folder).join("Open Notebook.onetoc2")).file_id; |
| 15 | let file = if name.ends_with(".one") { |
| 16 | root.join(path) |
| 17 | } else { |
| 18 | root.join(path).join("Open Notebook.onetoc2") |
| 19 | }; |
| 20 | let header = header(file); |
| 21 | assert_eq!((header.ancestor, header.name_crc), (parent, *crc), "{path}"); |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | fn names(folder: &notebook::discover::Folder) -> (Vec<String>, Vec<String>) { |
| 26 | ( |
| 27 | folder.sections.iter().map(|s| s.path.clone()).collect(), |
| 28 | folder.groups.iter().map(|g| g.path.clone()).collect(), |
| 29 | ) |
| 30 | } |
| 31 | |
| 32 | /// `NOTEBOOK_STRUCTURE_EXPORT` names a new directory receiving the final notebook for a |
| 33 | /// cold reopen. |
| 34 | #[test] |
| 35 | fn sections_and_groups_are_created_renamed_coloured_ordered_and_deleted() { |
| 36 | let temporary = tempfile::tempdir().unwrap(); |
| 37 | let root = temporary.path().join("notebook"); |
| 38 | std::fs::create_dir(&root).unwrap(); |
| 39 | let first = onestore::create_section("First.one", "First page", "Author").unwrap(); |
| 40 | std::fs::File::create_new(root.join("First.one")) |
| 41 | .unwrap() |
| 42 | .write_all(&first) |
| 43 | .unwrap(); |
| 44 | let first_id = onestore::Store::parse(&first).unwrap().header.file_id; |
| 45 | std::fs::write( |
| 46 | root.join("Open Notebook.onetoc2"), |
| 47 | onestore::create_table_of_contents("Open Notebook.onetoc2", &[("First.one", first_id)]) |
| 48 | .unwrap(), |
| 49 | ) |
| 50 | .unwrap(); |
| 51 | let toc = onestore::read_file(root.join("Open Notebook.onetoc2")).unwrap(); |
| 52 | let toc_id = onestore::Store::parse(&toc).unwrap().header.file_id; |
| 53 | onestore::place_file(root.join("First.one"), toc_id, "First.one").unwrap(); |
| 54 | let mut notebook = Notebook::open(&root, temporary.path().join("cache")).unwrap(); |
| 55 | assert_eq!( |
| 56 | notebook |
| 57 | .create_section( |
| 58 | "", |
| 59 | "Second", |
| 60 | &onestore::PageCreation::new(None, Some(""), "Author").unwrap() |
| 61 | ) |
| 62 | .unwrap(), |
| 63 | "Second.one" |
| 64 | ); |
| 65 | assert_eq!(notebook.create_group("", "Archive").unwrap(), "Archive"); |
| 66 | assert_eq!( |
| 67 | notebook |
| 68 | .create_section( |
| 69 | "Archive", |
| 70 | "Inner", |
| 71 | &onestore::PageCreation::new(None, Some(""), "Author").unwrap() |
| 72 | ) |
| 73 | .unwrap(), |
| 74 | "Archive/Inner.one" |
| 75 | ); |
| 76 | assert_eq!( |
| 77 | names(notebook.catalog()), |
| 78 | ( |
| 79 | vec!["First.one".to_owned(), "Second.one".to_owned()], |
| 80 | vec!["Archive".to_owned()] |
| 81 | ) |
| 82 | ); |
| 83 | assert_eq!( |
| 84 | names(&notebook.catalog().groups[0]), |
| 85 | (vec!["Archive/Inner.one".to_owned()], vec![]) |
| 86 | ); |
| 87 | assert_eq!( |
| 88 | notebook.rename("Second.one", "Renamed").unwrap(), |
| 89 | "Renamed.one" |
| 90 | ); |
| 91 | assert_eq!(notebook.rename("Archive", "Kept").unwrap(), "Kept"); |
| 92 | notebook |
| 93 | .set_section_color("Renamed.one", Some(0x5ed7ff)) |
| 94 | .unwrap(); |
| 95 | notebook.reorder("", &["Kept", "Renamed.one"]).unwrap(); |
| 96 | assert_eq!( |
| 97 | names(notebook.catalog()), |
| 98 | ( |
| 99 | vec!["Renamed.one".to_owned(), "First.one".to_owned()], |
| 100 | vec!["Kept".to_owned()] |
| 101 | ) |
| 102 | ); |
| 103 | let toc = notebook.catalog().toc.as_ref().unwrap(); |
| 104 | let orders: Vec<(Option<String>, u32)> = toc |
| 105 | .unresolved |
| 106 | .iter() |
| 107 | .map(|entry| (entry.filename.clone(), entry.order)) |
| 108 | .collect(); |
| 109 | assert!(orders.is_empty(), "{orders:?}"); |
| 110 | let colour = onestore::read_file(root.join("Renamed.one")).unwrap(); |
| 111 | let store = onestore::Store::parse(&colour).unwrap(); |
| 112 | let index = onestore::RevisionIndex::parse(&store).unwrap(); |
| 113 | let document = onestore::document::Document::parse(&index).unwrap(); |
| 114 | let revision = document.active(document.root).unwrap(); |
| 115 | let onestore::document::Kind::SectionMetadata { color, .. } = |
| 116 | &revision.nodes[&revision.roots[&2]].kind |
| 117 | else { |
| 118 | panic!() |
| 119 | }; |
| 120 | assert_eq!(*color, Some(0x5ed7ff)); |
| 121 | placement( |
| 122 | &root, |
| 123 | &[ |
| 124 | ("First.one", 0x2e1b24f3), |
| 125 | ("Renamed.one", 0x6108912a), |
| 126 | ("Kept", 0xf2a400f4), |
| 127 | ("Kept/Inner.one", 0x83e261da), |
| 128 | ], |
| 129 | ); |
| 130 | if let Some(directory) = std::env::var_os("NOTEBOOK_STRUCTURE_EXPORT") { |
| 131 | copy_dir(&root, std::path::Path::new(&directory)); |
| 132 | } |
| 133 | notebook.delete("First.one").unwrap(); |
| 134 | assert_eq!( |
| 135 | names(notebook.catalog()), |
| 136 | ( |
| 137 | vec!["Renamed.one".to_owned()], |
| 138 | vec!["Kept".to_owned(), "OneNote_RecycleBin".to_owned()] |
| 139 | ) |
| 140 | ); |
| 141 | assert_eq!( |
| 142 | names(&notebook.catalog().groups[1]), |
| 143 | (vec!["OneNote_RecycleBin/First.one".to_owned()], vec![]) |
| 144 | ); |
| 145 | assert!(root.join("OneNote_RecycleBin/First.one").exists()); |
| 146 | let bin = onestore::read_file(root.join("OneNote_RecycleBin/Open Notebook.onetoc2")).unwrap(); |
| 147 | assert!( |
| 148 | String::from_utf16_lossy( |
| 149 | &bin.chunks_exact(2) |
| 150 | .map(|c| u16::from_le_bytes([c[0], c[1]])) |
| 151 | .collect::<Vec<_>>() |
| 152 | ) |
| 153 | .contains("First.one") |
| 154 | ); |
| 155 | assert!(notebook.rename("Kept/Inner.one", "First").is_ok()); |
| 156 | assert!(notebook.delete("Missing.one").is_err()); |
| 157 | placement( |
| 158 | &root, |
| 159 | &[ |
| 160 | ("Renamed.one", 0x6108912a), |
| 161 | ("Kept", 0xf2a400f4), |
| 162 | ("Kept/First.one", 0x2e1b24f3), |
| 163 | ("OneNote_RecycleBin", 0x986b646a), |
| 164 | ("OneNote_RecycleBin/First.one", 0x2e1b24f3), |
| 165 | ], |
| 166 | ); |
| 167 | if let Some(directory) = std::env::var_os("NOTEBOOK_STRUCTURE_EXPORT_DELETED") { |
| 168 | copy_dir(&root, std::path::Path::new(&directory)); |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | fn copy_dir(from: &std::path::Path, to: &std::path::Path) { |
| 173 | std::fs::create_dir_all(to).unwrap(); |
| 174 | for entry in std::fs::read_dir(from).unwrap() { |
| 175 | let entry = entry.unwrap(); |
| 176 | let target = to.join(entry.file_name()); |
| 177 | if entry.file_type().unwrap().is_dir() { |
| 178 | copy_dir(&entry.path(), &target); |
| 179 | } else { |
| 180 | std::fs::copy(entry.path(), target).unwrap(); |
| 181 | } |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | #[test] |
| 186 | fn a_rename_keeps_its_place_in_the_notebook_and_in_a_group() { |
| 187 | let temporary = tempfile::tempdir().unwrap(); |
| 188 | let root = temporary.path().join("notebook"); |
| 189 | let page = onestore::PageCreation::new(None, Some(""), "Author").unwrap(); |
| 190 | let mut notebook = |
| 191 | Notebook::create(&root, temporary.path().join("cache"), 0x00d7ff, &page).unwrap(); |
| 192 | notebook.create_group("", "Group").unwrap(); |
| 193 | for folder in ["", "Group"] { |
| 194 | for name in ["Video", "Song", "Album"] { |
| 195 | notebook.create_section(folder, name, &page).unwrap(); |
| 196 | } |
| 197 | } |
| 198 | let path = |folder: &str, name: &str| { |
| 199 | if folder.is_empty() { |
| 200 | format!("{name}.one") |
| 201 | } else { |
| 202 | format!("{folder}/{name}.one") |
| 203 | } |
| 204 | }; |
| 205 | notebook |
| 206 | .reorder( |
| 207 | "", |
| 208 | &["Album.one", "Video.one", "New Section 1.one", "Song.one"], |
| 209 | ) |
| 210 | .unwrap(); |
| 211 | notebook |
| 212 | .reorder("Group", &["Group/Album.one", "Group/Video.one"]) |
| 213 | .unwrap(); |
| 214 | notebook.rename("Video.one", "Kitchen").unwrap(); |
| 215 | notebook.rename("Group/Video.one", "Kitchen").unwrap(); |
| 216 | notebook.rename("Group", "Renamed").unwrap(); |
| 217 | assert_eq!( |
| 218 | names(notebook.catalog()), |
| 219 | ( |
| 220 | ["Album", "Kitchen", "New Section 1", "Song"] |
| 221 | .map(|name| path("", name)) |
| 222 | .to_vec(), |
| 223 | vec!["Renamed".to_owned()] |
| 224 | ) |
| 225 | ); |
| 226 | assert_eq!( |
| 227 | names(&notebook.catalog().groups[0]).0, |
| 228 | ["Album", "Kitchen", "Song"].map(|name| path("Renamed", name)) |
| 229 | ); |
| 230 | } |
| 231 | |
| 232 | /// Each entry a TOC file lists: its filename and file identity. |
| 233 | fn listed(path: &std::path::Path) -> Vec<(String, [u8; 16])> { |
| 234 | let image = onestore::read_file(path).unwrap(); |
| 235 | let store = onestore::Store::parse(&image).unwrap(); |
| 236 | let index = onestore::RevisionIndex::parse(&store).unwrap(); |
| 237 | let document = onestore::document::Document::parse(&index).unwrap(); |
| 238 | let revision = document.active(document.root).unwrap(); |
| 239 | let onestore::document::Kind::Toc { entries, .. } = &revision.nodes[&revision.roots[&1]].kind |
| 240 | else { |
| 241 | panic!() |
| 242 | }; |
| 243 | entries |
| 244 | .iter() |
| 245 | .map(|id| match &revision.nodes[id].kind { |
| 246 | onestore::document::Kind::Toc { |
| 247 | filename, identity, .. |
| 248 | } => (filename.clone().unwrap_or_default(), identity.unwrap()), |
| 249 | _ => panic!(), |
| 250 | }) |
| 251 | .collect() |
| 252 | } |
| 253 | |
| 254 | fn file_id(path: &std::path::Path) -> [u8; 16] { |
| 255 | onestore::Store::parse(&onestore::read_file(path).unwrap()) |
| 256 | .unwrap() |
| 257 | .header |
| 258 | .file_id |
| 259 | } |
| 260 | |
| 261 | #[test] |
| 262 | fn a_page_delete_lists_the_recycle_bin_it_finds_or_makes_and_leaves_an_unreadable_one() { |
| 263 | let temporary = tempfile::tempdir().unwrap(); |
| 264 | let root = temporary.path().join("notebook"); |
| 265 | let bin = root.join("OneNote_RecycleBin"); |
| 266 | let page = onestore::PageCreation::new(None, Some("Kept"), "Author").unwrap(); |
| 267 | let mut notebook = |
| 268 | Notebook::create(&root, temporary.path().join("cache"), 0x00d7ff, &page).unwrap(); |
| 269 | let recycle = |notebook: &mut Notebook| { |
| 270 | let image = notebook.read_section("New Section 1.one").unwrap(); |
| 271 | let pages = notebook::session::stored_pages(&image).unwrap(); |
| 272 | notebook.recycle_pages(&[pages[0].page.clone()], "Author") |
| 273 | }; |
| 274 | let bins = |root: &std::path::Path| -> Vec<[u8; 16]> { |
| 275 | listed(&root.join("Open Notebook.onetoc2")) |
| 276 | .into_iter() |
| 277 | .filter(|(name, _)| name == "OneNote_RecycleBin") |
| 278 | .map(|(_, identity)| identity) |
| 279 | .collect() |
| 280 | }; |
| 281 | recycle(&mut notebook).unwrap(); |
| 282 | |
| 283 | // Gone from the folder, the bin's entry is stale; the next bin takes its name. |
| 284 | std::fs::remove_dir_all(&bin).unwrap(); |
| 285 | notebook.refresh().unwrap(); |
| 286 | recycle(&mut notebook).unwrap(); |
| 287 | let toc = bin.join("Open Notebook.onetoc2"); |
| 288 | assert_eq!(bins(&root), [file_id(&toc)]); |
| 289 | let deleted = file_id(&bin.join("OneNote_DeletedPages.one")); |
| 290 | assert_eq!(listed(&toc), [("OneNote_DeletedPages.one".into(), deleted)]); |
| 291 | |
| 292 | // A bin whose TOC has another name and misses the deleted pages is kept and completed. |
| 293 | std::fs::remove_file(&toc).unwrap(); |
| 294 | let other = bin.join("OneNote Table Of Contents.onetoc2"); |
| 295 | std::fs::write( |
| 296 | &other, |
| 297 | onestore::create_table_of_contents("OneNote Table Of Contents.onetoc2", &[]).unwrap(), |
| 298 | ) |
| 299 | .unwrap(); |
| 300 | let parent = file_id(&root.join("Open Notebook.onetoc2")); |
| 301 | onestore::place_file(&other, parent, "OneNote_RecycleBin").unwrap(); |
| 302 | notebook.refresh().unwrap(); |
| 303 | recycle(&mut notebook).unwrap(); |
| 304 | assert!(!toc.exists()); |
| 305 | assert_eq!(bins(&root), [file_id(&other)]); |
| 306 | assert_eq!( |
| 307 | listed(&other), |
| 308 | [("OneNote_DeletedPages.one".into(), deleted)] |
| 309 | ); |
| 310 | let ancestor = |
| 311 | onestore::Store::parse(&onestore::read_file(bin.join("OneNote_DeletedPages.one")).unwrap()) |
| 312 | .unwrap() |
| 313 | .header |
| 314 | .ancestor; |
| 315 | assert_eq!(ancestor, file_id(&other)); |
| 316 | // `NOTEBOOK_RECYCLE_EXPORT` names a new directory receiving it for a cold reopen. |
| 317 | if let Some(directory) = std::env::var_os("NOTEBOOK_RECYCLE_EXPORT") { |
| 318 | copy_dir(&root, std::path::Path::new(&directory)); |
| 319 | } |
| 320 | // A bin that cannot be read is neither replaced nor added to. |
| 321 | #[cfg(unix)] |
| 322 | { |
| 323 | use std::os::unix::fs::PermissionsExt; |
| 324 | // Permission bits do not bind the superuser. |
| 325 | if unsafe { libc::geteuid() } == 0 { |
| 326 | return; |
| 327 | } |
| 328 | std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o000)).unwrap(); |
| 329 | let denied = notebook.refresh().and_then(|_| recycle(&mut notebook)); |
| 330 | std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); |
| 331 | assert!(denied.is_err()); |
| 332 | let names: Vec<_> = std::fs::read_dir(&bin) |
| 333 | .unwrap() |
| 334 | .map(|entry| entry.unwrap().file_name().into_string().unwrap()) |
| 335 | .collect(); |
| 336 | assert_eq!(names.len(), 2, "{names:?}"); |
| 337 | } |
| 338 | } |
| 339 | |
| 340 | /// What the recycle bin holds goes for good once its pages last changed over 60 days |
| 341 | /// before, as OneNote 2010 prunes it; a pruned section's TOC entry stays, as OneNote leaves it |
| 342 | /// (`corpus/recycle-purge`). `NOTEBOOK_PURGE_EXPORT` names a new directory receiving the |
| 343 | /// purged notebook for a cold reopen. |
| 344 | #[test] |
| 345 | fn the_recycle_bin_forgets_pages_and_sections_unchanged_for_sixty_days() { |
| 346 | let temporary = tempfile::tempdir().unwrap(); |
| 347 | let root = temporary.path().join("notebook"); |
| 348 | let bin = root.join("OneNote_RecycleBin"); |
| 349 | let page = onestore::PageCreation::new(None, Some("Kept"), "Author").unwrap(); |
| 350 | let mut notebook = |
| 351 | Notebook::create(&root, temporary.path().join("cache"), 0x00d7ff, &page).unwrap(); |
| 352 | let old = onestore::PageCreation::new(None, Some("Old page"), "Author").unwrap(); |
| 353 | notebook.create_section("", "Old", &old).unwrap(); |
| 354 | let image = notebook.read_section("New Section 1.one").unwrap(); |
| 355 | let pages = notebook::session::stored_pages(&image).unwrap(); |
| 356 | notebook |
| 357 | .recycle_pages(&[pages[0].page.clone()], "Author") |
| 358 | .unwrap(); |
| 359 | notebook.delete("Old.one").unwrap(); |
| 360 | let toc = bin.join("Open Notebook.onetoc2"); |
| 361 | let before = listed(&toc); |
| 362 | assert!(before.iter().any(|(name, _)| name == "Old.one")); |
| 363 | let deleted = bin.join("OneNote_DeletedPages.one"); |
| 364 | let recycled = |notebook: &Notebook| { |
| 365 | let image = notebook |
| 366 | .read_section("OneNote_RecycleBin/OneNote_DeletedPages.one") |
| 367 | .unwrap(); |
| 368 | notebook::session::stored_pages(&image).unwrap().len() |
| 369 | }; |
| 370 | assert_eq!(recycled(&notebook), 1); |
| 371 | let now = std::time::SystemTime::now() |
| 372 | .duration_since(std::time::UNIX_EPOCH) |
| 373 | .unwrap() |
| 374 | .as_secs() as u32 |
| 375 | - 315_532_800; |
| 376 | let day = 86_400; |
| 377 | assert_eq!(notebook.purge_recycle_bin(now + 59 * day).unwrap(), 0); |
| 378 | assert!(bin.join("Old.one").exists()); |
| 379 | let length = std::fs::metadata(&deleted).unwrap().len(); |
| 380 | assert_eq!(notebook.purge_recycle_bin(now + 61 * day).unwrap(), 2); |
| 381 | assert!(!bin.join("Old.one").exists()); |
| 382 | assert_eq!(recycled(&notebook), 0); |
| 383 | // One appended revision; the bin's TOC untouched. |
| 384 | assert!(std::fs::metadata(&deleted).unwrap().len() > length); |
| 385 | assert_eq!(listed(&toc), before); |
| 386 | assert_eq!(notebook.purge_recycle_bin(now + 61 * day).unwrap(), 0); |
| 387 | if let Some(directory) = std::env::var_os("NOTEBOOK_PURGE_EXPORT") { |
| 388 | copy_dir(&root, std::path::Path::new(&directory)); |
| 389 | } |
| 390 | } |