1//! The application's view of a notebook: sections opened through a local replica that
2//! publishes their edits to the section file in the background.
3
4pub use crate::background::{Background, Known, Listener};
5pub use crate::merge::conflict;
6use crate::{
7 EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover, fs,
8};
9use onestore::{
10 CommitError, ExGuid, PageCreation, PageEdit, RevisionIndex, Stamp, Store, Transaction,
11 document::Document,
12 op::{Edit, Op, SectionOp},
13 page::Page,
14 protected,
15};
16use std::{
17 collections::{BTreeMap, BTreeSet},
18 io,
19 io::Write,
20 path::{Path, PathBuf},
21 sync::{
22 Arc, Mutex,
23 mpsc::{self, Receiver, Sender},
24 },
25 time::Duration,
26};
27
28/// A difference between two catalog reads of a notebook, in catalog paths.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum Change {
31 /// A section or group appeared, including one replacing a file under an existing path.
32 Added(String),
33 /// A section or group is no longer in the notebook.
34 Removed(String),
35 /// The same file now lives at another path: a rename or a move between groups.
36 Moved { from: String, to: String },
37 /// A folder's surviving sections and groups changed order.
38 Reordered(String),
39}
40
41/// Sections and groups are followed by file identity; a group without a TOC only by path.
42#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
43enum Key {
44 File([u8; 16]),
45 Folder(String),
46}
47
48type Entries = (BTreeMap<Key, String>, BTreeMap<String, Vec<Key>>);
49
50fn entries(folder: &discover::Folder) -> Entries {
51 fn walk(folder: &discover::Folder, out: &mut Entries) {
52 let mut order = Vec::new();
53 for section in &folder.sections {
54 let key = Key::File(section.file_id);
55 out.0.insert(key.clone(), section.path.clone());
56 order.push(key);
57 }
58 for group in &folder.groups {
59 let key = match &group.toc {
60 Some(toc) => Key::File(toc.file_id),
61 None => Key::Folder(group.path.clone()),
62 };
63 out.0.insert(key.clone(), group.path.clone());
64 order.push(key);
65 walk(group, out);
66 }
67 out.1.insert(folder.path.clone(), order);
68 }
69 let mut out = Entries::default();
70 walk(folder, &mut out);
71 out
72}
73
74/// Where a notebook's files live: a mounted directory or an SMB share. Paths are catalog
75/// paths, `/`-separated and relative to the notebook root.
76pub trait Storage: Send + Sync {
77 /// Reads the notebook's catalog, reading only the files `cache` holds as listed otherwise.
78 fn discover(
79 &self,
80 cache: &mut discover::Cache,
81 limits: discover::Limits,
82 ) -> Result<discover::Folder>;
83 /// Where the notebook lives, which names its catalog's cache.
84 fn location(&self) -> String;
85 /// A folder's entries, as discovery lists them.
86 fn entries(&self, folder: &str) -> io::Result<Vec<discover::Entry>>;
87 fn exists(&self, path: &str) -> bool;
88 /// A section's or TOC's stamp, without reading its body or coordinating with writers.
89 fn stamp(&self, path: &str) -> io::Result<Stamp>;
90 fn read(&self, path: &str) -> Result<Vec<u8>>;
91 /// Reads a file of at most `limit` bytes as it stands, whatever it holds.
92 fn read_file(&self, path: &str, limit: usize) -> Result<Vec<u8>>;
93 /// Creates a file holding `bytes`; an existing file is an error.
94 fn create(&self, path: &str, bytes: &[u8]) -> Result<()>;
95 fn create_directory(&self, path: &str) -> Result<()>;
96 /// Gives a file or directory the Windows hidden attribute, where the storage keeps one.
97 fn hide(&self, path: &str) -> Result<()>;
98 /// Renames or moves a file or directory; an existing target is an error.
99 fn rename(&self, from: &str, to: &str) -> Result<()>;
100 /// Renames the notebook's own folder to `name` beside it once no other writer holds any
101 /// of `files`; the location it then has.
102 fn rename_root(&self, name: &str, files: &[String]) -> Result<String>;
103 /// Renames a file over another, replacing it.
104 fn replace(&self, from: &str, to: &str) -> Result<()>;
105 /// Deletes a file or an empty directory.
106 fn delete(&self, path: &str) -> Result<()>;
107 /// Names a section or TOC file for its notebook, as `onestore::place`.
108 fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()>;
109 /// Publishes a transaction made on the file's current image.
110 fn commit(&self, path: &str, transaction: &Transaction) -> Result<()>;
111 /// Confirms that the file still has `base`'s stamp and is durable (`onestore::confirm`).
112 fn confirm(&self, path: &str, base: &Stamp) -> std::result::Result<(), CommitError>;
113 /// Puts the file `with` in the place of the section or TOC at `path` under the coordination
114 /// its writers take, provided `path` still has `base`'s stamp.
115 fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()>;
116}
117
118struct Publication {
119 path: String,
120 transaction: Transaction,
121}
122
123impl Publication {
124 async fn commit(self, storage: &dyn Storage) -> Result<()> {
125 match storage.commit(&self.path, &self.transaction) {
126 Err(Error::Remote(error))
127 if error.state == onestore::CommitState::Unknown
128 && error.error.kind() == io::ErrorKind::WouldBlock =>
129 {
130 let expected = self.transaction.stamp();
131 fs::durable().await.map_err(|error| CommitError {
132 state: onestore::CommitState::Unknown,
133 error,
134 })?;
135 storage.confirm(&self.path, &expected)?;
136 Ok(())
137 }
138 result => result,
139 }
140 }
141}
142
143/// A mounted notebook directory.
144struct Directory(PathBuf);
145
146impl Directory {
147 fn path(&self, relative: &str) -> PathBuf {
148 if relative.is_empty() {
149 self.0.clone()
150 } else {
151 self.0.join(relative)
152 }
153 }
154}
155
156impl Storage for Directory {
157 fn discover(
158 &self,
159 cache: &mut discover::Cache,
160 limits: discover::Limits,
161 ) -> Result<discover::Folder> {
162 Ok(cache.discover(&mut discover::Local::open(&self.0)?, limits)?)
163 }
164
165 fn location(&self) -> String {
166 self.0.to_string_lossy().into_owned()
167 }
168
169 fn entries(&self, folder: &str) -> io::Result<Vec<discover::Entry>> {
170 use discover::Source;
171 discover::Local::open(&self.0)?.entries(folder, LIMITS.entries)
172 }
173
174 fn exists(&self, path: &str) -> bool {
175 fs::metadata(self.path(path)).is_ok()
176 }
177
178 fn stamp(&self, path: &str) -> io::Result<Stamp> {
179 FileRemote(self.path(path)).stamp()
180 }
181
182 fn read(&self, path: &str) -> Result<Vec<u8>> {
183 Ok(fs::read_file(self.path(path))?)
184 }
185
186 fn read_file(&self, path: &str, limit: usize) -> Result<Vec<u8>> {
187 use std::io::Read;
188 let mut bytes = Vec::new();
189 fs::File::open(self.path(path))?
190 .take(limit as u64 + 1)
191 .read_to_end(&mut bytes)?;
192 if bytes.len() > limit {
193 return Err(io::Error::from(io::ErrorKind::FileTooLarge).into());
194 }
195 Ok(bytes)
196 }
197
198 fn create(&self, path: &str, bytes: &[u8]) -> Result<()> {
199 let path = self.path(path);
200 if discover::placeholder(&path).is_some_and(|placeholder| fs::metadata(placeholder).is_ok())
201 {
202 return Err(io::Error::from(io::ErrorKind::AlreadyExists).into());
203 }
204 fs::File::create_new(path)?.write_all(bytes)?;
205 Ok(())
206 }
207
208 fn create_directory(&self, path: &str) -> Result<()> {
209 Ok(fs::create_dir(self.path(path))?)
210 }
211
212 /// macOS keeps the attribute as `UF_HIDDEN`, and passes it on to a share it mounted;
213 /// Linux has no attribute to set, a dot folder being hidden there already.
214 #[cfg_attr(windows, allow(unsafe_code))]
215 fn hide(&self, path: &str) -> Result<()> {
216 #[cfg(target_vendor = "apple")]
217 {
218 use nix::sys::stat::{FileFlag, stat};
219 let path = self.path(path);
220 let flags = stat(&path).map_err(io::Error::from)?.st_flags;
221 let flags = FileFlag::from_bits_retain(flags);
222 if !flags.contains(FileFlag::UF_HIDDEN) {
223 nix::unistd::chflags(&path, flags | FileFlag::UF_HIDDEN)
224 .map_err(io::Error::from)?;
225 }
226 }
227 #[cfg(windows)]
228 {
229 use std::os::windows::ffi::OsStrExt;
230 use windows_sys::Win32::Storage::FileSystem::{
231 FILE_ATTRIBUTE_HIDDEN, GetFileAttributesW, INVALID_FILE_ATTRIBUTES,
232 SetFileAttributesW,
233 };
234 let path: Vec<u16> = self
235 .path(path)
236 .as_os_str()
237 .encode_wide()
238 .chain([0])
239 .collect();
240 // SAFETY: `path` is a NUL-terminated UTF-16 string that outlives both calls.
241 let attributes = unsafe { GetFileAttributesW(path.as_ptr()) };
242 if attributes == INVALID_FILE_ATTRIBUTES
243 || unsafe { SetFileAttributesW(path.as_ptr(), attributes | FILE_ATTRIBUTE_HIDDEN) }
244 == 0
245 {
246 return Err(io::Error::last_os_error().into());
247 }
248 }
249 #[cfg(not(any(target_vendor = "apple", windows)))]
250 let _ = path;
251 Ok(())
252 }
253
254 fn rename(&self, from: &str, to: &str) -> Result<()> {
255 Ok(fs::rename(self.path(from), self.path(to))?)
256 }
257
258 /// A local file system shows no other writer's hold; one that refuses to rename a folder
259 /// whose files are open says so as the rename fails.
260 fn rename_root(&self, name: &str, _: &[String]) -> Result<String> {
261 let to = self.0.with_file_name(name);
262 // A change of case alone finds the folder itself on a case-insensitive volume.
263 if fs::metadata(&to).is_ok() && fs::canonicalize(&to)? != self.0 {
264 return Err(io::Error::from(io::ErrorKind::AlreadyExists).into());
265 }
266 fs::rename(&self.0, &to)?;
267 Ok(to.to_string_lossy().into_owned())
268 }
269
270 fn replace(&self, from: &str, to: &str) -> Result<()> {
271 Ok(fs::rename(self.path(from), self.path(to))?)
272 }
273
274 fn delete(&self, path: &str) -> Result<()> {
275 let path = self.path(path);
276 if fs::metadata(&path).is_ok_and(|metadata| metadata.is_dir()) {
277 fs::remove_dir(path)?;
278 } else {
279 fs::remove_file(path)?;
280 }
281 Ok(())
282 }
283
284 fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()> {
285 Ok(fs::place_file(self.path(path), ancestor, name)?)
286 }
287
288 fn commit(&self, path: &str, transaction: &Transaction) -> Result<()> {
289 Ok(fs::commit_file(transaction, self.path(path))?)
290 }
291
292 fn confirm(&self, path: &str, base: &Stamp) -> std::result::Result<(), CommitError> {
293 fs::confirm_file(self.path(path), base)
294 }
295
296 fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()> {
297 Ok(fs::supersede_file(self.path(path), base, self.path(with))?)
298 }
299}
300
301/// A notebook directory on an SMB share, reached through the native-compatible client.
302#[cfg(feature = "smb")]
303pub struct Share {
304 client: Arc<crate::smb::Client>,
305 root: String,
306 /// Where the sections' replicas are.
307 copies: PathBuf,
308}
309
310#[cfg(feature = "smb")]
311impl Share {
312 fn path(&self, relative: &str) -> String {
313 match (self.root.is_empty(), relative.is_empty()) {
314 (_, true) => self.root.clone(),
315 (true, false) => relative.to_owned(),
316 (false, false) => format!("{}/{relative}", self.root),
317 }
318 }
319}
320
321#[cfg(feature = "smb")]
322impl Storage for Share {
323 fn discover(
324 &self,
325 cache: &mut discover::Cache,
326 limits: discover::Limits,
327 ) -> Result<discover::Folder> {
328 let mut source = discover::Smb::new(&self.client, &self.root)?.copies(self.copies.clone());
329 Ok(cache.discover(&mut source, limits)?)
330 }
331
332 fn location(&self) -> String {
333 self.client.location(&self.root)
334 }
335
336 fn entries(&self, folder: &str) -> io::Result<Vec<discover::Entry>> {
337 use discover::Source;
338 discover::Smb::new(&self.client, &self.root)?.entries(folder, LIMITS.entries)
339 }
340
341 fn stamp(&self, path: &str) -> io::Result<Stamp> {
342 self.client.stamp(&self.path(path))
343 }
344
345 fn exists(&self, path: &str) -> bool {
346 let (folder, name) = split(path);
347 self.client
348 .read_dir(&self.path(folder), LIMITS.entries)
349 .is_ok_and(|entries| entries.iter().any(|entry| entry.name == name))
350 }
351
352 fn read(&self, path: &str) -> Result<Vec<u8>> {
353 Ok(self
354 .client
355 .read_storage(&self.path(path), crate::MAX_FILE_BYTES)?)
356 }
357
358 fn read_file(&self, path: &str, limit: usize) -> Result<Vec<u8>> {
359 Ok(self.client.read_asset(&self.path(path), limit)?)
360 }
361
362 fn create(&self, path: &str, bytes: &[u8]) -> Result<()> {
363 Ok(self.client.create(&self.path(path), bytes)?)
364 }
365
366 fn create_directory(&self, path: &str) -> Result<()> {
367 Ok(self.client.create_directory(&self.path(path))?)
368 }
369
370 fn hide(&self, path: &str) -> Result<()> {
371 Ok(self.client.hide(&self.path(path))?)
372 }
373
374 fn rename(&self, from: &str, to: &str) -> Result<()> {
375 Ok(self.client.rename(&self.path(from), &self.path(to))?)
376 }
377
378 fn rename_root(&self, name: &str, files: &[String]) -> Result<String> {
379 if self.root.is_empty() {
380 return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
381 }
382 for file in files {
383 self.client.unheld(&self.path(file))?;
384 }
385 let (parent, _) = split(&self.root);
386 let taken = self
387 .client
388 .read_dir(parent, LIMITS.entries)?
389 .iter()
390 .any(|entry| {
391 entry.name.eq_ignore_ascii_case(name) && entry.name != split(&self.root).1
392 });
393 if taken {
394 return Err(io::Error::from(io::ErrorKind::AlreadyExists).into());
395 }
396 let to = catalog_path(parent, name);
397 self.client.rename(&self.root, &to)?;
398 Ok(self.client.location(&to))
399 }
400
401 fn replace(&self, from: &str, to: &str) -> Result<()> {
402 Ok(self.client.replace(&self.path(from), &self.path(to))?)
403 }
404
405 fn delete(&self, path: &str) -> Result<()> {
406 Ok(self.client.delete(&self.path(path))?)
407 }
408
409 fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()> {
410 Ok(self.client.place(&self.path(path), ancestor, name)?)
411 }
412
413 fn commit(&self, path: &str, transaction: &Transaction) -> Result<()> {
414 Ok(self
415 .client
416 .commit_transaction(&self.path(path), transaction)?)
417 }
418
419 fn confirm(&self, path: &str, base: &Stamp) -> std::result::Result<(), CommitError> {
420 self.client.confirm(&self.path(path), base)
421 }
422
423 fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()> {
424 Ok(self
425 .client
426 .supersede(&self.path(path), base, &self.path(with))?)
427 }
428}
429
430pub(crate) const LIMITS: discover::Limits = discover::Limits {
431 entries: 100_000,
432 bytes_per_file: crate::MAX_FILE_BYTES,
433 depth: 64,
434};
435
436const TOC: &str = "Open Notebook.onetoc2";
437const RECYCLE_BIN: &str = "OneNote_RecycleBin";
438/// How long OneNote 2010 keeps what its recycle bin holds, its `DaysToKeepRecycledItems`.
439pub const RECYCLE_DAYS: u32 = 60;
440
441/// A notebook's files and the cache directory holding its section replicas.
442pub struct Notebook {
443 storage: Box<dyn Storage>,
444 /// The mounted directory, when sections open through local replicas.
445 root: Option<PathBuf>,
446 cache: PathBuf,
447 catalog: discover::Folder,
448 /// What reading the catalog took from each file, kept at `listing` in the cache.
449 read: discover::Cache,
450 listing: PathBuf,
451}
452
453impl Notebook {
454 /// The colour OneNote 2010 gives each new notebook beside its default one, COLORREF
455 /// (`corpus/notebook-management/native/new-notebook`).
456 pub const NEW_COLOR: u32 = 0x00aeba91;
457
458 pub fn open(root: impl AsRef<Path>, cache: impl AsRef<Path>) -> Result<Self> {
459 let root = fs::canonicalize(root)?;
460 Self::with(Box::new(Directory(root.clone())), Some(root), cache)
461 }
462
463 /// Creates a notebook in the new folder `root`, as OneNote 2010 creates one: a table of
464 /// contents in the notebook colour `color` (COLORREF) and "New Section 1" holding the
465 /// page `page` creates.
466 pub fn create(
467 root: impl AsRef<Path>,
468 cache: impl AsRef<Path>,
469 color: u32,
470 page: &PageCreation,
471 ) -> Result<Self> {
472 fs::create_dir(root.as_ref())?;
473 let mut notebook = Self::open(root, cache)?;
474 notebook.edit_toc("", &[onestore::TocEdit::Color(color)])?;
475 notebook.refresh()?;
476 notebook.create_section("", "New Section 1", page)?;
477 Ok(notebook)
478 }
479
480 /// Opens the notebook at `root` on the share `client` is connected to. Sections open
481 /// through `Section::resume_smb` with the catalog's paths.
482 #[cfg(feature = "smb")]
483 pub fn open_smb(
484 client: Arc<crate::smb::Client>,
485 root: &str,
486 cache: impl AsRef<Path>,
487 ) -> Result<Self> {
488 let root = root.replace('\\', "/");
489 let copies = crate::location::folder(cache.as_ref(), &client.location(&root));
490 Self::with(
491 Box::new(Share {
492 client,
493 root,
494 copies,
495 }),
496 None,
497 cache,
498 )
499 }
500
501 /// Opens the notebook a Live Share host serves to `guest`. Sections open through
502 /// `Section::resume_hosted` with the catalog's paths; while the host can't be reached,
503 /// the notebook opens as its folders were last listed.
504 #[cfg(feature = "live")]
505 pub fn open_hosted(
506 guest: Arc<crate::live::share::Guest>,
507 cache: impl AsRef<Path>,
508 ) -> Result<Self> {
509 let listed = listing(cache.as_ref(), &guest.location()).with_extension("entries.json");
510 Self::with(
511 Box::new(crate::live::share::Hosted::new(guest, listed)),
512 None,
513 cache,
514 )
515 }
516
517 /// Opens the notebook whose folder is the root of `documents`. Sections open through
518 /// `Section::resume_provider` with the catalog's paths; while the provider can't be
519 /// reached, the notebook opens as its folders were last listed.
520 pub fn open_provider(documents: Arc<crate::provider::Documents>) -> Result<Self> {
521 let cache = documents.cache.clone();
522 let listed = listing(&cache, &documents.location()).with_extension("entries.json");
523 Self::with(
524 Box::new(crate::provider::Tree { documents, listed }),
525 None,
526 cache,
527 )
528 }
529
530 /// The storage the notebook's files are in, for a Live Share host to serve.
531 pub fn into_storage(self) -> Box<dyn Storage> {
532 self.storage
533 }
534
535 fn with(
536 storage: Box<dyn Storage>,
537 root: Option<PathBuf>,
538 cache: impl AsRef<Path>,
539 ) -> Result<Self> {
540 let cache = cache.as_ref().to_path_buf();
541 let listing = listing(&cache, &storage.location());
542 fs::create_dir_all(listing.parent().unwrap_or(&cache))?;
543 let mut read = fs::read(&listing)
544 .ok()
545 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
546 .unwrap_or_default();
547 let catalog = storage.discover(&mut read, LIMITS)?;
548 let location = storage.location();
549 let mut claims: BTreeMap<String, Vec<([u8; 16], &str)>> = BTreeMap::new();
550 for section in catalog.sections() {
551 let identity = match (&root, &section.state) {
552 (Some(_), discover::SectionState::Readable { document, .. }) => *document,
553 (None, discover::SectionState::Readable { .. }) => section.file_id,
554 _ => continue,
555 };
556 claims
557 .entry(replica_location(&location, section))
558 .or_default()
559 .push((identity, &section.path));
560 }
561 for (at, sections) in &claims {
562 let identities: Vec<[u8; 16]> =
563 sections.iter().map(|(identity, _)| *identity).collect();
564 crate::location::claim(&cache, at, &identities, |identity| {
565 let (_, path) = sections.iter().find(|(held, _)| held == identity)?;
566 Stamp::of(&storage.read(path).ok()?).ok()
567 })?;
568 }
569 let notebook = Self {
570 storage,
571 root,
572 cache,
573 catalog,
574 read,
575 listing,
576 };
577 notebook.keep_listing();
578 notebook.forget_superseded();
579 Ok(notebook)
580 }
581
582 /// Keeps what the catalog read for the next time the notebook opens; failing costs only
583 /// reading the files again.
584 fn keep_listing(&self) {
585 let _ = (|| -> Result<()> {
586 let folder = self.listing.parent().unwrap_or(Path::new("."));
587 let mut file = tempfile::NamedTempFile::new_in(folder)?;
588 serde_json::to_writer(&mut file, &self.read).map_err(io::Error::from)?;
589 file.persist(&self.listing).map_err(|error| error.error)?;
590 Ok(())
591 })();
592 }
593
594 pub fn catalog(&self) -> &discover::Folder {
595 &self.catalog
596 }
597
598 /// The tags the notebook draws with Snowbound's art (`crate::sidecar`); none where it
599 /// maps none.
600 pub fn tag_art(&self) -> Result<Vec<crate::sidecar::TagMapping>> {
601 crate::sidecar::mappings(&*self.storage)
602 }
603
604 /// The secret of the notebook's live presence room, made where it has none.
605 pub fn presence_room(&self) -> Result<[u8; 16]> {
606 crate::sidecar::room(&*self.storage)
607 }
608
609 /// The picture a mapping names, once its bytes match its name.
610 pub fn tag_art_file(&self, art: &str) -> Result<Vec<u8>> {
611 crate::sidecar::art(&*self.storage, art)
612 }
613
614 /// Maps tag `name` with symbol `shape` to picture `bytes`, a PNG or SVG as `extension`
615 /// says, making the notebook's hidden `.snowbound` folder where it has none. Returns the
616 /// notebook's mappings as they then stand.
617 pub fn map_tag_art(
618 &self,
619 name: &str,
620 shape: u16,
621 bytes: &[u8],
622 extension: &str,
623 ) -> Result<Vec<crate::sidecar::TagMapping>> {
624 crate::sidecar::map(&*self.storage, name, shape, bytes, extension)
625 }
626
627 /// The notebook's style themes and which theme each scope takes (`sidecar::themes`).
628 pub fn themes(&self) -> Result<crate::sidecar::themes::Themes> {
629 crate::sidecar::themes::read(&*self.storage)
630 }
631
632 /// Merges `change` into the notebook's themes, making its hidden `.snowbound` folder
633 /// where it has none; returns the themes as they then stand.
634 pub fn save_themes(
635 &self,
636 change: crate::sidecar::themes::Themes,
637 ) -> Result<crate::sidecar::themes::Themes> {
638 crate::sidecar::themes::write(&*self.storage, change)
639 }
640
641 /// Rereads the notebook and reports what changed since the last catalog, keyed by
642 /// file identity so a renamed or moved section stays the same section. A failed read
643 /// keeps the previous catalog: an unreachable notebook is not an empty one.
644 pub fn refresh(&mut self) -> Result<Vec<Change>> {
645 let catalog = self.storage.discover(&mut self.read, LIMITS)?;
646 self.keep_listing();
647 let (before, before_orders) = entries(&self.catalog);
648 let (after, after_orders) = entries(&catalog);
649 let mut changes = Vec::new();
650 for key in before_orders.values().flatten() {
651 let from = &before[key];
652 match after.get(key) {
653 None => changes.push(Change::Removed(from.clone())),
654 Some(to) if to != from => changes.push(Change::Moved {
655 from: from.clone(),
656 to: to.clone(),
657 }),
658 Some(_) => {}
659 }
660 }
661 for key in after_orders.values().flatten() {
662 if !before.contains_key(key) {
663 changes.push(Change::Added(after[key].clone()));
664 }
665 }
666 for (folder, order) in &after_orders {
667 let Some(previous) = before_orders.get(folder) else {
668 continue;
669 };
670 let kept = |sequence: &[Key], other: &[Key]| -> Vec<Key> {
671 sequence
672 .iter()
673 .filter(|key| other.contains(key))
674 .cloned()
675 .collect()
676 };
677 if kept(previous, order) != kept(order, previous) {
678 changes.push(Change::Reordered(folder.clone()));
679 }
680 }
681 self.catalog = catalog;
682 self.forget_superseded();
683 Ok(changes)
684 }
685
686 fn folder(&self, path: &str) -> Result<&discover::Folder> {
687 self.catalog
688 .folders()
689 .find(|folder| folder.path == path)
690 .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound).into())
691 }
692
693 /// A catalog section's path, refusing paths the catalog does not list.
694 fn section_path(&self, path: &str) -> Result<&discover::Section> {
695 self.catalog
696 .sections()
697 .find(|section| section.path == path)
698 .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound).into())
699 }
700
701 /// A folder's TOC path and file identity, creating the TOC when the folder has none
702 /// (OneNote names it `Open Notebook.onetoc2`).
703 fn toc(&self, folder: &str) -> Result<(String, [u8; 16])> {
704 match &self.folder(folder)?.toc {
705 Some(toc) => Ok((catalog_path(folder, &toc.filename), toc.file_id)),
706 None => {
707 let path = catalog_path(folder, TOC);
708 // One this change already created, before the catalog was read again.
709 let bytes = if self.storage.exists(&path) {
710 self.storage.read(&path)?
711 } else {
712 let bytes = onestore::create_table_of_contents(TOC, &[])?;
713 self.storage.create(&path, &bytes)?;
714 bytes
715 };
716 Ok((path, onestore::Store::parse(&bytes)?.header.file_id))
717 }
718 }
719 }
720
721 fn edit_toc(&self, folder: &str, edits: &[onestore::TocEdit]) -> Result<()> {
722 let (toc, _) = self.toc(folder)?;
723 self.commit_toc(&toc, edits)
724 }
725
726 /// Commits `edits` to the TOC at `toc`. A name it still lists for a file gone from its
727 /// folder passes to the file an edit gives that name; the stale entry goes.
728 fn commit_toc(&self, toc: &str, edits: &[onestore::TocEdit]) -> Result<()> {
729 match self.toc_publication(toc, edits)? {
730 Some(commit) => self.storage.commit(&commit.path, &commit.transaction),
731 None => Ok(()),
732 }
733 }
734
735 fn toc_publication(
736 &self,
737 toc: &str,
738 edits: &[onestore::TocEdit],
739 ) -> Result<Option<Publication>> {
740 let unresolved = self
741 .folder(split(toc).0)
742 .ok()
743 .and_then(|folder| folder.toc.as_ref())
744 .map_or(&[][..], |toc| &toc.unresolved[..]);
745 let mut superseded = Vec::new();
746 for edit in edits {
747 if let onestore::TocEdit::Add { filename, .. }
748 | onestore::TocEdit::Rename { filename, .. } = edit
749 {
750 superseded.extend(
751 unresolved
752 .iter()
753 .filter(|entry| {
754 entry
755 .filename
756 .as_deref()
757 .is_some_and(|name| name.eq_ignore_ascii_case(filename))
758 })
759 .map(|entry| onestore::TocEdit::Remove {
760 identity: entry.file,
761 }),
762 );
763 }
764 superseded.push(edit.clone());
765 }
766 let source = self.storage.read(toc)?;
767 Ok(
768 onestore::edit_table_of_contents(&source, &superseded)?.map(|transaction| {
769 Publication {
770 path: toc.into(),
771 transaction,
772 }
773 }),
774 )
775 }
776
777 /// Creates `name.one` in `folder` holding the page `page` creates, in the next of
778 /// OneNote's section colours, and lists it last in the folder's TOC, as OneNote creates
779 /// a section. Returns the new catalog path.
780 pub fn create_section(
781 &mut self,
782 folder: &str,
783 name: &str,
784 page: &PageCreation,
785 ) -> Result<String> {
786 let filename = format!("{name}.one");
787 let color = next_color(self.folder(folder)?);
788 let (_, ancestor) = self.toc(folder)?;
789 let mut bytes = onestore::create_empty_section(&filename, Some(color))?;
790 let transaction = {
791 let arena = onestore::Arena::default();
792 let mut section = onestore::Section::open(&arena, bytes.clone())?;
793 let edit = Edit {
794 at: crate::now(),
795 ops: vec![Op::Section(SectionOp::Create(page.clone()))],
796 };
797 section.apply(page.author(), &edit)?;
798 section.seal()?
799 };
800 if let Some(transaction) = transaction {
801 transaction.apply(&mut bytes)?;
802 }
803 let path = catalog_path(folder, &filename);
804 self.storage.create(&path, &bytes)?;
805 self.storage.place(&path, ancestor, &filename)?;
806 let identity = onestore::Store::parse(&bytes)?.header.file_id;
807 self.edit_toc(
808 folder,
809 &[onestore::TocEdit::Add {
810 filename: filename.clone(),
811 identity,
812 group: false,
813 }],
814 )?;
815 self.refresh()?;
816 Ok(path)
817 }
818
819 /// Creates a section group: a folder with its own TOC, listed last in the parent's TOC.
820 pub fn create_group(&mut self, folder: &str, name: &str) -> Result<String> {
821 self.folder(folder)?;
822 if !component(name) {
823 return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
824 }
825 let (_, ancestor) = self.toc(folder)?;
826 let group = catalog_path(folder, name);
827 self.storage.create_directory(&group)?;
828 let bytes = onestore::create_table_of_contents(TOC, &[])?;
829 let path = catalog_path(&group, TOC);
830 self.storage.create(&path, &bytes)?;
831 self.storage.place(&path, ancestor, name)?;
832 let identity = onestore::Store::parse(&bytes)?.header.file_id;
833 self.edit_toc(
834 folder,
835 &[onestore::TocEdit::Add {
836 filename: name.to_owned(),
837 identity,
838 group: true,
839 }],
840 )?;
841 self.refresh()?;
842 Ok(group)
843 }
844
845 /// Renames a section or section group: the file or folder and its TOC entry.
846 pub fn rename(&mut self, path: &str, name: &str) -> Result<String> {
847 let (folder, entry) = split(path);
848 let Entry {
849 filename,
850 identity,
851 copy,
852 } = self.entry(folder, entry)?;
853 let target = if filename.to_ascii_lowercase().ends_with(".one") {
854 format!("{name}.one")
855 } else {
856 name.to_owned()
857 };
858 let renamed = catalog_path(folder, &target);
859 if !component(&target) || self.storage.exists(&renamed) {
860 return Err(io::Error::from(io::ErrorKind::AlreadyExists).into());
861 }
862 self.storage
863 .rename(&catalog_path(folder, &filename), &renamed)?;
864 if !copy {
865 let (_, ancestor) = self.toc(folder)?;
866 let placed = if target.ends_with(".one") {
867 renamed.clone()
868 } else {
869 catalog_path(&renamed, TOC)
870 };
871 self.storage.place(&placed, ancestor, &target)?;
872 }
873 if let Some(identity) = identity {
874 let (toc, _) = self.toc(folder)?;
875 let edit = if lists(&self.storage.read(&toc)?, identity)? {
876 onestore::TocEdit::Rename {
877 identity,
878 filename: target,
879 }
880 } else {
881 onestore::TocEdit::Add {
882 identity,
883 filename: target,
884 group: !filename.to_ascii_lowercase().ends_with(".one"),
885 }
886 };
887 self.commit_toc(&toc, &[edit])?;
888 }
889 self.refresh()?;
890 Ok(renamed)
891 }
892
893 /// Renames the notebook's folder to `name`, as OneNote 2010 finds a notebook folder renamed
894 /// outside it: no file inside changes, and it opens the folder again by its new name. Refused,
895 /// `WouldBlock`, while another writer holds one of its sections or tables of contents, and
896 /// `AlreadyExists` where `name` is taken. The replicas and the catalog's listing follow, so
897 /// edits waiting publish to the renamed folder; every replica must be closed. Returns the
898 /// notebook's new location, as `crate::location` names it.
899 pub fn rename_folder(mut self, name: &str) -> Result<String> {
900 if !folder_name(name) {
901 return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
902 }
903 self.replicas_closed()?;
904 let from = self.storage.location();
905 let files: Vec<String> = (self.catalog.folders())
906 .flat_map(|folder| {
907 let toc = folder.toc.as_ref().map(|toc| &toc.filename);
908 (toc.map(|toc| catalog_path(&folder.path, toc)).into_iter())
909 .chain(folder.sections.iter().map(|section| section.path.clone()))
910 })
911 .collect();
912 let to = self.storage.rename_root(name, &files)?;
913 let mut moves = vec![(from.clone(), to.clone())];
914 moves.extend(
915 (self.catalog.sections())
916 .filter(|section| section.copy)
917 .map(|section| {
918 (
919 replica_location(&from, section),
920 replica_location(&to, section),
921 )
922 }),
923 );
924 for (from, to) in moves {
925 crate::location::moved(&self.cache, &from, &to)?;
926 }
927 let _ = fs::remove_file(&self.listing);
928 self.listing = listing(&self.cache, &to);
929 self.keep_listing();
930 Ok(to)
931 }
932
933 /// Waits for browser cache receipts before moving the notebook and its replicas.
934 pub async fn rename_folder_async(self, name: &str) -> Result<String> {
935 #[cfg(target_arch = "wasm32")]
936 {
937 let deadline = web_time::Instant::now() + Duration::from_secs(30);
938 let (_, retry) = crate::task::channel();
939 loop {
940 match self.replicas_closed() {
941 Err(error) if error.busy() && web_time::Instant::now() < deadline => {
942 crate::task::wait(&retry, Some(Duration::from_millis(50))).await;
943 }
944 result => {
945 result?;
946 break;
947 }
948 }
949 }
950 }
951 self.rename_folder(name)
952 }
953
954 fn replicas_closed(&self) -> Result<()> {
955 let from = self.storage.location();
956 let locations = std::iter::once(from.clone()).chain(
957 self.catalog
958 .sections()
959 .filter(|section| section.copy)
960 .map(|section| replica_location(&from, section)),
961 );
962 for location in locations {
963 let entries = match fs::read_dir(crate::location::folder(&self.cache, &location)) {
964 Ok(entries) => entries,
965 Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
966 Err(error) => return Err(error.into()),
967 };
968 for entry in entries {
969 let path = entry?.path();
970 if path.extension() == Some("sqlite".as_ref()) {
971 drop(crate::closed(&path)?);
972 }
973 }
974 }
975 Ok(())
976 }
977
978 /// Sets a section's colour (COLORREF) in its own metadata, where OneNote keeps it.
979 pub fn set_section_color(&mut self, path: &str, color: Option<u32>) -> Result<()> {
980 let path = self.section_path(path)?.path.clone();
981 let arena = onestore::Arena::default();
982 let mut section = onestore::Section::open(&arena, self.storage.read(&path)?)?;
983 let edit = Edit {
984 at: crate::now(),
985 ops: vec![Op::Section(SectionOp::Color(color))],
986 };
987 section.apply("", &edit)?;
988 if let Some(transaction) = section.seal()? {
989 self.storage.commit(&path, &transaction)?;
990 }
991 self.refresh()?;
992 Ok(())
993 }
994
995 /// Sets the notebook's colour (COLORREF) in its root table of contents, as OneNote 2010's
996 /// Notebook Properties does (`corpus/section-color`).
997 pub fn set_color(&mut self, color: u32) -> Result<()> {
998 self.edit_toc("", &[onestore::TocEdit::Color(color)])?;
999 self.refresh().map(drop)
1000 }
1001
1002 /// Orders a folder's sections and groups; entries left out follow in their current order.
1003 pub fn reorder(&mut self, folder: &str, paths: &[&str]) -> Result<()> {
1004 let mut identities = Vec::new();
1005 for path in paths {
1006 let (parent, entry) = split(path);
1007 if parent != folder {
1008 return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
1009 }
1010 identities.extend(self.entry(folder, entry)?.identity);
1011 }
1012 self.edit_toc(folder, &[onestore::TocEdit::Order(identities)])?;
1013 self.refresh().map(drop)
1014 }
1015
1016 /// Deletes a section or section group the way OneNote 2010 does. A section's file moves
1017 /// into the notebook's `OneNote_RecycleBin` folder, a section group with its own TOC that
1018 /// lists it (and that the root TOC lists). A group's sections, in its groups too, move
1019 /// there the same way, then its folders go. Either way its folder's TOC entry goes.
1020 pub fn delete(&mut self, path: &str) -> Result<()> {
1021 let (folder, entry) = split(path);
1022 let deleted = self.entry(folder, entry)?;
1023 if path == RECYCLE_BIN {
1024 return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
1025 }
1026 if deleted.filename.to_ascii_lowercase().ends_with(".one") {
1027 self.bin_section(path, &deleted)?;
1028 } else {
1029 // Sections first, then each folder once it is empty, deepest first.
1030 let mut sections = Vec::new();
1031 let mut folders = Vec::new();
1032 let mut pending = vec![self.folder(path)?];
1033 while let Some(group) = pending.pop() {
1034 // Its folder could not be emptied, so nothing moves.
1035 if let Some(entry) = group.unavailable.first() {
1036 return Err(io::Error::new(
1037 io::ErrorKind::PermissionDenied,
1038 entry.error.clone(),
1039 )
1040 .into());
1041 }
1042 sections.extend(
1043 group
1044 .sections
1045 .iter()
1046 .map(|section| (section.path.clone(), Entry::of(group, section))),
1047 );
1048 folders.push((
1049 group.path.clone(),
1050 group.toc.as_ref().map(|toc| toc.filename.clone()),
1051 ));
1052 pending.extend(&group.groups);
1053 }
1054 for (section, entry) in sections {
1055 self.bin_section(&section, &entry)?;
1056 }
1057 for (group, toc) in folders.into_iter().rev() {
1058 if let Some(toc) = toc {
1059 self.storage.delete(&catalog_path(&group, &toc))?;
1060 }
1061 self.storage.delete(&group)?;
1062 }
1063 }
1064 if let Some(identity) = deleted.identity {
1065 self.edit_toc(folder, &[onestore::TocEdit::Remove { identity }])?;
1066 }
1067 self.refresh().map(drop)
1068 }
1069
1070 /// The recycle bin's TOC path and file identity, creating the bin as OneNote does: a
1071 /// section group the root TOC lists. A bin the root TOC misses is listed again.
1072 fn bin(&self) -> Result<(String, [u8; 16])> {
1073 let (toc, identity, publication) = self.prepare_bin()?;
1074 if let Some(commit) = publication {
1075 self.storage.commit(&commit.path, &commit.transaction)?;
1076 }
1077 Ok((toc, identity))
1078 }
1079
1080 fn prepare_bin(&self) -> Result<(String, [u8; 16], Option<Publication>)> {
1081 if let Some(entry) = self
1082 .catalog
1083 .unavailable
1084 .iter()
1085 .find(|entry| entry.path == RECYCLE_BIN)
1086 {
1087 return Err(
1088 io::Error::new(io::ErrorKind::PermissionDenied, entry.error.clone()).into(),
1089 );
1090 }
1091 // A bin OneNote made may name its TOC otherwise; a second TOC would hide the folder.
1092 let bin_toc = match self.folder(RECYCLE_BIN) {
1093 Ok(discover::Folder { toc: Some(toc), .. }) => catalog_path(RECYCLE_BIN, &toc.filename),
1094 _ => {
1095 let bin_toc = catalog_path(RECYCLE_BIN, TOC);
1096 if !self.storage.exists(&bin_toc) {
1097 if !self.storage.exists(RECYCLE_BIN) {
1098 self.storage.create_directory(RECYCLE_BIN)?;
1099 }
1100 let bytes = onestore::create_table_of_contents(TOC, &[])?;
1101 self.storage.create(&bin_toc, &bytes)?;
1102 }
1103 bin_toc
1104 }
1105 };
1106 let identity = onestore::Store::parse(&self.storage.read(&bin_toc)?)?
1107 .header
1108 .file_id;
1109 let (root, root_identity) = self.toc("")?;
1110 // OneNote takes a TOC placed under another parent for a new one.
1111 let publication = if !lists(&self.storage.read(&root)?, identity)? {
1112 self.storage.place(&bin_toc, root_identity, RECYCLE_BIN)?;
1113 self.toc_publication(
1114 &root,
1115 &[onestore::TocEdit::Add {
1116 filename: RECYCLE_BIN.into(),
1117 identity,
1118 group: true,
1119 }],
1120 )?
1121 } else {
1122 None
1123 };
1124 Ok((bin_toc, identity, publication))
1125 }
1126
1127 /// Moves the section file at `path`, its folder's `entry`, into the recycle bin, under a
1128 /// name no binned section has, and lists it there; its own folder's TOC is the caller's.
1129 fn bin_section(&self, path: &str, entry: &Entry) -> Result<()> {
1130 let filename = &entry.filename;
1131 let (bin_toc, bin_identity) = self.bin()?;
1132 let mut target = filename.to_owned();
1133 let mut attempt = 1;
1134 while self.storage.exists(&catalog_path(RECYCLE_BIN, &target)) {
1135 attempt += 1;
1136 let (stem, extension) = filename.rsplit_once('.').unwrap_or((filename, ""));
1137 target = format!("{stem} ({attempt}).{extension}");
1138 }
1139 let binned = catalog_path(RECYCLE_BIN, &target);
1140 self.storage.rename(path, &binned)?;
1141 let (false, Some(identity)) = (entry.copy, entry.identity) else {
1142 return Ok(());
1143 };
1144 self.storage.place(&binned, bin_identity, &target)?;
1145 self.commit_toc(
1146 &bin_toc,
1147 &[onestore::TocEdit::Add {
1148 filename: target,
1149 identity,
1150 group: false,
1151 }],
1152 )
1153 }
1154
1155 /// Keeps copies of `pages` in the notebook's recycle bin, as OneNote 2010 does with a
1156 /// page it deletes: in `OneNote_RecycleBin/OneNote_DeletedPages.one`, each at the top
1157 /// level with its identity, title, date and creation time. The caller then deletes the
1158 /// pages from their section.
1159 pub fn recycle_pages(&mut self, pages: &[Page], author: &str) -> Result<()> {
1160 crate::task::blocking(self.recycle_pages_async(pages, author))?
1161 }
1162
1163 /// Recycles pages after the browser host acknowledges each destination append.
1164 pub async fn recycle_pages_async(&mut self, pages: &[Page], author: &str) -> Result<()> {
1165 const DELETED: &str = "OneNote_DeletedPages.one";
1166 // A rejected host receipt leaves its append queued ahead of this retry.
1167 fs::durable().await?;
1168 let (bin_toc, bin_identity, publication) = self.prepare_bin()?;
1169 if let Some(commit) = publication {
1170 commit.commit(self.storage.as_ref()).await?;
1171 }
1172 let path = catalog_path(RECYCLE_BIN, DELETED);
1173 if !self.storage.exists(&path) {
1174 // OneNote's "Deleted Pages" section is grey.
1175 let bytes = onestore::create_empty_section(DELETED, Some(0x00e1e1e1))?;
1176 self.storage.create(&path, &bytes)?;
1177 }
1178 let mut bytes = self.storage.read(&path)?;
1179 // Listed and placed too when an earlier delete made the file but not its entry.
1180 let identity = onestore::Store::parse(&bytes)?.header.file_id;
1181 if !lists(&self.storage.read(&bin_toc)?, identity)? {
1182 self.storage.place(&path, bin_identity, DELETED)?;
1183 if let Some(commit) = self.toc_publication(
1184 &bin_toc,
1185 &[onestore::TocEdit::Add {
1186 filename: DELETED.into(),
1187 identity,
1188 group: false,
1189 }],
1190 )? {
1191 commit.commit(self.storage.as_ref()).await?;
1192 }
1193 bytes = self.storage.read(&path)?;
1194 }
1195 let transaction = {
1196 let arena = onestore::Arena::default();
1197 let mut section = onestore::Section::open(&arena, bytes)?;
1198 let mut ops = Vec::new();
1199 for page in pages {
1200 ops.push(moved(page, author)?);
1201 }
1202 section.apply(
1203 author,
1204 &Edit {
1205 at: crate::now(),
1206 ops,
1207 },
1208 )?;
1209 section.seal()?
1210 };
1211 if let Some(transaction) = transaction {
1212 Publication { path, transaction }
1213 .commit(self.storage.as_ref())
1214 .await?;
1215 }
1216 fs::durable().await?;
1217 self.refresh().map(drop)
1218 }
1219
1220 /// Takes the pages `identities` names out of the recycle bin's Deleted Pages in one
1221 /// revision, as OneNote 2010 does when Undo brings deleted pages back. Pages not there
1222 /// are passed over.
1223 pub fn unrecycle_pages(&mut self, identities: &[[u8; 16]]) -> Result<()> {
1224 crate::task::blocking(self.unrecycle_pages_async(identities))?
1225 }
1226
1227 /// Removes recycled pages after the browser host acknowledges their deletion append.
1228 pub async fn unrecycle_pages_async(&mut self, identities: &[[u8; 16]]) -> Result<()> {
1229 fs::durable().await?;
1230 let path = catalog_path(RECYCLE_BIN, "OneNote_DeletedPages.one");
1231 if !self.storage.exists(&path) {
1232 return Ok(());
1233 }
1234 let bytes = self.storage.read(&path)?;
1235 let binned: Vec<ExGuid> = stored_pages(&bytes)?
1236 .into_iter()
1237 .filter(|stored| {
1238 stored
1239 .page
1240 .identity
1241 .is_some_and(|id| identities.contains(&id))
1242 })
1243 .map(|stored| stored.space)
1244 .collect();
1245 if binned.is_empty() {
1246 return Ok(());
1247 }
1248 let transaction = {
1249 let arena = onestore::Arena::default();
1250 let mut section = onestore::Section::open(&arena, bytes)?;
1251 section.apply(
1252 "",
1253 &Edit {
1254 at: crate::now(),
1255 ops: vec![Op::Section(SectionOp::Delete(binned))],
1256 },
1257 )?;
1258 section.seal()?
1259 };
1260 if let Some(transaction) = transaction {
1261 Publication { path, transaction }
1262 .commit(self.storage.as_ref())
1263 .await?;
1264 }
1265 fs::durable().await?;
1266 self.refresh().map(drop)
1267 }
1268
1269 /// Deletes for good what OneNote 2010 prunes from the recycle bin at `now`, Time32 seconds
1270 /// since 1980: each page of Deleted Pages last changed over `RECYCLE_DAYS` before, in one
1271 /// revision, and each binned section none of whose pages changed since
1272 /// (`corpus/recycle-purge`). OneNote judges by the pages' own last change, not when they
1273 /// were deleted, and leaves a pruned section's entry in the bin's TOC, as this does.
1274 /// Returns how many pages and sections went.
1275 pub fn purge_recycle_bin(&mut self, now: u32) -> Result<usize> {
1276 const DELETED: &str = "OneNote_DeletedPages.one";
1277 let now = (u64::from(now) + 11_960_006_400) * 10_000_000;
1278 let expired = |modified: Option<u64>| {
1279 modified.is_some_and(|modified| {
1280 now.saturating_sub(modified) > u64::from(RECYCLE_DAYS) * 86_400 * 10_000_000
1281 })
1282 };
1283 let Ok(bin) = self.folder(RECYCLE_BIN) else {
1284 return Ok(0);
1285 };
1286 let sections: Vec<String> = bin
1287 .sections
1288 .iter()
1289 .map(|section| section.path.clone())
1290 .collect();
1291 let mut purged = 0;
1292 for path in sections {
1293 let bytes = self.storage.read(&path)?;
1294 // A protected section, or one the model cannot read, stays.
1295 let Ok(pages) = stored_pages(&bytes) else {
1296 continue;
1297 };
1298 if split(&path).1.eq_ignore_ascii_case(DELETED) {
1299 let old: Vec<ExGuid> = pages
1300 .iter()
1301 .filter(|page| expired(page.modified))
1302 .map(|page| page.space)
1303 .collect();
1304 if old.is_empty() {
1305 continue;
1306 }
1307 let arena = onestore::Arena::default();
1308 let mut section = onestore::Section::open(&arena, bytes)?;
1309 section.apply(
1310 "",
1311 &Edit {
1312 at: crate::now(),
1313 ops: vec![Op::Section(SectionOp::Delete(old.clone()))],
1314 },
1315 )?;
1316 if let Some(transaction) = section.seal()? {
1317 self.storage.commit(&path, &transaction)?;
1318 }
1319 purged += old.len();
1320 } else if !pages.is_empty() && pages.iter().all(|page| expired(page.modified)) {
1321 self.storage.delete(&path)?;
1322 purged += 1;
1323 }
1324 }
1325 if purged > 0 {
1326 self.refresh()?;
1327 }
1328 Ok(purged)
1329 }
1330
1331 /// Empties the recycle bin as OneNote 2010's Empty Recycle Bin does: every page of
1332 /// Deleted Pages goes in one revision, and every binned section's file goes, its entry
1333 /// left in the bin's TOC (`corpus/recycle-bin-view`).
1334 pub fn empty_recycle_bin(&mut self) -> Result<()> {
1335 let Ok(bin) = self.folder(RECYCLE_BIN) else {
1336 return Ok(());
1337 };
1338 let sections: Vec<String> = (bin.sections.iter())
1339 .filter(|section| !section.copy)
1340 .map(|section| section.path.clone())
1341 .collect();
1342 for path in sections {
1343 if !split(&path)
1344 .1
1345 .eq_ignore_ascii_case("OneNote_DeletedPages.one")
1346 {
1347 self.storage.delete(&path)?;
1348 continue;
1349 }
1350 let arena = onestore::Arena::default();
1351 let mut section = onestore::Section::open(&arena, self.storage.read(&path)?)?;
1352 let pages: Vec<ExGuid> = section
1353 .pages()?
1354 .into_iter()
1355 .map(|(space, ..)| space)
1356 .collect();
1357 if pages.is_empty() {
1358 continue;
1359 }
1360 section.apply(
1361 "",
1362 &Edit {
1363 at: crate::now(),
1364 ops: vec![Op::Section(SectionOp::Delete(pages))],
1365 },
1366 )?;
1367 if let Some(transaction) = section.seal()? {
1368 self.storage.commit(&path, &transaction)?;
1369 }
1370 }
1371 self.refresh().map(drop)
1372 }
1373
1374 /// The table of contents of the folder at catalog path `folder` as its file stores it.
1375 pub fn read_toc(&self, folder: &str) -> Result<Vec<u8>> {
1376 let toc = self.folder(folder)?.toc.as_ref();
1377 let toc = toc.ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?;
1378 self.storage.read(&catalog_path(folder, &toc.filename))
1379 }
1380
1381 /// Moves a section or section group into the folder at catalog path `folder`, last, as
1382 /// OneNote moves one dragged onto a group: the file or folder moves and each TOC's
1383 /// entry follows it. Returns its new catalog path.
1384 pub fn move_entry(&mut self, path: &str, folder: &str) -> Result<String> {
1385 let (from, entry) = split(path);
1386 let Entry {
1387 filename,
1388 identity,
1389 copy,
1390 } = self.entry(from, entry)?;
1391 self.folder(folder)?;
1392 let group = !filename.to_ascii_lowercase().ends_with(".one");
1393 let target = catalog_path(folder, &filename);
1394 if from == folder
1395 || path == RECYCLE_BIN
1396 || group && (folder == path || folder.starts_with(&format!("{path}/")))
1397 || self.storage.exists(&target)
1398 {
1399 return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
1400 }
1401 let registered = if let (Some(identity), Some(toc)) = (identity, &self.folder(from)?.toc) {
1402 lists(
1403 &self.storage.read(&catalog_path(from, &toc.filename))?,
1404 identity,
1405 )?
1406 .then_some(identity)
1407 } else {
1408 None
1409 };
1410 let (_, ancestor) = self.toc(folder)?;
1411 self.storage.rename(path, &target)?;
1412 if !copy {
1413 let placed = if group {
1414 catalog_path(&target, TOC)
1415 } else {
1416 target.clone()
1417 };
1418 self.storage.place(&placed, ancestor, &filename)?;
1419 if let Some(identity) = identity {
1420 self.edit_toc(
1421 folder,
1422 &[onestore::TocEdit::Add {
1423 filename,
1424 identity,
1425 group,
1426 }],
1427 )?;
1428 }
1429 }
1430 if let Some(identity) = registered {
1431 self.edit_toc(from, &[onestore::TocEdit::Remove { identity }])?;
1432 }
1433 self.refresh()?;
1434 Ok(target)
1435 }
1436
1437 /// The stored filename and TOC identity of a folder's section or group.
1438 fn entry(&self, folder: &str, name: &str) -> Result<Entry> {
1439 let parent = self.folder(folder)?;
1440 if let Some(section) = parent
1441 .sections
1442 .iter()
1443 .find(|section| split(&section.path).1 == name)
1444 {
1445 return Ok(Entry::of(parent, section));
1446 }
1447 if let Some(group) = parent
1448 .groups
1449 .iter()
1450 .find(|group| split(&group.path).1 == name)
1451 {
1452 let toc = group
1453 .toc
1454 .as_ref()
1455 .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?;
1456 return Ok(Entry {
1457 filename: name.to_owned(),
1458 identity: Some(toc.file_id),
1459 copy: false,
1460 });
1461 }
1462 Err(io::Error::from(io::ErrorKind::NotFound).into())
1463 }
1464
1465 /// The section path and page space a stored internal link opens, found by identity:
1466 /// in the linked section first, then in every other readable section, so a link
1467 /// follows its page across sections. `None` for other URLs and unknown targets.
1468 pub fn find_page(&self, url: &str) -> Result<Option<(String, Option<ExGuid>)>> {
1469 let Some(link) = onestore::page::link::parse_internal_link(url) else {
1470 return Ok(None);
1471 };
1472 let mut sections: Vec<_> = self
1473 .catalog
1474 .sections()
1475 .filter(|section| matches!(section.state, discover::SectionState::Readable { .. }))
1476 .collect();
1477 sections.sort_by_key(|section| section.file_id != link.section);
1478 let Some(page) = link.page else {
1479 return Ok(sections
1480 .first()
1481 .filter(|section| section.file_id == link.section)
1482 .map(|section| (section.path.clone(), None)));
1483 };
1484 for section in sections {
1485 let bytes = self.storage.read(&section.path)?;
1486 let store = Store::parse(&bytes)?;
1487 let index = RevisionIndex::parse(&store)?;
1488 let document = Document::parse(&index)?;
1489 for (space, _) in document.pages()? {
1490 if Page::identity_of(document.active(space)?) == Some(page) {
1491 return Ok(Some((section.path.clone(), Some(space))));
1492 }
1493 }
1494 }
1495 Ok(None)
1496 }
1497
1498 /// The section at catalog `path` as its file stores it, without the edits a replica
1499 /// may hold (`stored_pages` reads it).
1500 pub fn read_section(&self, path: &str) -> Result<Vec<u8>> {
1501 self.storage.read(&self.section_path(path)?.path)
1502 }
1503
1504 /// The key of the password-protected section at catalog `path`, opened with `password`.
1505 /// Edits this device queued before the section was protected elsewhere, held since, are
1506 /// queued again under the key: each page they changed comes back as a copy, as a page
1507 /// another client removed does, the section's pages having taken new identities.
1508 pub fn unlock(&self, path: &str, password: &str) -> Result<protected::Key> {
1509 crate::task::blocking(self.unlock_async(path, password))?
1510 }
1511
1512 pub async fn unlock_async(&self, path: &str, password: &str) -> Result<protected::Key> {
1513 let section = self.section_path(path)?;
1514 let image = self.storage.read(&section.path)?;
1515 let key = protected::Key::open(&image, password)?;
1516 let held: Vec<PathBuf> = self
1517 .superseded(&[section])
1518 .into_iter()
1519 .filter(|(.., queued)| *queued > 0)
1520 .map(|(replica, ..)| replica)
1521 .collect();
1522 if !held.is_empty() {
1523 let replica =
1524 Replica::open_or_create(self.replica_path(path)?, Some(&key), || Ok(image))?;
1525 for path in held {
1526 // A queue sealed under an earlier password waits for that password.
1527 let Ok(old) = Replica::open(&path) else {
1528 continue;
1529 };
1530 for (author, edit) in old.copies()? {
1531 replica.apply_async(&author, edit).await?;
1532 }
1533 drop(old);
1534 remove_replica(&path)?;
1535 }
1536 }
1537 Ok(key)
1538 }
1539
1540 /// The replicas of the files the protected `locked` sections superseded when their
1541 /// passwords were set (`onestore::protected::rekey` keeps the placement a file's header
1542 /// names), each held so that no one opens it meanwhile, with how many edits it queues. Only
1543 /// replicas no readable section names are read; one in use is left.
1544 fn superseded(
1545 &self,
1546 locked: &[&discover::Section],
1547 ) -> Vec<(PathBuf, rusqlite::Connection, u64)> {
1548 let named: BTreeSet<PathBuf> = self
1549 .catalog
1550 .sections()
1551 .filter(|section| matches!(section.state, discover::SectionState::Readable { .. }))
1552 .filter_map(|section| self.replica_path(&section.path).ok())
1553 .collect();
1554 let placed: Vec<_> = locked
1555 .iter()
1556 .filter_map(|section| {
1557 let (_, stamp) = self.read.found(&section.path)?;
1558 Some((section.file_id, stamp.header, self.replica_folder(section)))
1559 })
1560 .collect();
1561 let folders: BTreeSet<&PathBuf> = placed.iter().map(|(.., folder)| folder).collect();
1562 let mut superseded = Vec::new();
1563 for folder in folders {
1564 let Ok(entries) = fs::read_dir(folder) else {
1565 continue;
1566 };
1567 for path in entries.filter_map(|entry| Some(entry.ok()?.path())) {
1568 if path
1569 .extension()
1570 .is_none_or(|extension| extension != "sqlite")
1571 || named.contains(&path)
1572 {
1573 continue;
1574 }
1575 let Ok(held) = crate::closed(&path) else {
1576 continue;
1577 };
1578 let Ok((base, queued)) = crate::peek(&held) else {
1579 continue;
1580 };
1581 let Ok(header) = onestore::Header::parse(&base.header) else {
1582 continue;
1583 };
1584 if placed.iter().any(|(file, stamp, at)| {
1585 at == folder
1586 && base.header[128..148] == stamp[128..148]
1587 && header.file_id != *file
1588 }) {
1589 superseded.push((path, held, queued));
1590 }
1591 }
1592 }
1593 superseded
1594 }
1595
1596 /// Deletes the replicas holding nothing unpublished of files a protected section
1597 /// superseded: their plaintext is the section's before its password.
1598 fn forget_superseded(&self) {
1599 let locked: Vec<_> = self
1600 .catalog
1601 .sections()
1602 .filter(|section| matches!(section.state, discover::SectionState::Locked))
1603 .collect();
1604 if locked.is_empty() {
1605 return;
1606 }
1607 for (replica, held, queued) in self.superseded(&locked) {
1608 drop(held);
1609 if queued == 0 {
1610 let _ = remove_replica(&replica);
1611 }
1612 }
1613 }
1614
1615 /// Sets, changes or removes the password of the section at catalog `path`, as OneNote
1616 /// 2010 does: the section is written anew under new identities (`onestore::protected::
1617 /// rekey`), the file replaces the old one and its folder's TOC follows it. `key` opens
1618 /// it as it stands; `password` protects it anew, or `None` leaves it unprotected. Its
1619 /// replica, a cache of the old file, goes too, so its session must be closed and its
1620 /// edits published first. Returns the new key.
1621 pub fn set_password(
1622 &mut self,
1623 path: &str,
1624 key: Option<&protected::Key>,
1625 password: Option<&str>,
1626 ) -> Result<Option<protected::Key>> {
1627 let section = self.section_path(path)?;
1628 let replica = self.replica_path(path)?;
1629 // Held until the file is replaced, so that no session queues edits to the old file.
1630 let held = fs::metadata(&replica)
1631 .is_ok()
1632 .then(|| crate::closed(&replica))
1633 .transpose()?;
1634 if let Some(held) = &held
1635 && crate::peek(held)?.1 > 0
1636 {
1637 return Err(io::Error::new(
1638 io::ErrorKind::WouldBlock,
1639 "Edits to the section wait to be published",
1640 )
1641 .into());
1642 }
1643 let source = self.storage.read(&section.path)?;
1644 let new = password.map(protected::Key::new).transpose()?;
1645 let image = onestore::protected::rekey(&source, key, new.as_ref())?;
1646 let (folder, filename) = split(&section.path);
1647 // A dot file, which discovery and OneNote pass over, until it replaces the section.
1648 let written = catalog_path(folder, &format!(".{filename}.snowbound"));
1649 if self.storage.exists(&written) {
1650 self.storage.delete(&written)?;
1651 }
1652 self.storage.create(&written, &image)?;
1653 if let Err(error) = self
1654 .storage
1655 .supersede(&section.path, &Stamp::of(&source)?, &written)
1656 {
1657 let _ = self.storage.delete(&written);
1658 return Err(error);
1659 }
1660 drop(held);
1661 remove_replica(&replica)?;
1662 let parent = self.folder(folder)?;
1663 if let Some(identity) = Entry::of(parent, section).identity {
1664 self.edit_toc(
1665 folder,
1666 &[onestore::TocEdit::Reidentify {
1667 identity,
1668 with: Store::parse(&image)?.header.file_id,
1669 }],
1670 )?;
1671 }
1672 self.refresh()?;
1673 Ok(new)
1674 }
1675
1676 /// Where the replica of the section at catalog `path` lives, in the notebook's
1677 /// `location::folder`: named by the section's document identity in a mounted notebook
1678 /// (`Section::open`), by its file identity on a share. It exists once the section has
1679 /// been opened.
1680 pub fn replica_path(&self, path: &str) -> Result<PathBuf> {
1681 let section = self.section_path(path)?;
1682 let folder = self.replica_folder(section);
1683 Ok(match (&self.root, &section.state) {
1684 (Some(_), discover::SectionState::Readable { document, .. }) => {
1685 replica_file(&folder, document)
1686 }
1687 (Some(_), _) => {
1688 let image = self.storage.read(&section.path)?;
1689 let root = RevisionIndex::parse(&Store::parse(&image)?)?.root;
1690 replica_file(&folder, &root.guid)
1691 }
1692 (None, _) => replica_file(&folder, &section.file_id),
1693 })
1694 }
1695
1696 /// The `location::folder` holding the replica of `section`.
1697 fn replica_folder(&self, section: &discover::Section) -> PathBuf {
1698 let location = replica_location(&self.storage.location(), section);
1699 crate::location::folder(&self.cache, &location)
1700 }
1701
1702 /// Every readable section, for `Background::watch`, handing on the files the last
1703 /// discovery read so that each is read once.
1704 pub fn replicas(&mut self) -> Vec<Known> {
1705 let mut images = self.read.take();
1706 self.catalog
1707 .sections()
1708 .filter(|section| matches!(section.state, discover::SectionState::Readable { .. }))
1709 .map(|section| Known {
1710 path: section.path.clone(),
1711 replica: self.replica_path(&section.path).ok(),
1712 found: self.read.found(&section.path),
1713 image: images.remove(&section.path),
1714 })
1715 .collect()
1716 }
1717
1718 /// Keeps the sections of a mounted notebook in sync while they are not open
1719 /// (`Background`): a file is checked when `Background::touched` reports it changed, and
1720 /// otherwise every `Background::BACKSTOP` when `watched`, as a host that watches the
1721 /// folder reports every change, or else every `Background::UNWATCHED`. `copies` keeps an
1722 /// offline copy of every section, for a folder that is not on this computer.
1723 pub fn background(
1724 &self,
1725 watched: bool,
1726 copies: bool,
1727 notify: impl Fn() + Send + 'static,
1728 ) -> Result<Background> {
1729 self.background_with(watched, copies, |file| FileRemote(file.to_owned()), notify)
1730 }
1731
1732 /// `background`, reaching each section file through the remote `remote` makes for it
1733 /// (`section_with`).
1734 pub fn background_with<R: Remote + 'static>(
1735 &self,
1736 watched: bool,
1737 copies: bool,
1738 remote: impl Fn(&Path) -> R + Clone + Send + 'static,
1739 notify: impl Fn() + Send + 'static,
1740 ) -> Result<Background> {
1741 let Some(root) = self.root.clone() else {
1742 return Err(io::Error::new(
1743 io::ErrorKind::Unsupported,
1744 "Sections on a share sync through Background::smb",
1745 )
1746 .into());
1747 };
1748 Background::start(
1749 copies,
1750 move |_| {
1751 let (folder, remote) = (root.clone(), remote.clone());
1752 let bind = move |path: &str| remote(&folder.join(path));
1753 let mut local = discover::Local::open(&root)?;
1754 let list = move |folder: String| {
1755 use discover::Source;
1756 std::future::ready(local.entries(&folder, LIMITS.entries))
1757 };
1758 Ok(((bind, list), watched))
1759 },
1760 notify,
1761 )
1762 }
1763
1764 /// Opens a section of a mounted notebook by its catalog path.
1765 pub fn section(
1766 &self,
1767 path: &str,
1768 offline: bool,
1769 notify: impl Fn() + Send + 'static,
1770 ) -> Result<Section> {
1771 self.section_with(
1772 path,
1773 |file| Ok(FileRemote(file.to_owned())),
1774 offline,
1775 notify,
1776 )
1777 }
1778
1779 /// `section`, reading and publishing through the remote `connect` makes for its file
1780 /// (`Section::open_with`).
1781 pub fn section_with<R: Remote + 'static>(
1782 &self,
1783 path: &str,
1784 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
1785 offline: bool,
1786 notify: impl Fn() + Send + 'static,
1787 ) -> Result<Section> {
1788 self.open_section(path, None, connect, offline, notify)
1789 }
1790
1791 /// `section` for a password-protected section, under the `key` `unlock` gave.
1792 pub fn section_unlocked(
1793 &self,
1794 path: &str,
1795 key: &protected::Key,
1796 offline: bool,
1797 notify: impl Fn() + Send + 'static,
1798 ) -> Result<Section> {
1799 self.section_unlocked_with(
1800 path,
1801 key,
1802 |file| Ok(FileRemote(file.to_owned())),
1803 offline,
1804 notify,
1805 )
1806 }
1807
1808 /// `section_with` for a password-protected section, under its `key`.
1809 pub fn section_unlocked_with<R: Remote + 'static>(
1810 &self,
1811 path: &str,
1812 key: &protected::Key,
1813 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
1814 offline: bool,
1815 notify: impl Fn() + Send + 'static,
1816 ) -> Result<Section> {
1817 self.open_section(path, Some(key), connect, offline, notify)
1818 }
1819
1820 fn open_section<R: Remote + 'static>(
1821 &self,
1822 path: &str,
1823 key: Option<&protected::Key>,
1824 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
1825 offline: bool,
1826 notify: impl Fn() + Send + 'static,
1827 ) -> Result<Section> {
1828 let section = self.section_path(path)?;
1829 let (path, replicas) = (section.path.clone(), self.replica_folder(section));
1830 let Some(root) = &self.root else {
1831 return Err(io::Error::new(
1832 io::ErrorKind::Unsupported,
1833 "Sections on a share open through Section::resume_smb",
1834 )
1835 .into());
1836 };
1837 let file = root.join(path);
1838 // Evicted files resume their replicas; existing links still resolve within the root.
1839 let file = match fs::symlink_metadata(&file) {
1840 Err(error) if error.kind() == io::ErrorKind::NotFound => fs::canonicalize(
1841 file.parent()
1842 .ok_or(io::Error::from(io::ErrorKind::InvalidInput))?,
1843 )?
1844 .join(
1845 file.file_name()
1846 .ok_or(io::Error::from(io::ErrorKind::InvalidInput))?,
1847 ),
1848 _ => fs::canonicalize(&file)?,
1849 };
1850 if !file.starts_with(root) {
1851 return Err(io::Error::from(io::ErrorKind::PermissionDenied).into());
1852 }
1853 // A replica the catalog's identity names resumes without reading the file, which its
1854 // worker checks next, as a share's does.
1855 if let discover::SectionState::Readable { document, .. } = &section.state {
1856 let cache = replica_file(&replicas, document);
1857 if fs::metadata(&cache).is_ok() {
1858 let (replica, remote, mut connect) =
1859 (Replica::open(&cache)?, file.clone(), connect);
1860 return Section::start(
1861 file,
1862 replica,
1863 move || connect(&remote),
1864 CHECK,
1865 offline,
1866 notify,
1867 );
1868 }
1869 }
1870 let replica = |identity: &[u8; 16], _: &[u8]| {
1871 fs::create_dir_all(&replicas)?;
1872 Ok(replica_file(&replicas, identity))
1873 };
1874 Section::open_in(file, key, replica, connect, CHECK, offline, notify)
1875 }
1876}
1877
1878/// A section or group as its folder holds it, for the structure operations.
1879struct Entry {
1880 filename: String,
1881 /// What its folder's TOC lists it under, if anything.
1882 identity: Option<[u8; 16]>,
1883 /// A copy of another section of the notebook (`discover::Section::copy`). Its header names
1884 /// the other's file, so it is never placed or listed anew: OneNote lists a copy it finds
1885 /// under an identity of its own, leaving the file as it is.
1886 copy: bool,
1887}
1888
1889impl Entry {
1890 /// `section` of the folder `parent`: a copy has only the entry its folder's TOC lists
1891 /// under its name.
1892 fn of(parent: &discover::Folder, section: &discover::Section) -> Self {
1893 let filename = split(&section.path).1.to_owned();
1894 let identity = if section.copy {
1895 parent
1896 .toc
1897 .iter()
1898 .flat_map(|toc| &toc.unresolved)
1899 .find(|entry| {
1900 entry
1901 .filename
1902 .as_deref()
1903 .is_some_and(|name| name.eq_ignore_ascii_case(&filename))
1904 })
1905 .map(|entry| entry.file)
1906 } else {
1907 Some(section.file_id)
1908 };
1909 Self {
1910 filename,
1911 identity,
1912 copy: section.copy,
1913 }
1914 }
1915}
1916
1917/// A page as a section file stores it.
1918pub struct StoredPage {
1919 pub space: ExGuid,
1920 pub page: Page,
1921 /// The revision metadata's timestamp, or the page's `LastModifiedTime`, as FILETIME.
1922 pub modified: Option<u64>,
1923 /// Who changed it last: revision metadata, or the latest modified object in older files.
1924 pub author: Option<String>,
1925}
1926
1927/// The pages of the section file `image` holds, in section order; pages the model cannot
1928/// build are left out.
1929pub fn stored_pages(image: &[u8]) -> Result<Vec<StoredPage>> {
1930 let store = Store::parse(image)?;
1931 let index = RevisionIndex::parse(&store)?;
1932 pages_of(&Document::parse(&index)?)
1933}
1934
1935/// `stored_pages` of a password-protected section, under its `key`.
1936pub fn stored_pages_unlocked(image: &[u8], key: &protected::Key) -> Result<Vec<StoredPage>> {
1937 let store = Store::parse(image)?;
1938 let index = RevisionIndex::parse(&store)?;
1939 let unlocked = protected::UnlockedSection::unlock(&index, key, Default::default())?;
1940 pages_of(&unlocked.document()?)
1941}
1942
1943fn pages_of(document: &Document<'_>) -> Result<Vec<StoredPage>> {
1944 Ok(document
1945 .pages()?
1946 .into_iter()
1947 .filter_map(|(space, id)| {
1948 let revision = document.active(space).ok()?;
1949 let latest = (revision.nodes.values())
1950 .filter(|node| node.latest_author.is_some())
1951 .max_by_key(|node| node.modified);
1952 let metadata = revision.roots.get(&4).and_then(|id| revision.nodes.get(id));
1953 let author = metadata
1954 .filter(|node| {
1955 matches!(node.kind, onestore::document::Kind::RevisionMetadata { .. })
1956 })
1957 .filter(|node| node.latest_author.is_some())
1958 .or(latest)
1959 .and_then(|node| revision.nodes.get(&node.latest_author?))
1960 .and_then(|node| match &node.kind {
1961 onestore::document::Kind::Author { name } => name.clone(),
1962 _ => None,
1963 });
1964 Some(StoredPage {
1965 space,
1966 page: Page::from_revision(revision, id).ok()?,
1967 modified: metadata
1968 .and_then(|node| match node.kind {
1969 onestore::document::Kind::RevisionMetadata { modified_filetime } => {
1970 modified_filetime
1971 }
1972 _ => None,
1973 })
1974 .or_else(|| {
1975 revision
1976 .nodes
1977 .get(&id)
1978 .and_then(|node| node.modified)
1979 .map(|seconds| (u64::from(seconds) + 11_960_006_400) * 10_000_000)
1980 }),
1981 author,
1982 })
1983 })
1984 .collect())
1985}
1986
1987/// The colours OneNote 2010 gives a folder's first sixteen new sections, COLORREF, in the
1988/// order it gives them (`corpus/notebook-management/native/section-colors`: sixteen sections
1989/// made with New Section in a new notebook).
1990const SECTION_COLORS: [u32; 16] = [
1991 0x00e4a88a, 0x0078b0f6, 0x00bba4d5, 0x00d2bb9b, 0x00b79cab, 0x0099d1e8, 0x006ff9f5, 0x0092e7ad,
1992 0x00cabc4d, 0x007575ba, 0x00aa9595, 0x00e4a88a, 0x0069d8ff, 0x0097c9b7, 0x009795ee, 0x00de9eb4,
1993];
1994
1995/// The colour OneNote gives a new section in `folder`, by how many sections it holds.
1996fn next_color(folder: &discover::Folder) -> u32 {
1997 SECTION_COLORS[folder.sections.len() % SECTION_COLORS.len()]
1998}
1999
2000/// The op putting `page` into another section as OneNote moves a page there, into the
2001/// recycle bin or another section: last, under fresh object identities, keeping its page
2002/// identity, title, date and creation time.
2003pub fn moved(page: &Page, author: &str) -> Result<Op> {
2004 let mut creation = PageCreation::new(None, Some(&page.title), author)?;
2005 if let (Some(identity), Some(created)) = (page.identity, page.created) {
2006 creation = creation.keeping(identity, created)?;
2007 }
2008 if let Some([date, time]) = page.date_text() {
2009 creation = creation.dated(&date, &time)?;
2010 }
2011 Ok(Op::Section(SectionOp::Import {
2012 creation,
2013 page: page.copy()?,
2014 }))
2015}
2016
2017/// The edits putting the pages `moved` where `order` lists them, with their levels, in a
2018/// section now listing `listed`: each goes before the next page of `order` that stays put
2019/// and is still listed. Pages gone from the section are left out.
2020pub fn arrange(
2021 listed: &[(ExGuid, String, u32)],
2022 order: &[(ExGuid, u32)],
2023 moved: &[ExGuid],
2024) -> Result<Vec<PageEdit>> {
2025 let present = |space: &ExGuid| listed.iter().any(|(listed, ..)| listed == space);
2026 let mut edits = Vec::new();
2027 for (index, (space, level)) in order.iter().enumerate() {
2028 if !moved.contains(space) || !present(space) {
2029 continue;
2030 }
2031 let before = order[index + 1..]
2032 .iter()
2033 .map(|(space, _)| *space)
2034 .find(|space| !moved.contains(space) && present(space));
2035 edits.push(PageEdit::move_to(*space, before, *level)?);
2036 }
2037 Ok(edits)
2038}
2039
2040/// Whether `page` holds nothing but an empty title, as the page a section left without
2041/// pages gains does.
2042pub fn blank(page: &Page) -> bool {
2043 page.title.trim().is_empty()
2044 && page
2045 .objects
2046 .iter()
2047 .all(|object| matches!(object, onestore::page::PageObject::Title(_)))
2048}
2049
2050fn component(name: &str) -> bool {
2051 !name.is_empty() && !name.contains(['/', '\\', '\0']) && name != "." && name != ".."
2052}
2053
2054/// Whether the TOC `image` holds has an entry for the file identity `file`.
2055pub(crate) fn lists(image: &[u8], file: [u8; 16]) -> Result<bool> {
2056 let store = Store::parse(image)?;
2057 let index = RevisionIndex::parse(&store)?;
2058 let document = Document::parse(&index)?;
2059 let revision = document.active(document.root)?;
2060 let entries = revision
2061 .roots
2062 .get(&1)
2063 .and_then(|id| revision.nodes.get(id))
2064 .map_or(&[][..], |node| match &node.kind {
2065 onestore::document::Kind::Toc { entries, .. } => &entries[..],
2066 _ => &[],
2067 });
2068 Ok(entries.iter().any(|id| {
2069 matches!(
2070 revision.nodes.get(id).map(|node| &node.kind),
2071 Some(onestore::document::Kind::Toc { identity: Some(identity), .. }) if *identity == file
2072 )
2073 }))
2074}
2075
2076/// Where the cache keeps what reading the catalog of the notebook at `location` took.
2077pub(crate) fn listing(cache: &Path, location: &str) -> PathBuf {
2078 let name: String = <sha2::Sha256 as sha2::Digest>::digest(location)[..16]
2079 .iter()
2080 .map(|byte| format!("{byte:02x}"))
2081 .collect();
2082 cache.join("listings").join(format!("{name}.json"))
2083}
2084
2085/// Whether `name` can name a folder on every system a notebook's readers use, Windows's
2086/// included.
2087fn folder_name(name: &str) -> bool {
2088 component(name)
2089 && !name.contains(['<', '>', ':', '"', '|', '?', '*'])
2090 && !name.chars().any(char::is_control)
2091 && !name.ends_with(['.', ' '])
2092}
2093
2094fn split(path: &str) -> (&str, &str) {
2095 match path.rsplit_once('/') {
2096 Some((folder, name)) => (folder, name),
2097 None => ("", path),
2098 }
2099}
2100
2101fn catalog_path(folder: &str, name: &str) -> String {
2102 if folder.is_empty() {
2103 name.to_owned()
2104 } else {
2105 format!("{folder}/{name}")
2106 }
2107}
2108
2109/// The location keying the replica of `section` in the notebook at `notebook`: the notebook's,
2110/// so that a section renamed or moved within it keeps its replica, or for a copy of another
2111/// section of the notebook its own file's.
2112fn replica_location(notebook: &str, section: &discover::Section) -> String {
2113 if section.copy {
2114 format!("{notebook}/{}", section.path)
2115 } else {
2116 notebook.to_owned()
2117 }
2118}
2119
2120/// Deletes the closed replica at `replica`, with the files SQLite keeps beside it.
2121fn remove_replica(replica: &Path) -> io::Result<()> {
2122 for suffix in ["", "-wal", "-shm"] {
2123 let mut file = replica.as_os_str().to_owned();
2124 file.push(suffix);
2125 match fs::remove_file(file) {
2126 Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error),
2127 _ => {}
2128 }
2129 }
2130 Ok(())
2131}
2132
2133/// How often an open section's file is checked where nothing watches it.
2134const CHECK: Duration = Duration::from_secs(2);
2135
2136/// Names a lone section's replica in the folder of `location` by its identity, taking one a
2137/// moved file left behind (`location::claim`).
2138fn claimed<'a>(
2139 cache: &'a Path,
2140 location: &'a str,
2141) -> impl FnOnce(&[u8; 16], &[u8]) -> Result<PathBuf> + 'a {
2142 move |identity, source| {
2143 let folder =
2144 crate::location::claim(cache, location, &[*identity], |_| Stamp::of(source).ok())?;
2145 Ok(replica_file(&folder, identity))
2146 }
2147}
2148
2149/// The replica in `cache` of the section `identity` names.
2150pub(crate) fn replica_file(cache: &Path, identity: &[u8; 16]) -> PathBuf {
2151 let name: String = identity.iter().map(|byte| format!("{byte:02x}")).collect();
2152 cache.join(format!("{name}.sqlite"))
2153}
2154
2155/// Why a synchronization step did not reach the section file, as the host shows it.
2156pub(crate) fn reached(error: &Error) -> io::Error {
2157 match error {
2158 Error::RemoteIo(error) => io::Error::new(error.kind(), error.to_string()),
2159 Error::Remote(error) => io::Error::new(error.error.kind(), error.to_string()),
2160 error => io::Error::other(error.to_string()),
2161 }
2162}
2163
2164/// What happened to the section since the last poll.
2165#[derive(Debug)]
2166pub enum Event {
2167 /// A remote change reached these pages; reload them where they are open.
2168 Changed(Vec<ExGuid>),
2169 /// The section refused an edit `apply` handed it; the pages it names are as they were
2170 /// before it, so an editor showing them should reload them.
2171 Rejected { spaces: Vec<ExGuid>, error: String },
2172 /// A publication attempt finished with this durable state.
2173 Attempt { id: u64, status: EditStatus },
2174 /// The section file could not be reached; the replica keeps its state.
2175 Unreachable(io::Error),
2176 /// The replica itself failed and the worker stopped, or edits wait in memory for the cache.
2177 Failed(String),
2178}
2179
2180type Notify = Arc<dyn Fn() + Send + Sync>;
2181
2182/// How a section's synchronization stands, for the host to show.
2183#[derive(Debug)]
2184pub struct SyncStatus {
2185 /// FILETIME of the last synchronization step that reached the section file.
2186 pub synced: Option<u64>,
2187 /// Why the last step failed, until one reaches the section file again.
2188 pub error: Option<io::Error>,
2189 /// Edits the section file does not hold yet, uncertain attempts included.
2190 pub queued: u64,
2191}
2192
2193/// What a `SyncStatus` comes to for the reader, from the best to the worst.
2194#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
2195pub enum SyncState {
2196 UpToDate,
2197 /// Not reached yet, or edits wait.
2198 Syncing,
2199 /// Another client holds the section file.
2200 InUse,
2201 /// The file or its server cannot be reached; edits wait until it can.
2202 NotConnected,
2203 /// The file cannot be written where it is stored.
2204 ReadOnly,
2205 /// The section is password protected, which Snowbound cannot open.
2206 Protected,
2207 /// The file is stably not a section Snowbound can read.
2208 Unreadable,
2209 Failed,
2210}
2211
2212impl SyncStatus {
2213 pub fn state(&self) -> SyncState {
2214 use io::ErrorKind::*;
2215 match self.error.as_ref().map(io::Error::kind) {
2216 Some(PermissionDenied | ReadOnlyFilesystem) => SyncState::ReadOnly,
2217 Some(WouldBlock | ResourceBusy) => SyncState::InUse,
2218 Some(Unsupported) => SyncState::Protected,
2219 Some(InvalidData) => SyncState::Unreadable,
2220 Some(
2221 NotFound | ConnectionRefused | ConnectionReset | ConnectionAborted | NotConnected
2222 | TimedOut | HostUnreachable | NetworkUnreachable | NetworkDown | BrokenPipe
2223 | AddrNotAvailable,
2224 ) => SyncState::NotConnected,
2225 Some(_) => SyncState::Failed,
2226 None if self.synced.is_none() || self.queued > 0 => SyncState::Syncing,
2227 None => SyncState::UpToDate,
2228 }
2229 }
2230}
2231
2232/// The last attempt's outcome; `None` before the first.
2233pub(crate) type Observed = Option<Option<io::Error>>;
2234
2235pub(crate) fn sync_status(
2236 replica: &Replica,
2237 synced: impl FnOnce() -> Option<u64>,
2238 observed: &Mutex<Observed>,
2239) -> Result<SyncStatus> {
2240 let queued: i64 = crate::lock(&replica.section.connection)?.query_row(
2241 "SELECT count(*) FROM edits",
2242 [],
2243 |row| row.get(0),
2244 )?;
2245 let observed = observed
2246 .lock()
2247 .map_err(|_| io::Error::other("Synchronization observer panicked"))?;
2248 Ok(SyncStatus {
2249 // Read after `observed`: the worker stores the time before it clears the error, so a
2250 // cleared error never shows beside the time of the step before it.
2251 synced: synced(),
2252 error: observed
2253 .as_ref()
2254 .and_then(Option::as_ref)
2255 .map(|error| io::Error::new(error.kind(), error.to_string())),
2256 queued: crate::unsigned(queued)?,
2257 })
2258}
2259
2260/// A section file with its replica and background publication. `Send + Sync`: edits
2261/// arrive from any thread without waiting, and `notify` wakes the host when events wait.
2262pub struct Section {
2263 file: PathBuf,
2264 replica: Arc<Replica>,
2265 worker: Option<SyncWorker>,
2266 events: Mutex<Receiver<Event>>,
2267 sender: Sender<Event>,
2268 notify: Notify,
2269 pub(crate) observed: Arc<Mutex<Observed>>,
2270}
2271
2272impl Section {
2273 /// Opens the lone section file through a replica in `cache`, in the file's
2274 /// `location::folder`, creating the replica from the file on first use and converting an
2275 /// older one. `notify` runs on a background thread whenever an event is available.
2276 pub fn open(
2277 file: impl AsRef<Path>,
2278 cache: impl AsRef<Path>,
2279 offline: bool,
2280 notify: impl Fn() + Send + 'static,
2281 ) -> Result<Self> {
2282 Self::open_with(
2283 file,
2284 cache,
2285 |file| Ok(FileRemote(file.to_owned())),
2286 offline,
2287 notify,
2288 )
2289 }
2290
2291 /// `open`, reading and publishing the file through the remote `connect` makes for it,
2292 /// as a host that coordinates file access with other processes (iOS's file providers)
2293 /// needs.
2294 pub fn open_with<R: Remote + 'static>(
2295 file: impl AsRef<Path>,
2296 cache: impl AsRef<Path>,
2297 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
2298 offline: bool,
2299 notify: impl Fn() + Send + 'static,
2300 ) -> Result<Self> {
2301 let file = fs::canonicalize(file)?;
2302 let location = file.to_string_lossy().into_owned();
2303 let replicas = claimed(cache.as_ref(), &location);
2304 Self::open_in(file, None, replicas, connect, CHECK, offline, notify)
2305 }
2306
2307 /// Opens the canonical section `file`, a protected one under `key`, through the replica
2308 /// `replica` names for its document identity and image.
2309 fn open_in<R: Remote + 'static>(
2310 file: PathBuf,
2311 key: Option<&protected::Key>,
2312 replica: impl FnOnce(&[u8; 16], &[u8]) -> Result<PathBuf>,
2313 mut connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
2314 interval: Duration,
2315 offline: bool,
2316 notify: impl Fn() + Send + 'static,
2317 ) -> Result<Self> {
2318 let source = connect(&file)?.read()?;
2319 let store = Store::parse(&source)?;
2320 let identity = RevisionIndex::parse(&store)?.root;
2321 let cache = replica(&identity.guid, &source)?;
2322 let replica = Replica::open_or_create(&cache, key, || Ok(source))?;
2323 let remote = file.clone();
2324 Self::start(
2325 file,
2326 replica,
2327 move || connect(&remote),
2328 interval,
2329 offline,
2330 notify,
2331 )
2332 }
2333
2334 /// Resumes an owned local replica without reading the publication target.
2335 /// A target with a different document identity is rejected by synchronization.
2336 pub fn resume(
2337 file: impl AsRef<Path>,
2338 replica: Replica,
2339 offline: bool,
2340 notify: impl Fn() + Send + 'static,
2341 ) -> Result<Self> {
2342 let file = fs::absolute(file)?;
2343 let remote = file.clone();
2344 Self::start(
2345 file,
2346 replica,
2347 move || Ok(FileRemote(remote.clone())),
2348 CHECK,
2349 offline,
2350 notify,
2351 )
2352 }
2353
2354 /// Resumes a replica against a share-relative section path. Credentials remain in
2355 /// `connect`, which is called on the worker again after transport failures.
2356 #[cfg(feature = "smb")]
2357 pub fn resume_smb(
2358 path: String,
2359 replica: Replica,
2360 limit: usize,
2361 mut connect: impl FnMut() -> io::Result<crate::smb::Client> + Send + 'static,
2362 offline: bool,
2363 notify: impl Fn() + Send + 'static,
2364 ) -> Result<Self> {
2365 Self::start(
2366 PathBuf::from(&path),
2367 replica,
2368 move || Ok(crate::SmbRemote::new(connect()?, path.clone(), limit)),
2369 CHECK,
2370 offline,
2371 notify,
2372 )
2373 }
2374
2375 /// Resumes a replica against a section a Live Share host serves at catalog `path`.
2376 #[cfg(feature = "live")]
2377 pub fn resume_hosted(
2378 path: String,
2379 replica: Replica,
2380 guest: Arc<crate::live::share::Guest>,
2381 offline: bool,
2382 notify: impl Fn() + Send + 'static,
2383 ) -> Result<Self> {
2384 Self::start(
2385 PathBuf::from(&path),
2386 replica,
2387 move || Ok(crate::live::share::HostedRemote::new(&guest, &path)),
2388 CHECK,
2389 offline,
2390 notify,
2391 )
2392 }
2393
2394 /// Resumes a replica against the section at catalog `path` of a notebook a document
2395 /// provider keeps (`Notebook::open_provider`). Its file is checked every
2396 /// `Background::UNWATCHED`, and when `wake` passes on what the host hears of changes.
2397 pub fn resume_provider(
2398 path: String,
2399 replica: Replica,
2400 documents: Arc<crate::provider::Documents>,
2401 offline: bool,
2402 notify: impl Fn() + Send + 'static,
2403 ) -> Result<Self> {
2404 Self::start(
2405 PathBuf::from(&path),
2406 replica,
2407 move || Ok(crate::provider::ProviderRemote::new(&documents, &path)),
2408 Background::UNWATCHED,
2409 offline,
2410 notify,
2411 )
2412 }
2413
2414 /// Opens the lone section that is the root of `documents` as `open` opens a file, checked
2415 /// as `resume_provider`'s is; while the provider can't be reached, from the only replica
2416 /// its location has.
2417 pub fn open_provider(
2418 documents: Arc<crate::provider::Documents>,
2419 offline: bool,
2420 notify: impl Fn() + Send + Sync + 'static,
2421 ) -> Result<Self> {
2422 let (cache, location) = (documents.cache.clone(), documents.location());
2423 let cache = cache.as_path();
2424 let file = PathBuf::from(&documents.root);
2425 let remote = move || Ok(crate::provider::ProviderRemote::new(&documents, ""));
2426 let notify = Arc::new(notify);
2427 let (connect, woken) = (remote.clone(), Arc::clone(&notify));
2428 let replicas = claimed(cache, &location);
2429 let opened = Self::open_in(
2430 file.clone(),
2431 None,
2432 replicas,
2433 move |_| connect(),
2434 Background::UNWATCHED,
2435 offline,
2436 move || woken(),
2437 );
2438 match opened {
2439 Err(Error::Io(error)) if error.kind() == io::ErrorKind::NotConnected => {
2440 let mut replicas = fs::read_dir(crate::location::folder(cache, &location))
2441 .into_iter()
2442 .flatten()
2443 .filter_map(|entry| Some(entry.ok()?.path()))
2444 .filter(|path| path.extension() == Some("sqlite".as_ref()));
2445 match (replicas.next(), replicas.next()) {
2446 (Some(only), None) => Self::start(
2447 file,
2448 Replica::open(only)?,
2449 remote,
2450 Background::UNWATCHED,
2451 offline,
2452 move || notify(),
2453 ),
2454 _ => Err(Error::Io(error)),
2455 }
2456 }
2457 opened => opened,
2458 }
2459 }
2460
2461 fn start<R: Remote + 'static>(
2462 file: PathBuf,
2463 replica: Replica,
2464 connect: impl FnMut() -> io::Result<R> + Send + 'static,
2465 interval: Duration,
2466 offline: bool,
2467 notify: impl Fn() + Send + 'static,
2468 ) -> Result<Self> {
2469 let replica = Arc::new(replica);
2470 let (sender, events) = mpsc::channel();
2471 let notify = Mutex::new(notify);
2472 let notify: Notify = Arc::new(move || {
2473 if let Ok(notify) = notify.lock() {
2474 notify();
2475 }
2476 });
2477 let observed: Arc<Mutex<Observed>> = Arc::new(Mutex::new(None));
2478 let mut section = Self {
2479 file,
2480 replica,
2481 worker: None,
2482 events: Mutex::new(events),
2483 sender,
2484 notify,
2485 observed,
2486 };
2487 section.worker = Some(section.start_worker(connect, interval, offline, None)?);
2488 Ok(section)
2489 }
2490
2491 fn start_worker<R: Remote + 'static>(
2492 &self,
2493 connect: impl FnMut() -> io::Result<R> + Send + 'static,
2494 interval: Duration,
2495 offline: bool,
2496 replacing: Option<&SyncWorker>,
2497 ) -> Result<SyncWorker> {
2498 let (sender, notify) = (self.sender.clone(), Arc::clone(&self.notify));
2499 let observed = Arc::clone(&self.observed);
2500 Ok(self.replica.start_sync_replacing(
2501 interval,
2502 offline,
2503 connect,
2504 move |result| {
2505 let error = result.as_ref().err().map(reached);
2506 // Reaching the file as the last attempt did is no news to the host.
2507 let mut changed = false;
2508 if let Ok(mut observed) = observed.lock() {
2509 changed = observed
2510 .as_ref()
2511 .is_none_or(|last| last.is_some() != error.is_some());
2512 *observed = Some(error);
2513 }
2514 let mut events = Vec::new();
2515 match result {
2516 Ok(synced) => {
2517 if !synced.changed.is_empty() {
2518 events.push(Event::Changed(synced.changed.clone()));
2519 }
2520 if let Some((id, status)) = &synced.edit {
2521 events.push(Event::Attempt {
2522 id: *id,
2523 status: status.clone(),
2524 });
2525 }
2526 }
2527 Err(Error::RemoteIo(error)) => {
2528 events.push(Event::Unreachable(match error.raw_os_error() {
2529 Some(code) => io::Error::from_raw_os_error(code),
2530 None => io::Error::new(error.kind(), error.to_string()),
2531 }))
2532 }
2533 Err(Error::Remote(error)) => events.push(Event::Unreachable(io::Error::new(
2534 error.error.kind(),
2535 error.to_string(),
2536 ))),
2537 Err(error) => events.push(Event::Failed(error.to_string())),
2538 }
2539 if (changed || !events.is_empty())
2540 && events.into_iter().all(|event| sender.send(event).is_ok())
2541 {
2542 notify();
2543 }
2544 },
2545 replacing,
2546 )?)
2547 }
2548
2549 /// Follows a section file moved within its notebook, keeping its replica and queued edits.
2550 pub fn relocate(&mut self, file: impl AsRef<Path>) -> Result<()> {
2551 self.relocate_with(fs::absolute(file)?, |file| Ok(FileRemote(file.to_owned())))
2552 }
2553
2554 /// `relocate`, with a host's publication target. Cancellation leaves any in-flight step
2555 /// to finish off-thread; synchronization ownership serializes the replacement worker.
2556 pub fn relocate_with<R: Remote + 'static>(
2557 &mut self,
2558 file: impl AsRef<Path>,
2559 mut connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
2560 ) -> Result<()> {
2561 let file = file.as_ref().to_owned();
2562 let target = file.clone();
2563 let previous = self.worker.take();
2564 if let Some(worker) = &previous {
2565 worker.cancel();
2566 }
2567 self.observed = Arc::new(Mutex::new(None));
2568 self.worker =
2569 Some(self.start_worker(move || connect(&target), CHECK, false, previous.as_ref())?);
2570 self.file = file;
2571 Ok(())
2572 }
2573
2574 /// The absolute local path, or share-relative path for an SMB session.
2575 pub fn file(&self) -> &Path {
2576 &self.file
2577 }
2578
2579 /// The section file identity, which internal links name as `section-id`
2580 /// (`onestore::page::link::internal_link`).
2581 pub fn identity(&self) -> Result<[u8; 16]> {
2582 self.replica.identity()
2583 }
2584
2585 /// Applies an edit without waiting: it becomes durable on the section thread, which
2586 /// reports a refusal as `Event::Rejected`. Edits apply in the order they arrive.
2587 pub fn apply(&self, author: &str, edit: Edit) -> Result<()> {
2588 self.apply_with(author, edit, |_| {})
2589 }
2590
2591 /// Submits without waiting; on completion, calls `reply` before waking the host.
2592 pub fn apply_with(
2593 &self,
2594 author: &str,
2595 edit: Edit,
2596 reply: impl FnOnce(Result<u64>) + Send + 'static,
2597 ) -> Result<()> {
2598 let (sender, notify) = (self.sender.clone(), Arc::clone(&self.notify));
2599 let waiting: crate::working::Notice = {
2600 let (sender, notify) = (sender.clone(), Arc::clone(&notify));
2601 Box::new(move |error| {
2602 if sender.send(Event::Failed(error.to_owned())).is_ok() {
2603 notify();
2604 }
2605 })
2606 };
2607 let root = self.replica.root;
2608 let spaces = crate::queue::spaces(&edit, root);
2609 self.replica.submit(
2610 author,
2611 edit,
2612 Box::new(move |result| {
2613 let event = match &result {
2614 Ok(_) => None,
2615 Err(Error::Rejected(error)) => Some(Event::Rejected {
2616 spaces,
2617 error: error.to_string(),
2618 }),
2619 Err(error) => Some(Event::Failed(error.to_string())),
2620 };
2621 reply(result);
2622 if let Some(event) = event {
2623 if sender.send(event).is_ok() {
2624 notify();
2625 }
2626 } else {
2627 notify();
2628 }
2629 }),
2630 waiting,
2631 )
2632 }
2633
2634 /// Page spaces, titles and outline levels (1 at the top) in section order, as the
2635 /// local edits leave them.
2636 pub fn pages(&self) -> Result<Vec<(ExGuid, String, u32)>> {
2637 self.replica.pages()
2638 }
2639
2640 /// The page to show or edit; O(page), for opening and reloading.
2641 pub fn page(&self, space: ExGuid) -> Result<Page> {
2642 self.replica.page(space)
2643 }
2644
2645 /// Copies a page (usually from another section) to the end of this section under
2646 /// fresh identities, queued like the user's own edits. Returns the new page's space.
2647 /// Content outside the model refuses to copy.
2648 pub fn import_page(&self, page: &Page, author: &str) -> Result<ExGuid> {
2649 crate::task::blocking(self.import_page_async(page, author))?
2650 }
2651
2652 pub fn import_page_async(
2653 &self,
2654 page: &Page,
2655 author: &str,
2656 ) -> impl Future<Output = Result<ExGuid>> + Send + 'static + use<> {
2657 let replica = Arc::clone(&self.replica);
2658 let author = author.to_owned();
2659 let creation = PageCreation::new(None, Some(&page.title), &author);
2660 let page = page.copy();
2661 async move {
2662 let creation = creation?;
2663 let space = creation.space();
2664 replica
2665 .apply_async(
2666 &author,
2667 Edit {
2668 at: crate::now(),
2669 ops: vec![Op::Section(SectionOp::Import {
2670 creation,
2671 page: page?,
2672 })],
2673 },
2674 )
2675 .await?;
2676 Ok(space)
2677 }
2678 }
2679
2680 /// Removes pages or conflict pages permanently, queued like the user's own edits (a
2681 /// move across sections imports a page there, then retires it here with `Replica::retire`).
2682 pub fn delete_pages(&self, pages: &[ExGuid]) -> Result<u64> {
2683 crate::task::blocking(self.delete_pages_async(pages))?
2684 }
2685
2686 pub fn delete_pages_async(
2687 &self,
2688 pages: &[ExGuid],
2689 ) -> impl Future<Output = Result<u64>> + Send + 'static + use<> {
2690 let replica = Arc::clone(&self.replica);
2691 let pages = pages.to_vec();
2692 async move {
2693 replica
2694 .apply_async(
2695 "",
2696 Edit {
2697 at: crate::now(),
2698 ops: vec![Op::Section(SectionOp::Delete(pages))],
2699 },
2700 )
2701 .await
2702 }
2703 }
2704
2705 pub fn status(&self, id: u64) -> Result<Option<EditStatus>> {
2706 self.replica.status(id)
2707 }
2708
2709 pub fn pending(&self) -> Result<Vec<PendingEdit>> {
2710 self.replica.pending()
2711 }
2712
2713 /// See [`Replica::written`].
2714 pub fn written(&self) -> Result<()> {
2715 self.replica.written()
2716 }
2717
2718 /// The conflict pages of each page that has them, as the local edits leave them
2719 /// (`onestore::Section::conflicts`); `page` reads one, `delete_pages` removes it.
2720 pub fn conflicts(&self) -> Result<Vec<(ExGuid, Vec<onestore::ConflictPage>)>> {
2721 self.replica.conflicts()
2722 }
2723
2724 /// The versions of each page that has them, newest first, as the local edits leave them
2725 /// (`onestore::Section::versions`); `version` reads one.
2726 pub fn versions(&self) -> Result<Vec<(ExGuid, Vec<onestore::PageVersion>)>> {
2727 self.replica.versions()
2728 }
2729
2730 /// Page `space` as its version `version` holds it; O(section).
2731 pub fn version(&self, space: ExGuid, version: ExGuid) -> Result<Page> {
2732 self.replica.version(space, version)
2733 }
2734
2735 /// Makes a page's version its current state, the page as it stood becoming the newest
2736 /// version, queued like the user's own edits.
2737 pub fn restore_version(&self, space: ExGuid, version: ExGuid, author: &str) -> Result<u64> {
2738 crate::task::blocking(self.restore_version_async(space, version, author))?
2739 }
2740
2741 pub fn restore_version_async(
2742 &self,
2743 space: ExGuid,
2744 version: ExGuid,
2745 author: &str,
2746 ) -> impl Future<Output = Result<u64>> + Send + 'static + use<> {
2747 let replica = Arc::clone(&self.replica);
2748 let author = author.to_owned();
2749 let op = SectionOp::restore(space, version);
2750 async move {
2751 replica
2752 .apply_async(
2753 &author,
2754 Edit {
2755 at: crate::now(),
2756 ops: vec![Op::Section(op?)],
2757 },
2758 )
2759 .await
2760 }
2761 }
2762
2763 /// Deletes versions of pages, queued like the user's own edits.
2764 pub fn delete_versions(&self, versions: &[(ExGuid, Vec<ExGuid>)]) -> Result<u64> {
2765 crate::task::blocking(self.delete_versions_async(versions))?
2766 }
2767
2768 pub fn delete_versions_async(
2769 &self,
2770 versions: &[(ExGuid, Vec<ExGuid>)],
2771 ) -> impl Future<Output = Result<u64>> + Send + 'static + use<> {
2772 let replica = Arc::clone(&self.replica);
2773 let ops = versions
2774 .iter()
2775 .map(|(page, versions)| {
2776 Op::Section(SectionOp::DeleteVersions {
2777 page: *page,
2778 versions: versions.clone(),
2779 })
2780 })
2781 .collect();
2782 async move {
2783 replica
2784 .apply_async(
2785 "",
2786 Edit {
2787 at: crate::now(),
2788 ops,
2789 },
2790 )
2791 .await
2792 }
2793 }
2794
2795 /// Retires an uncertain attempt after review, exporting the queue to `archive` first:
2796 /// `Mine` publishes the local edits again, `Theirs` abandons them. Neither claims the
2797 /// attempt was acknowledged.
2798 pub fn release(
2799 &self,
2800 id: u64,
2801 archive: impl AsRef<Path>,
2802 resolution: Resolution,
2803 ) -> Result<()> {
2804 self.replica.release(id, archive.as_ref(), resolution)
2805 }
2806
2807 /// Captures the queue, its images and its states in a read-only archive.
2808 pub fn export_recovery(&self, path: impl AsRef<Path>) -> Result<()> {
2809 self.replica.export_recovery(path)
2810 }
2811
2812 /// Events since the last poll, oldest first.
2813 pub fn events(&self) -> Vec<Event> {
2814 self.events
2815 .lock()
2816 .map(|events| events.try_iter().collect())
2817 .unwrap_or_default()
2818 }
2819
2820 /// Requests a synchronization attempt now, working offline included (Sync Now).
2821 pub fn wake(&self) {
2822 if let Some(worker) = &self.worker {
2823 worker.wake();
2824 }
2825 }
2826
2827 /// Publishes local edits once `pause` passes without another (`SyncWorker::set_pause`),
2828 /// as a notebook on a cloud drive does; `wake` publishes them at once.
2829 pub fn set_pause(&self, pause: Duration) {
2830 if let Some(worker) = &self.worker {
2831 worker.set_pause(pause);
2832 }
2833 }
2834
2835 /// Stops or resumes synchronizing: working offline, edits queue until `wake` or until
2836 /// working online again (OneNote's Work Offline).
2837 pub fn set_offline(&self, offline: bool) {
2838 if let Some(worker) = &self.worker {
2839 worker.set_offline(offline);
2840 }
2841 }
2842
2843 /// When the section file was last reached, why it could not be since, and what waits
2844 /// for it. `notify` runs when it is first reached and when an error comes or goes.
2845 pub fn sync_status(&self) -> Result<SyncStatus> {
2846 sync_status(
2847 &self.replica,
2848 || self.worker.as_ref().and_then(SyncWorker::synced),
2849 &self.observed,
2850 )
2851 }
2852
2853 /// The replica, which other threads may read pages from while the section is open.
2854 pub fn replica(&self) -> &Arc<Replica> {
2855 &self.replica
2856 }
2857
2858 /// Stops future sync steps; native threads finish the current operation before returning.
2859 /// The worker retains cache ownership until its in-flight operation finishes.
2860 pub fn close(mut self) -> Result<()> {
2861 match self.worker.take() {
2862 Some(worker) => worker.stop(),
2863 None => Ok(()),
2864 }
2865 }
2866
2867 /// Stops synchronization without consuming the section's recoverable model.
2868 pub fn stop_async(&mut self) -> impl Future<Output = Result<()>> + Send + 'static + use<> {
2869 let worker = self.worker.take();
2870 async move {
2871 match worker {
2872 Some(worker) => worker.stop_async().await,
2873 None => Ok(()),
2874 }
2875 }
2876 }
2877
2878 /// Makes the submitted edits locally durable and awaits synchronization before closing.
2879 pub async fn close_async(mut self) -> Result<()> {
2880 self.replica.written_async().await?;
2881 self.stop_async().await
2882 }
2883}
2884
2885/// The section file itself as the publication target, under OneNote-compatible exclusion.
2886struct FileRemote(PathBuf);
2887
2888impl Remote for FileRemote {
2889 fn read(&mut self) -> io::Result<Vec<u8>> {
2890 fs::read_file(&self.0)
2891 }
2892
2893 /// Read without the whole-file lock, which would block OneNote's readers: a change
2894 /// detector, never a snapshot to edit.
2895 fn stamp(&mut self) -> io::Result<Stamp> {
2896 use std::io::Read;
2897 let mut file = fs::File::open(&self.0)?;
2898 let mut header = [0; 1024];
2899 file.read_exact(&mut header)?;
2900 let length = file.metadata()?.len();
2901 Ok(Stamp { header, length })
2902 }
2903
2904 fn publish(&mut self, transaction: &Transaction) -> std::result::Result<(), CommitError> {
2905 fs::commit_file(transaction, &self.0)
2906 }
2907
2908 fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), CommitError> {
2909 fs::confirm_file(&self.0, base)
2910 }
2911}
2912
2913impl Drop for Section {
2914 fn drop(&mut self) {
2915 drop(self.worker.take());
2916 }
2917}
2918
2919#[cfg(test)]
2920mod tests;