| 1 | //! OneNote packages and the copies Save As writes (`corpus/notebook-package`). |
| 2 | |
| 3 | use notebook::{package, session::Notebook}; |
| 4 | use std::path::Path; |
| 5 | |
| 6 | fn header(image: &[u8]) -> onestore::Header { |
| 7 | onestore::Store::parse(image).unwrap().header |
| 8 | } |
| 9 | |
| 10 | /// Each entry of the table of contents `image` holds: its filename and file identity. |
| 11 | fn listed(image: &[u8]) -> Vec<(String, [u8; 16])> { |
| 12 | let store = onestore::Store::parse(image).unwrap(); |
| 13 | let index = onestore::RevisionIndex::parse(&store).unwrap(); |
| 14 | let document = onestore::document::Document::parse(&index).unwrap(); |
| 15 | let revision = document.active(document.root).unwrap(); |
| 16 | let onestore::document::Kind::Toc { entries, .. } = &revision.nodes[&revision.roots[&1]].kind |
| 17 | else { |
| 18 | panic!() |
| 19 | }; |
| 20 | (entries.iter()) |
| 21 | .map(|id| match &revision.nodes[id].kind { |
| 22 | onestore::document::Kind::Toc { |
| 23 | filename, identity, .. |
| 24 | } => (filename.clone().unwrap_or_default(), identity.unwrap()), |
| 25 | _ => panic!(), |
| 26 | }) |
| 27 | .collect() |
| 28 | } |
| 29 | |
| 30 | /// A notebook of a section, a group holding one, and a page deleted into the recycle bin. |
| 31 | fn notebook(root: &Path, cache: &Path) -> Notebook { |
| 32 | let page = |title: &str| onestore::PageCreation::new(None, Some(title), "Author").unwrap(); |
| 33 | let mut notebook = Notebook::create(root, cache, 0x00d7ff, &page("First")).unwrap(); |
| 34 | notebook.create_group("", "Group").unwrap(); |
| 35 | notebook |
| 36 | .create_section("Group", "Inner", &page("Inside")) |
| 37 | .unwrap(); |
| 38 | notebook |
| 39 | .create_section("", "Doomed", &page("Binned")) |
| 40 | .unwrap(); |
| 41 | let image = notebook.read_section("Doomed.one").unwrap(); |
| 42 | let pages = notebook::session::stored_pages(&image).unwrap(); |
| 43 | notebook |
| 44 | .recycle_pages(&[pages[0].page.clone()], "Author") |
| 45 | .unwrap(); |
| 46 | notebook.delete("Doomed.one").unwrap(); |
| 47 | notebook |
| 48 | } |
| 49 | |
| 50 | /// Pack writes the notebook's tables of contents and sections, the recycle bin left out, |
| 51 | /// outside any notebook; Unpack Notebook gives every file a new identity, placed under its |
| 52 | /// folder's table of contents, whose entries follow, and the notebook opens as it was. |
| 53 | /// `NOTEBOOK_PACKAGE_EXPORT` names a new directory receiving the package and the notebook |
| 54 | /// unpacked, for OneNote 2010 to unpack and to open cold. |
| 55 | #[test] |
| 56 | fn a_notebook_packs_and_unpacks_as_onenote_packs_one() { |
| 57 | let temporary = tempfile::tempdir().unwrap(); |
| 58 | let root = temporary.path().join("Packed"); |
| 59 | let cache = temporary.path().join("cache"); |
| 60 | let notebook = notebook(&root, &cache); |
| 61 | let files = package::notebook_files(&notebook, |_| None).unwrap(); |
| 62 | let paths: Vec<&str> = files.iter().map(|(path, _)| path.as_str()).collect(); |
| 63 | assert_eq!( |
| 64 | paths, |
| 65 | [ |
| 66 | "Open Notebook.onetoc2", |
| 67 | "New Section 1.one", |
| 68 | "Group/Open Notebook.onetoc2", |
| 69 | "Group/Inner.one" |
| 70 | ] |
| 71 | ); |
| 72 | let packed = package::pack(files.clone()).unwrap(); |
| 73 | let read = package::read(&packed, 1 << 30).unwrap(); |
| 74 | assert_eq!(read.len(), files.len()); |
| 75 | for ((path, bytes), (stored, original)) in read.iter().zip(&files) { |
| 76 | assert_eq!(path, stored); |
| 77 | let (unplaced, placed) = (header(bytes), header(original)); |
| 78 | assert_eq!( |
| 79 | unplaced.file_id, placed.file_id, |
| 80 | "{path} keeps its identity" |
| 81 | ); |
| 82 | assert_eq!( |
| 83 | (unplaced.ancestor, unplaced.name_crc), |
| 84 | ([0; 16], 0), |
| 85 | "{path}" |
| 86 | ); |
| 87 | assert_eq!(bytes[..128], original[..128]); |
| 88 | assert_eq!(bytes[148..], original[148..]); |
| 89 | } |
| 90 | |
| 91 | let unpacked = temporary.path().join("Unpacked"); |
| 92 | package::unpack(read, &unpacked).unwrap(); |
| 93 | let opened = Notebook::open(&unpacked, &cache).unwrap(); |
| 94 | let catalog = opened.catalog(); |
| 95 | let sections = |folder: &notebook::discover::Folder| -> Vec<String> { |
| 96 | (folder.sections.iter()) |
| 97 | .map(|section| section.path.clone()) |
| 98 | .collect() |
| 99 | }; |
| 100 | assert_eq!(sections(catalog), ["New Section 1.one"]); |
| 101 | assert_eq!(sections(&catalog.groups[0]), ["Group/Inner.one"]); |
| 102 | assert!(catalog.unavailable.is_empty() && catalog.groups[0].unavailable.is_empty()); |
| 103 | let read = |path: &str| std::fs::read(unpacked.join(path)).unwrap(); |
| 104 | let root_toc = read("Open Notebook.onetoc2"); |
| 105 | let group_toc = read("Group/Open Notebook.onetoc2"); |
| 106 | let section = read("New Section 1.one"); |
| 107 | let inner = read("Group/Inner.one"); |
| 108 | for (unpacked, (_, original)) in [&root_toc, &section, &group_toc, &inner] |
| 109 | .into_iter() |
| 110 | .zip(&files) |
| 111 | { |
| 112 | assert_ne!(header(unpacked).file_id, header(original).file_id); |
| 113 | } |
| 114 | assert_eq!(header(&root_toc).ancestor, [0; 16]); |
| 115 | assert_eq!(header(&section).ancestor, header(&root_toc).file_id); |
| 116 | assert_eq!(header(&group_toc).ancestor, header(&root_toc).file_id); |
| 117 | assert_eq!(header(&inner).ancestor, header(&group_toc).file_id); |
| 118 | // The recycle bin's entry stays, as in OneNote's packages, naming a folder not there. |
| 119 | let bin = listed(&files[0].1)[2].clone(); |
| 120 | assert_eq!(bin.0, "OneNote_RecycleBin"); |
| 121 | assert_eq!( |
| 122 | listed(&root_toc), |
| 123 | [ |
| 124 | ("New Section 1.one".to_owned(), header(&section).file_id), |
| 125 | ("Group".to_owned(), header(&group_toc).file_id), |
| 126 | bin |
| 127 | ] |
| 128 | ); |
| 129 | assert_eq!( |
| 130 | listed(&group_toc), |
| 131 | [("Inner.one".to_owned(), header(&inner).file_id)] |
| 132 | ); |
| 133 | let pages = notebook::session::stored_pages(&inner).unwrap(); |
| 134 | assert_eq!(pages[0].page.title, "Inside"); |
| 135 | // Placed as they are, nothing is re-identified when the notebook is read again. |
| 136 | drop(opened); |
| 137 | let reopened = Notebook::open(&unpacked, &cache).unwrap(); |
| 138 | assert_eq!( |
| 139 | reopened.catalog().sections[0].file_id, |
| 140 | header(&section).file_id |
| 141 | ); |
| 142 | assert!( |
| 143 | package::unpack(Vec::new(), &unpacked).is_err(), |
| 144 | "the folder is new" |
| 145 | ); |
| 146 | |
| 147 | if let Some(directory) = std::env::var_os("NOTEBOOK_PACKAGE_EXPORT") { |
| 148 | let directory = Path::new(&directory); |
| 149 | std::fs::create_dir(directory).unwrap(); |
| 150 | std::fs::write(directory.join("Packed.onepkg"), &packed).unwrap(); |
| 151 | copy(&unpacked, &directory.join("Unpacked")); |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | fn copy(from: &Path, to: &Path) { |
| 156 | std::fs::create_dir(to).unwrap(); |
| 157 | for entry in std::fs::read_dir(from).unwrap() { |
| 158 | let entry = entry.unwrap(); |
| 159 | if entry.file_type().unwrap().is_dir() { |
| 160 | copy(&entry.path(), &to.join(entry.file_name())); |
| 161 | } else { |
| 162 | std::fs::copy(entry.path(), to.join(entry.file_name())).unwrap(); |
| 163 | } |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | /// OneNote 2010's own package (LZX) unpacks and opens. |
| 168 | #[test] |
| 169 | fn onenotes_package_unpacks() { |
| 170 | let package = Path::new(env!("CARGO_MANIFEST_DIR")) |
| 171 | .join("../../corpus/notebook-package/native/Lab.onepkg"); |
| 172 | let files = package::read(&std::fs::read(package).unwrap(), 1 << 30).unwrap(); |
| 173 | let paths: Vec<&str> = files.iter().map(|(path, _)| path.as_str()).collect(); |
| 174 | assert_eq!(paths, ["Alpha.one", "Beta.one", "Open Notebook.onetoc2"]); |
| 175 | let temporary = tempfile::tempdir().unwrap(); |
| 176 | let root = temporary.path().join("Lab"); |
| 177 | package::unpack(files, &root).unwrap(); |
| 178 | let notebook = Notebook::open(&root, temporary.path().join("cache")).unwrap(); |
| 179 | let sections: Vec<&str> = (notebook.catalog().sections.iter()) |
| 180 | .map(|section| section.path.as_str()) |
| 181 | .collect(); |
| 182 | assert_eq!(sections, ["Alpha.one", "Beta.one"]); |
| 183 | let alpha = |
| 184 | notebook::session::stored_pages(&notebook.read_section("Alpha.one").unwrap()).unwrap(); |
| 185 | let titles: Vec<&str> = alpha.iter().map(|page| page.page.title.as_str()).collect(); |
| 186 | assert_eq!(titles, ["Alpha one", "Alpha three"]); |
| 187 | } |
| 188 | |
| 189 | /// A package naming a file outside its folder, or unpacking past the limit, is refused. |
| 190 | #[test] |
| 191 | fn a_package_stays_in_its_folder() { |
| 192 | let section = onestore::create_empty_section("A.one", None).unwrap(); |
| 193 | for path in ["../A.one", "Group/../../A.one", "C:/A.one", ""] { |
| 194 | let packed = package::pack(vec![(path.to_owned(), section.clone())]).unwrap(); |
| 195 | assert!(package::read(&packed, 1 << 30).is_err(), "{path}"); |
| 196 | } |
| 197 | let packed = package::pack(vec![("A.one".to_owned(), section.clone())]).unwrap(); |
| 198 | assert!(package::read(&packed, 10).is_err()); |
| 199 | } |
| 200 | |
| 201 | /// Save As writes a section as a copy outside any notebook, and a page as a section of its |
| 202 | /// own holding it, as OneNote 2010 does. `NOTEBOOK_SAVE_AS_EXPORT` names a new directory |
| 203 | /// receiving both, for OneNote 2010 to open cold. |
| 204 | #[test] |
| 205 | fn save_as_writes_copies_outside_the_notebook() { |
| 206 | let temporary = tempfile::tempdir().unwrap(); |
| 207 | let notebook = notebook( |
| 208 | &temporary.path().join("Notebook"), |
| 209 | &temporary.path().join("cache"), |
| 210 | ); |
| 211 | let image = notebook.read_section("Group/Inner.one").unwrap(); |
| 212 | let copy = package::section_copy(image.clone()).unwrap(); |
| 213 | assert_ne!(header(&copy).file_id, header(&image).file_id); |
| 214 | assert_eq!(header(&copy).ancestor, [0; 16]); |
| 215 | assert_eq!( |
| 216 | notebook::session::stored_pages(&copy).unwrap()[0] |
| 217 | .page |
| 218 | .title, |
| 219 | "Inside" |
| 220 | ); |
| 221 | |
| 222 | let page = notebook::session::stored_pages(&image) |
| 223 | .unwrap() |
| 224 | .remove(0) |
| 225 | .page; |
| 226 | let saved = package::page_section(&page, "Inside.one", Some(0xe4a88a), "Author").unwrap(); |
| 227 | let pages = notebook::session::stored_pages(&saved).unwrap(); |
| 228 | assert_eq!(pages.len(), 1); |
| 229 | assert_eq!( |
| 230 | (pages[0].page.title.as_str(), pages[0].page.identity), |
| 231 | ("Inside", page.identity) |
| 232 | ); |
| 233 | assert_eq!(header(&saved).ancestor, [0; 16]); |
| 234 | if let Some(directory) = std::env::var_os("NOTEBOOK_SAVE_AS_EXPORT") { |
| 235 | let directory = Path::new(&directory); |
| 236 | std::fs::create_dir(directory).unwrap(); |
| 237 | std::fs::write(directory.join("Inner copy.one"), &copy).unwrap(); |
| 238 | std::fs::write(directory.join("Inside.one"), &saved).unwrap(); |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | /// Empty Recycle Bin deletes Deleted Pages' pages and every binned section's file, leaving the |
| 243 | /// bin's table of contents as it was, as OneNote 2010 does. |
| 244 | #[test] |
| 245 | fn emptying_the_recycle_bin_leaves_its_table_of_contents() { |
| 246 | let temporary = tempfile::tempdir().unwrap(); |
| 247 | let root = temporary.path().join("Notebook"); |
| 248 | let mut notebook = notebook(&root, &temporary.path().join("cache")); |
| 249 | let bin = root.join("OneNote_RecycleBin"); |
| 250 | let toc = std::fs::read(bin.join("Open Notebook.onetoc2")).unwrap(); |
| 251 | assert!(bin.join("Doomed.one").exists()); |
| 252 | notebook.empty_recycle_bin().unwrap(); |
| 253 | assert!(!bin.join("Doomed.one").exists()); |
| 254 | let deleted = std::fs::read(bin.join("OneNote_DeletedPages.one")).unwrap(); |
| 255 | assert!( |
| 256 | notebook::session::stored_pages(&deleted) |
| 257 | .unwrap() |
| 258 | .is_empty() |
| 259 | ); |
| 260 | assert_eq!( |
| 261 | std::fs::read(bin.join("Open Notebook.onetoc2")).unwrap(), |
| 262 | toc |
| 263 | ); |
| 264 | notebook.empty_recycle_bin().unwrap(); |
| 265 | } |