1//! Replicas are kept per notebook location, so copies of a notebook never share one.
2
3use notebook::session::{Notebook, Section};
4use onestore::{
5 ExGuid,
6 op::{Edit, Op, PageOp},
7 page::{Page, PageObject},
8};
9use std::{
10 path::{Path, PathBuf},
11 time::{Duration, Instant},
12};
13
14/// A notebook folder `name` in `directory` holding `First.one` and `Second.one`.
15fn 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.
30fn 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.
39fn 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.
53fn 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
76fn 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
82fn 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.
92fn 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.
103fn 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]
115fn 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]
152fn 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]
171fn 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]
194fn 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]
224fn 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]
236fn 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]
266fn 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]
282fn 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.
315fn 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]
326fn 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]
353fn 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}