| 1 | //! Replicas are kept per notebook location, so copies of a notebook never share one. |
| 2 | |
| 3 | use notebook::session::{Notebook, Section}; |
| 4 | use onestore::{ |
| 5 | ExGuid, |
| 6 | op::{Edit, Op, PageOp}, |
| 7 | page::{Page, PageObject}, |
| 8 | }; |
| 9 | use std::{ |
| 10 | path::{Path, PathBuf}, |
| 11 | time::{Duration, Instant}, |
| 12 | }; |
| 13 | |
| 14 | /// A notebook folder `name` in `directory` holding `First.one` and `Second.one`. |
| 15 | fn notebook(directory: &Path, name: &str) -> PathBuf { |
| 16 | let root = directory.join(name); |
| 17 | std::fs::create_dir(&root).unwrap(); |
| 18 | for section in ["First", "Second"] { |
| 19 | let file = format!("{section}.one"); |
| 20 | std::fs::write( |
| 21 | root.join(&file), |
| 22 | onestore::create_section(&file, section, "Author").unwrap(), |
| 23 | ) |
| 24 | .unwrap(); |
| 25 | } |
| 26 | root |
| 27 | } |
| 28 | |
| 29 | /// Copies the folder `from` to `to`, as Finder or Files duplicates a notebook. |
| 30 | fn duplicate(from: &Path, to: &Path) { |
| 31 | std::fs::create_dir(to).unwrap(); |
| 32 | for entry in std::fs::read_dir(from).unwrap() { |
| 33 | let entry = entry.unwrap(); |
| 34 | std::fs::copy(entry.path(), to.join(entry.file_name())).unwrap(); |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | /// The first text of the section's first page, with its id and the page's space. |
| 39 | fn first(page: &Page) -> (ExGuid, String) { |
| 40 | page.objects |
| 41 | .iter() |
| 42 | .find_map(|object| match object { |
| 43 | PageObject::Outline(outline) => outline.paragraphs.iter().find_map(|paragraph| { |
| 44 | let text = paragraph.text()?; |
| 45 | Some((text.id, text.text.text().to_string())) |
| 46 | }), |
| 47 | _ => None, |
| 48 | }) |
| 49 | .unwrap() |
| 50 | } |
| 51 | |
| 52 | /// Types `text` at the start of the section's first page; the page's space. |
| 53 | fn typed(section: &Section, text: &str) -> ExGuid { |
| 54 | let space = section.pages().unwrap()[0].0; |
| 55 | let (id, _) = first(&section.page(space).unwrap()); |
| 56 | let edit = Edit { |
| 57 | at: (std::time::SystemTime::now() |
| 58 | .duration_since(std::time::UNIX_EPOCH) |
| 59 | .unwrap() |
| 60 | .as_secs() |
| 61 | + 11_644_473_600) |
| 62 | * 10_000_000, |
| 63 | ops: vec![Op::Page { |
| 64 | space, |
| 65 | op: PageOp::Text { |
| 66 | text: id, |
| 67 | range: 0..0, |
| 68 | with: text.into(), |
| 69 | }, |
| 70 | }], |
| 71 | }; |
| 72 | section.replica().apply("Editor", edit).unwrap(); |
| 73 | space |
| 74 | } |
| 75 | |
| 76 | fn stored(file: &Path, space: ExGuid) -> String { |
| 77 | let arena = onestore::Arena::default(); |
| 78 | let section = onestore::Section::open(&arena, onestore::read_file(file).unwrap()).unwrap(); |
| 79 | first(&section.page(space).unwrap()).1 |
| 80 | } |
| 81 | |
| 82 | fn until(what: &str, mut accept: impl FnMut() -> bool) { |
| 83 | let deadline = Instant::now() + Duration::from_secs(20); |
| 84 | while !accept() { |
| 85 | assert!(Instant::now() < deadline, "{what}"); |
| 86 | std::thread::sleep(Duration::from_millis(20)); |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | /// Types `text` into `First.one` of `root` while working offline and closes it, leaving the |
| 91 | /// edit queued in its replica. |
| 92 | fn queued(root: &Path, cache: &Path, text: &str) -> ExGuid { |
| 93 | let notebook = Notebook::open(root, cache).unwrap(); |
| 94 | let section = notebook.section("First.one", || {}).unwrap(); |
| 95 | section.set_offline(true); |
| 96 | let space = typed(&section, text); |
| 97 | assert_eq!(section.pending().unwrap().len(), 1); |
| 98 | section.close().unwrap(); |
| 99 | space |
| 100 | } |
| 101 | |
| 102 | /// Opens `First.one` of `root` and waits for it to publish what it holds. |
| 103 | fn published(root: &Path, cache: &Path) -> usize { |
| 104 | let notebook = Notebook::open(root, cache).unwrap(); |
| 105 | let section = notebook.section("First.one", || {}).unwrap(); |
| 106 | let pending = section.pending().unwrap().len(); |
| 107 | until("the queue published", || { |
| 108 | section.sync_status().unwrap().queued == 0 |
| 109 | }); |
| 110 | section.close().unwrap(); |
| 111 | pending |
| 112 | } |
| 113 | |
| 114 | #[test] |
| 115 | fn copies_of_a_notebook_keep_their_own_replicas_and_publish_only_to_themselves() { |
| 116 | let directory = tempfile::tempdir().unwrap(); |
| 117 | let cache = directory.path().join("cache"); |
| 118 | let original = notebook(directory.path(), "Notebook"); |
| 119 | let copy = directory.path().join("Notebook 2"); |
| 120 | duplicate(&original, &copy); |
| 121 | let (a, b) = ( |
| 122 | Notebook::open(&original, &cache).unwrap(), |
| 123 | Notebook::open(&copy, &cache).unwrap(), |
| 124 | ); |
| 125 | assert_ne!( |
| 126 | a.replica_path("First.one").unwrap(), |
| 127 | b.replica_path("First.one").unwrap() |
| 128 | ); |
| 129 | // Both open at once: neither waits on the other's replica. |
| 130 | let (mine, theirs) = ( |
| 131 | a.section("First.one", || {}).unwrap(), |
| 132 | b.section("First.one", || {}).unwrap(), |
| 133 | ); |
| 134 | mine.set_offline(true); |
| 135 | let space = typed(&mine, "Original "); |
| 136 | theirs.wake(); |
| 137 | until("the copy synced", || { |
| 138 | theirs.sync_status().unwrap().synced.is_some() |
| 139 | }); |
| 140 | assert!(theirs.pending().unwrap().is_empty()); |
| 141 | mine.set_offline(false); |
| 142 | until("the original published", || { |
| 143 | stored(&original.join("First.one"), space).starts_with("Original ") |
| 144 | }); |
| 145 | assert_eq!(stored(&copy.join("First.one"), space), "First"); |
| 146 | assert_eq!(first(&theirs.page(space).unwrap()).1, "First"); |
| 147 | mine.close().unwrap(); |
| 148 | theirs.close().unwrap(); |
| 149 | } |
| 150 | |
| 151 | #[test] |
| 152 | fn a_notebook_the_app_moves_keeps_its_queued_edits() { |
| 153 | let directory = tempfile::tempdir().unwrap(); |
| 154 | let cache = directory.path().join("cache"); |
| 155 | let root = notebook(directory.path(), "Notebook"); |
| 156 | let space = queued(&root, &cache, "Queued "); |
| 157 | let from = notebook::location::local(&root).unwrap(); |
| 158 | let renamed = directory.path().join("Renamed"); |
| 159 | std::fs::rename(&root, &renamed).unwrap(); |
| 160 | // A new notebook where the old one was: its location still exists, so only moving the |
| 161 | // replicas explicitly carries the queue. |
| 162 | notebook(directory.path(), "Notebook"); |
| 163 | let to = notebook::location::local(&renamed).unwrap(); |
| 164 | notebook::location::moved(&cache, &from, &to).unwrap(); |
| 165 | assert_eq!(published(&renamed, &cache), 1); |
| 166 | assert!(stored(&renamed.join("First.one"), space).starts_with("Queued ")); |
| 167 | assert_eq!(published(&root, &cache), 0); |
| 168 | } |
| 169 | |
| 170 | #[test] |
| 171 | fn renaming_a_notebook_folder_keeps_its_queued_edits_and_files() { |
| 172 | let directory = tempfile::tempdir().unwrap(); |
| 173 | let cache = directory.path().join("cache"); |
| 174 | let root = notebook(directory.path(), "Notebook"); |
| 175 | let space = queued(&root, &cache, "Queued "); |
| 176 | let before = std::fs::read(root.join("Second.one")).unwrap(); |
| 177 | let renamed = directory.path().join("Renamed"); |
| 178 | let to = Notebook::open(&root, &cache) |
| 179 | .unwrap() |
| 180 | .rename_folder("Renamed") |
| 181 | .unwrap(); |
| 182 | assert_eq!(to, notebook::location::local(&renamed).unwrap()); |
| 183 | assert!(std::fs::metadata(&root).is_err()); |
| 184 | // OneNote finds nothing inside changed. |
| 185 | assert_eq!(std::fs::read(renamed.join("Second.one")).unwrap(), before); |
| 186 | // A new notebook where the old one was takes nothing of the queue. |
| 187 | notebook(directory.path(), "Notebook"); |
| 188 | assert_eq!(published(&renamed, &cache), 1); |
| 189 | assert!(stored(&renamed.join("First.one"), space).starts_with("Queued ")); |
| 190 | assert_eq!(published(&root, &cache), 0); |
| 191 | } |
| 192 | |
| 193 | #[test] |
| 194 | fn a_notebook_folder_rename_refuses_a_taken_or_unportable_name_and_changes_nothing() { |
| 195 | let directory = tempfile::tempdir().unwrap(); |
| 196 | let cache = directory.path().join("cache"); |
| 197 | let root = notebook(directory.path(), "Notebook"); |
| 198 | notebook(directory.path(), "Taken"); |
| 199 | for (name, kind) in [ |
| 200 | ("Taken", std::io::ErrorKind::AlreadyExists), |
| 201 | ("Notes: 2026", std::io::ErrorKind::InvalidInput), |
| 202 | ("a/b", std::io::ErrorKind::InvalidInput), |
| 203 | ("Trailing.", std::io::ErrorKind::InvalidInput), |
| 204 | ] { |
| 205 | let error = Notebook::open(&root, &cache) |
| 206 | .unwrap() |
| 207 | .rename_folder(name) |
| 208 | .unwrap_err(); |
| 209 | assert!( |
| 210 | matches!(&error, notebook::Error::Io(error) if error.kind() == kind), |
| 211 | "{name}: {error}" |
| 212 | ); |
| 213 | assert!(std::fs::metadata(root.join("First.one")).is_ok()); |
| 214 | } |
| 215 | // A change of case alone renames the folder itself. |
| 216 | let to = Notebook::open(&root, &cache) |
| 217 | .unwrap() |
| 218 | .rename_folder("NOTEBOOK") |
| 219 | .unwrap(); |
| 220 | assert!(to.ends_with("NOTEBOOK")); |
| 221 | } |
| 222 | |
| 223 | #[test] |
| 224 | fn a_notebook_moved_outside_the_app_takes_its_replicas_along() { |
| 225 | let directory = tempfile::tempdir().unwrap(); |
| 226 | let cache = directory.path().join("cache"); |
| 227 | let root = notebook(directory.path(), "Notebook"); |
| 228 | let space = queued(&root, &cache, "Queued "); |
| 229 | let moved = directory.path().join("Moved"); |
| 230 | std::fs::rename(&root, &moved).unwrap(); |
| 231 | assert_eq!(published(&moved, &cache), 1); |
| 232 | assert!(stored(&moved.join("First.one"), space).starts_with("Queued ")); |
| 233 | } |
| 234 | |
| 235 | #[test] |
| 236 | fn a_replica_newer_than_a_surviving_copy_stays_behind() { |
| 237 | let directory = tempfile::tempdir().unwrap(); |
| 238 | let cache = directory.path().join("cache"); |
| 239 | let root = notebook(directory.path(), "Notebook"); |
| 240 | let copy = directory.path().join("Copy"); |
| 241 | duplicate(&root, &copy); |
| 242 | // The original moves on past the copy, then queues an edit and goes away. |
| 243 | { |
| 244 | let notebook = Notebook::open(&root, &cache).unwrap(); |
| 245 | let section = notebook.section("First.one", || {}).unwrap(); |
| 246 | typed(&section, "Published "); |
| 247 | until("the original published", || { |
| 248 | section.sync_status().unwrap().queued == 0 |
| 249 | }); |
| 250 | section.close().unwrap(); |
| 251 | } |
| 252 | queued(&root, &cache, "Queued "); |
| 253 | std::fs::remove_dir_all(&root).unwrap(); |
| 254 | assert_eq!(published(&copy, &cache), 0); |
| 255 | let space = Notebook::open(&copy, &cache) |
| 256 | .unwrap() |
| 257 | .section("First.one", || {}) |
| 258 | .unwrap() |
| 259 | .pages() |
| 260 | .unwrap()[0] |
| 261 | .0; |
| 262 | assert_eq!(stored(&copy.join("First.one"), space), "First"); |
| 263 | } |
| 264 | |
| 265 | #[test] |
| 266 | fn a_replica_named_by_identity_alone_moves_to_the_first_location_that_opens_it() { |
| 267 | let directory = tempfile::tempdir().unwrap(); |
| 268 | let cache = directory.path().join("cache"); |
| 269 | let root = notebook(directory.path(), "Notebook"); |
| 270 | queued(&root, &cache, "Queued "); |
| 271 | let replica = Notebook::open(&root, &cache) |
| 272 | .unwrap() |
| 273 | .replica_path("First.one") |
| 274 | .unwrap(); |
| 275 | let legacy = cache.join(replica.file_name().unwrap()); |
| 276 | std::fs::rename(&replica, &legacy).unwrap(); |
| 277 | assert_eq!(published(&root, &cache), 1); |
| 278 | assert!(!legacy.exists()); |
| 279 | } |
| 280 | |
| 281 | #[test] |
| 282 | fn a_section_copy_beside_its_original_opens_with_its_own_replica() { |
| 283 | let directory = tempfile::tempdir().unwrap(); |
| 284 | let cache = directory.path().join("cache"); |
| 285 | let root = notebook(directory.path(), "Notebook"); |
| 286 | std::fs::copy(root.join("First.one"), root.join("First 2.one")).unwrap(); |
| 287 | let mut notebook = Notebook::open(&root, &cache).unwrap(); |
| 288 | assert_ne!( |
| 289 | notebook.replica_path("First.one").unwrap(), |
| 290 | notebook.replica_path("First 2.one").unwrap() |
| 291 | ); |
| 292 | assert_eq!( |
| 293 | notebook |
| 294 | .replicas() |
| 295 | .iter() |
| 296 | .filter(|known| known.path.starts_with("First")) |
| 297 | .count(), |
| 298 | 2 |
| 299 | ); |
| 300 | let original = notebook.section("First.one", || {}).unwrap(); |
| 301 | let copy = notebook.section("First 2.one", || {}).unwrap(); |
| 302 | original.set_offline(true); |
| 303 | let space = typed(&original, "Mine "); |
| 304 | assert!(copy.pending().unwrap().is_empty()); |
| 305 | original.set_offline(false); |
| 306 | until("the original published", || { |
| 307 | stored(&root.join("First.one"), space).starts_with("Mine ") |
| 308 | }); |
| 309 | assert_eq!(stored(&root.join("First 2.one"), space), "First"); |
| 310 | original.close().unwrap(); |
| 311 | copy.close().unwrap(); |
| 312 | } |
| 313 | |
| 314 | /// Whether the catalog marks the section at `path` a copy. |
| 315 | fn copy(notebook: &Notebook, path: &str) -> bool { |
| 316 | notebook |
| 317 | .catalog() |
| 318 | .sections |
| 319 | .iter() |
| 320 | .find(|section| section.path == path) |
| 321 | .unwrap() |
| 322 | .copy |
| 323 | } |
| 324 | |
| 325 | #[test] |
| 326 | fn renaming_reordering_or_deleting_a_copy_leaves_the_originals_toc_entry() { |
| 327 | let directory = tempfile::tempdir().unwrap(); |
| 328 | let root = notebook(directory.path(), "Notebook"); |
| 329 | // `F.one` would be the original by its shorter path were the TOC not to list `First.one`. |
| 330 | for copy in ["F.one", "First 2.one"] { |
| 331 | std::fs::copy(root.join("First.one"), root.join(copy)).unwrap(); |
| 332 | } |
| 333 | let original = std::fs::read(root.join("First.one")).unwrap(); |
| 334 | let identity = onestore::Store::parse(&original).unwrap().header.file_id; |
| 335 | std::fs::write( |
| 336 | root.join("Open Notebook.onetoc2"), |
| 337 | onestore::create_table_of_contents("Open Notebook.onetoc2", &[("First.one", identity)]) |
| 338 | .unwrap(), |
| 339 | ) |
| 340 | .unwrap(); |
| 341 | let mut notebook = Notebook::open(&root, directory.path().join("cache")).unwrap(); |
| 342 | assert!(!copy(&notebook, "First.one") && copy(&notebook, "F.one")); |
| 343 | notebook.delete("First 2.one").unwrap(); |
| 344 | assert_eq!(notebook.rename("F.one", "G").unwrap(), "G.one"); |
| 345 | notebook.reorder("", &["G.one", "First.one"]).unwrap(); |
| 346 | assert!(!copy(&notebook, "First.one") && copy(&notebook, "G.one")); |
| 347 | // A copy keeps its header as it was, as OneNote leaves one. |
| 348 | assert_eq!(std::fs::read(root.join("G.one")).unwrap(), original); |
| 349 | assert_eq!(std::fs::read(root.join("First.one")).unwrap(), original); |
| 350 | } |
| 351 | |
| 352 | #[test] |
| 353 | fn forgetting_a_deleted_notebook_drops_its_replicas_and_queue_alone() { |
| 354 | let directory = tempfile::tempdir().unwrap(); |
| 355 | let cache = directory.path().join("cache"); |
| 356 | let root = notebook(directory.path(), "Notebook"); |
| 357 | let other = notebook(directory.path(), "Other"); |
| 358 | queued(&root, &cache, "Queued "); |
| 359 | queued(&other, &cache, "Kept "); |
| 360 | let location = notebook::location::local(&root).unwrap(); |
| 361 | std::fs::remove_dir_all(&root).unwrap(); |
| 362 | notebook::location::forget(&cache, &location).unwrap(); |
| 363 | assert!(std::fs::metadata(notebook::location::folder(&cache, &location)).is_err()); |
| 364 | // A notebook made where it was takes nothing of the queue; another keeps its own. |
| 365 | notebook(directory.path(), "Notebook"); |
| 366 | assert_eq!(published(&root, &cache), 0); |
| 367 | assert_eq!(published(&other, &cache), 1); |
| 368 | } |