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