1//! Notebooks behind the C surface: a folder or lone section from Files, or a folder on an
2//! SMB share through the embedded client. A section opens through its replica in the app's
3//! cache, which stores each edit at once and publishes it in the background.
4
5use crate::{Result, owned, report, srgb, string};
6use canvas::search::{Entry, Index, Query, Tagged};
7use notebook::{
8 Remote, Replica,
9 discover::{Folder, Reason, SectionState},
10 live::{self, Hello, Reach, share::Guest},
11 provider::Documents,
12 session::{self, Background, Event, Known, Notebook, SyncStatus},
13 smb::{Client, Credentials},
14};
15use onestore::{
16 CommitError, CommitState, ExGuid, PageCreation, PageEdit, Stamp, Transaction,
17 op::{Edit, Op, SectionOp},
18 page::Page,
19 protected::Key,
20};
21use std::{
22 collections::{BTreeMap, HashMap},
23 ffi::{CString, c_char, c_void},
24 io,
25 path::{Path, PathBuf},
26 sync::{
27 Arc, Mutex, OnceLock, Weak,
28 atomic::{AtomicBool, AtomicU8, Ordering},
29 },
30 time::{Duration, Instant},
31};
32
33/// How long an SMB request may take before the share counts as unreachable.
34const TIMEOUT: Duration = Duration::from_secs(10);
35/// The largest section file read whole.
36const LIMIT: usize = 256 * 1024 * 1024;
37/// The tab colour OneNote gives a section that stores none, as the desktop shows it.
38const SECTION_COLOR: u32 = 0x00e4_a88a;
39
40/// Called on a background thread when a notebook's sync status may have changed.
41static SYNC_WAKE: OnceLock<extern "C" fn()> = OnceLock::new();
42
43/// Has the host call `wake` whenever a notebook's closed sections report; set once, before
44/// any library opens.
45#[unsafe(no_mangle)]
46pub extern "C" fn sb_set_sync_wake(wake: extern "C" fn()) {
47 let _ = SYNC_WAKE.set(wake);
48}
49
50fn sync_woken() {
51 if let Some(wake) = SYNC_WAKE.get() {
52 wake();
53 }
54}
55
56/// Runs `body(context)` within the host's coordinated reading or writing of `path`, as
57/// `NSFileCoordinator` has other processes (file providers) wait for it, or not at all when
58/// it cannot coordinate.
59pub type Coordinator = extern "C" fn(
60 path: *const c_char,
61 write: bool,
62 body: extern "C" fn(*mut c_void),
63 context: *mut c_void,
64);
65
66static COORDINATOR: OnceLock<Coordinator> = OnceLock::new();
67
68/// Coordinates every read and write of a local notebook's files through `coordinator` from
69/// now on; set once, before any library opens.
70#[unsafe(no_mangle)]
71pub extern "C" fn sb_set_coordinator(coordinator: Coordinator) {
72 let _ = COORDINATOR.set(coordinator);
73}
74
75/// `work` within the host's coordinated access to `path`, or directly when the host
76/// coordinates nothing.
77pub(crate) fn coordinated<F: FnOnce() -> T, T>(path: &Path, write: bool, work: F) -> io::Result<T> {
78 let Some(coordinator) = COORDINATOR.get() else {
79 return Ok(work());
80 };
81 extern "C" fn run<F: FnOnce() -> T, T>(context: *mut c_void) {
82 // SAFETY: `context` is the pair below, alive for the host's call.
83 let (work, result) = unsafe { &mut *context.cast::<(Option<F>, Option<T>)>() };
84 *result = work.take().map(|work| work());
85 }
86 let mut pair = (Some(work), None);
87 let path = CString::new(path.as_os_str().as_encoded_bytes())?;
88 coordinator(path.as_ptr(), write, run::<F, T>, (&raw mut pair).cast());
89 pair.1
90 .ok_or_else(|| io::Error::other("Another app is using the notebook's file; try again."))
91}
92
93/// Calls `found(context, id, device)` for each conflict version the host's file provider keeps
94/// beside the file at `path`, as iCloud Drive keeps another device's commit that lost:
95/// `id` is a file the version's contents read from, `device` who saved it or null.
96pub type Versions = extern "C" fn(
97 path: *const c_char,
98 found: extern "C" fn(*mut c_void, *const c_char, *const c_char),
99 context: *mut c_void,
100);
101
102/// Marks version `id` of the file at `path` resolved and removes it, first keeping a copy
103/// beside the file when `keep`; false when it could not.
104pub type Retire = extern "C" fn(path: *const c_char, id: *const c_char, keep: bool) -> bool;
105
106static VERSIONS: OnceLock<(Versions, Retire)> = OnceLock::new();
107
108/// Merges the conflict versions the host lists into their files from now on, then has the
109/// host retire them; set once, before any library opens.
110#[unsafe(no_mangle)]
111pub extern "C" fn sb_set_versions(versions: Versions, retire: Retire) {
112 let _ = VERSIONS.set((versions, retire));
113}
114
115/// A local section file read and published under the host's file coordination.
116struct Coordinated(PathBuf);
117
118impl Remote for Coordinated {
119 fn read(&mut self) -> io::Result<Vec<u8>> {
120 coordinated(&self.0, false, || onestore::read_file(&self.0))?
121 }
122
123 /// The header and length, as the file remote's uncoordinated change detector reads them.
124 fn stamp(&mut self) -> io::Result<Stamp> {
125 coordinated(&self.0, false, || {
126 use std::io::Read;
127 let mut file = std::fs::File::open(&self.0)?;
128 let mut header = [0; 1024];
129 file.read_exact(&mut header)?;
130 Ok(Stamp {
131 header,
132 length: file.metadata()?.len(),
133 })
134 })?
135 }
136
137 fn publish(&mut self, transaction: &Transaction) -> std::result::Result<(), CommitError> {
138 coordinated(&self.0, true, || transaction.commit_file(&self.0)).map_err(uncommitted)?
139 }
140
141 fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), CommitError> {
142 coordinated(&self.0, true, || onestore::confirm_file(&self.0, base)).map_err(uncommitted)?
143 }
144
145 fn versions(&mut self) -> io::Result<Vec<notebook::Version>> {
146 let Some((versions, _)) = VERSIONS.get() else {
147 return Ok(Vec::new());
148 };
149 extern "C" fn found(context: *mut c_void, id: *const c_char, device: *const c_char) {
150 // SAFETY: `context` is the list below, alive for the host's call.
151 let listed = unsafe { &mut *context.cast::<Vec<notebook::Version>>() };
152 listed.push(notebook::Version {
153 id: string(id),
154 device: (!device.is_null()).then(|| string(device)),
155 });
156 }
157 let mut listed: Vec<notebook::Version> = Vec::new();
158 let path = CString::new(self.0.as_os_str().as_encoded_bytes())?;
159 versions(path.as_ptr(), found, (&raw mut listed).cast());
160 Ok(listed)
161 }
162
163 /// A version's contents never change: they read without coordination.
164 fn version(&mut self, id: &str) -> io::Result<Vec<u8>> {
165 std::fs::read(id)
166 }
167
168 fn retire(&mut self, id: &str, keep: bool) -> io::Result<()> {
169 let Some((_, retire)) = VERSIONS.get() else {
170 return Ok(());
171 };
172 let path = CString::new(self.0.as_os_str().as_encoded_bytes())?;
173 let id = CString::new(id)?;
174 if retire(path.as_ptr(), id.as_ptr(), keep) {
175 Ok(())
176 } else {
177 Err(io::Error::other("The version could not be retired"))
178 }
179 }
180}
181
182fn uncommitted(error: io::Error) -> CommitError {
183 CommitError {
184 state: CommitState::NotCommitted,
185 error,
186 }
187}
188
189/// A server and share with the account that opens it, kept to reconnect.
190pub(crate) struct Server {
191 pub(crate) address: String,
192 pub(crate) share: String,
193 pub(crate) user: String,
194 pub(crate) password: String,
195 pub(crate) domain: String,
196}
197
198impl Server {
199 pub(crate) fn connect(&self) -> io::Result<Client> {
200 Client::connect(
201 &self.address,
202 &self.share,
203 Credentials {
204 username: &self.user,
205 password: &self.password,
206 domain: &self.domain,
207 },
208 TIMEOUT,
209 )
210 }
211}
212
213/// A connected share, for browsing to a notebook folder.
214pub struct Share(Client);
215
216enum Place {
217 Folder(PathBuf),
218 /// A section file opened on its own.
219 File(PathBuf),
220 /// A notebook folder at `root` on a share; no client while the server cannot be reached.
221 Share {
222 server: Arc<Server>,
223 client: Mutex<Option<Arc<Client>>>,
224 root: String,
225 },
226 Hosted(Arc<Guest>),
227 /// A notebook folder a document provider keeps.
228 Provider(Arc<Documents>),
229 /// A section a document provider keeps, opened on its own, with its name when it opened:
230 /// none while the provider can't be reached, the host keeping the name it was picked by.
231 Document(Arc<Documents>, Option<String>),
232}
233
234#[derive(serde::Serialize, serde::Deserialize)]
235#[serde(deny_unknown_fields)]
236struct Joined {
237 share: [u8; 16],
238 secret: [u8; 16],
239 protocol: u16,
240 notebook: String,
241 host: String,
242}
243
244/// A share notebook's sections as last listed, so they open from their replicas while the
245/// server cannot be reached.
246#[derive(serde::Serialize, serde::Deserialize)]
247struct Listed {
248 tabs: Vec<Tab>,
249 /// Each readable section's file identity in hex, which names its replica, by catalog path.
250 files: BTreeMap<String, String>,
251}
252
253fn hex(bytes: &[u8]) -> String {
254 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
255}
256
257/// Every readable section's file identity in `folder` and its groups, by catalog path, as
258/// `Notebook::replicas` lists a local notebook's. `Client::read` answers a protected section
259/// as it does one mid-write (WouldBlock), so syncing one would show it in use for good.
260pub(crate) fn files(folder: &Folder, files: &mut BTreeMap<String, String>) {
261 for section in &folder.sections {
262 if matches!(section.state, SectionState::Readable { .. }) {
263 files.insert(section.path.clone(), hex(&section.file_id));
264 }
265 }
266 for group in &folder.groups {
267 self::files(group, files);
268 }
269}
270
271/// Catalog `path` from the top of the share holding a notebook at `root`.
272pub(crate) fn on_share(root: &str, path: &str) -> String {
273 if root.is_empty() {
274 path.to_owned()
275 } else {
276 format!("{root}/{path}")
277 }
278}
279
280/// When a section's file was last checked for changes, and how it was then.
281struct Checked {
282 at: Instant,
283 stamp: Vec<u8>,
284}
285
286pub struct Library {
287 /// A share's catalog may be absent while cached sections remain available.
288 notebook: Mutex<Option<Notebook>>,
289 place: Place,
290 cache: PathBuf,
291 index: Mutex<(HashMap<String, Checked>, Index)>,
292 /// Syncs the sections no session holds; none for a lone section.
293 background: Option<Background>,
294 /// The sections open for editing, by catalog path, whose sessions sync them.
295 open: Mutex<Vec<(String, Weak<Shared>)>>,
296 /// Work Offline, which sections opened later follow too.
297 offline: AtomicBool,
298 /// Keys of the protected sections unlocked this run, by catalog path; in memory only.
299 keys: Mutex<HashMap<String, Key>>,
300 /// How long edits wait for a pause in typing before they publish (`Section::set_pause`):
301 /// a file provider such as iCloud Drive uploads each publication, and makes one concurrent
302 /// with another device's a conflict version.
303 pause: Duration,
304}
305
306/// The pause edits to a file a provider keeps elsewhere wait for.
307const PAUSE: Duration = Duration::from_secs(3);
308
309#[derive(serde::Serialize, serde::Deserialize)]
310pub(crate) struct Tab {
311 pub(crate) name: String,
312 /// The catalog path `sb_section_open` takes.
313 pub(crate) path: String,
314 /// The section group holding it, `/`-separated; empty at the notebook's top.
315 pub(crate) group: String,
316 /// The tab colour in sRGB.
317 pub(crate) color: [u8; 3],
318 /// Password-protected or unreadable sections list but do not open; a protected one opens
319 /// once unlocked.
320 pub(crate) readable: bool,
321 /// Password protected and not unlocked (`sb_library_unlock`).
322 #[serde(default)]
323 pub(crate) locked: bool,
324 /// Not on this device yet, as iCloud Drive keeps it elsewhere; the host downloads it.
325 #[serde(default)]
326 pub(crate) downloading: bool,
327 /// Why the file could not be read, where retrying or repair may help.
328 #[serde(default)]
329 pub(crate) problem: Option<String>,
330}
331
332/// Titles keep OneNote's line breaks, which a one-line list shows as spaces.
333fn one_line(title: &str) -> String {
334 title.replace(|char: char| char.is_control(), " ")
335}
336
337fn stem(path: &str) -> String {
338 Path::new(path)
339 .file_stem()
340 .map(|stem| stem.to_string_lossy().into_owned())
341 .unwrap_or_default()
342}
343
344/// A folder's sections, its groups' after them, leaving out the recycle bin OneNote keeps
345/// deleted sections and pages in.
346fn tabs(folder: &Folder, unlocked: &HashMap<String, Key>, tabs: &mut Vec<Tab>) {
347 crash_report::conceal(&folder.path);
348 for section in &folder.sections {
349 crash_report::conceal(&section.path);
350 let (name, color, readable) = match &section.state {
351 SectionState::Readable { name, color, .. } => (name.clone(), *color, true),
352 SectionState::Locked => (None, None, unlocked.contains_key(&section.path)),
353 SectionState::Unreadable(_) => (None, None, false),
354 };
355 if let Some(name) = &name {
356 crash_report::conceal(name);
357 }
358 let locked = matches!(section.state, SectionState::Locked) && !readable;
359 tabs.push(Tab {
360 name: name.unwrap_or_else(|| stem(&section.path)),
361 path: section.path.clone(),
362 group: folder.path.clone(),
363 color: srgb(color.unwrap_or(SECTION_COLOR)),
364 readable,
365 locked,
366 downloading: false,
367 problem: None,
368 });
369 }
370 for entry in folder.unavailable.iter().filter(|entry| !entry.group) {
371 tabs.push(Tab {
372 name: stem(&entry.path),
373 path: entry.path.clone(),
374 group: folder.path.clone(),
375 color: srgb(SECTION_COLOR),
376 readable: false,
377 locked: false,
378 downloading: entry.reason == Reason::Evicted,
379 problem: match entry.reason {
380 Reason::InUse => Some("Section in use".into()),
381 Reason::Unreadable => Some("Can’t read this section".into()),
382 _ => None,
383 },
384 });
385 }
386 for group in &folder.groups {
387 if !group.path.ends_with("OneNote_RecycleBin") {
388 self::tabs(group, unlocked, tabs);
389 }
390 }
391}
392
393impl Library {
394 /// The notebook folder or lone section file at `path`. A folder `local` to this device
395 /// reports every change to `touched`; any other, as a file provider keeps it, gets offline
396 /// copies of its sections and is checked every few seconds.
397 pub(crate) fn open(path: &Path, cache: &Path, local: bool) -> Result<Self> {
398 crash_report::conceal(&path.to_string_lossy());
399 let (notebook, place, background) = if path.is_file() {
400 (None, Place::File(path.to_owned()), None)
401 } else {
402 let mut notebook = Notebook::open(path, cache)?;
403 let background = notebook.background_with(
404 local,
405 !local,
406 |file| Coordinated(file.to_owned()),
407 sync_woken,
408 )?;
409 background.watch(notebook.replicas());
410 (
411 Some(notebook),
412 Place::Folder(path.to_owned()),
413 Some(background),
414 )
415 };
416 Ok(Self {
417 notebook: Mutex::new(notebook),
418 place,
419 cache: cache.to_owned(),
420 index: Mutex::default(),
421 background,
422 open: Mutex::default(),
423 offline: AtomicBool::new(false),
424 keys: Default::default(),
425 pause: if local { Duration::ZERO } else { PAUSE },
426 })
427 }
428
429 /// The notebook folder `root` on `server`; while the server cannot be reached, its sections
430 /// as last listed, if it was listed before.
431 pub(crate) fn server(server: Server, root: &str, cache: &Path) -> Result<Self> {
432 let server = Arc::new(server);
433 let connect = Arc::clone(&server);
434 let background = Background::smb(root, LIMIT, move || connect.connect(), sync_woken)?;
435 let library = Self {
436 notebook: Mutex::default(),
437 place: Place::Share {
438 server,
439 client: Mutex::default(),
440 root: root.to_owned(),
441 },
442 cache: cache.to_owned(),
443 index: Mutex::default(),
444 background: Some(background),
445 open: Mutex::default(),
446 offline: AtomicBool::new(false),
447 keys: Default::default(),
448 pause: Duration::ZERO,
449 };
450 let reached = library.with_notebook(false, |_| Ok(()));
451 match library.listed() {
452 Some(listed) => library.watch(&listed.files),
453 None => reached?,
454 }
455 Ok(library)
456 }
457
458 fn hosted(
459 joined: Joined,
460 author: String,
461 device: Option<String>,
462 relay: Option<&str>,
463 local_network: bool,
464 offline: bool,
465 cache: &Path,
466 ) -> Result<Self> {
467 if joined.protocol != live::wire::VERSION {
468 return Err(io::Error::from(io::ErrorKind::Unsupported).into());
469 }
470 let mut hello = Hello::new(author, None)?;
471 hello.device = device;
472 let guest = Guest::start(
473 hello,
474 joined.share,
475 joined.secret,
476 local_network.then_some(Reach::Network),
477 relay,
478 sync_woken,
479 )?;
480 let mut notebook = match Notebook::open_hosted(Arc::clone(&guest), cache) {
481 Ok(notebook) => notebook,
482 Err(error) if guest.host().is_none() => {
483 let deadline = Instant::now() + TIMEOUT;
484 while guest.host().is_none() && guest.ended().is_none() && Instant::now() < deadline
485 {
486 std::thread::sleep(Duration::from_millis(50));
487 }
488 if guest.host().is_none() {
489 return Err(error.into());
490 }
491 Notebook::open_hosted(Arc::clone(&guest), cache)?
492 }
493 Err(error) => return Err(error.into()),
494 };
495 let background = Background::hosted(Arc::clone(&guest), sync_woken)?;
496 background.set_offline(offline);
497 background.watch(notebook.replicas());
498 Ok(Self {
499 notebook: Mutex::new(Some(notebook)),
500 place: Place::Hosted(guest),
501 cache: cache.to_owned(),
502 index: Mutex::default(),
503 background: Some(background),
504 open: Mutex::default(),
505 offline: AtomicBool::new(offline),
506 keys: Default::default(),
507 pause: Duration::ZERO,
508 })
509 }
510
511 /// The notebook folder, or with `folder` false the lone section, whose document `root` the
512 /// registered provider keeps (`sb_set_provider`). A provider `local` to this device
513 /// publishes each edit at once; any other waits for a pause in typing. While the provider
514 /// can't be reached, the folder opens as last listed and sections from their offline copies.
515 fn provider(root: &str, folder: bool, local: bool, device: &str, cache: &Path) -> Result<Self> {
516 let host = crate::provider::Host::default();
517 let name = (!folder)
518 .then(|| {
519 Some(
520 notebook::provider::Provider::document(&host, root)
521 .ok()?
522 .name,
523 )
524 })
525 .flatten();
526 let documents = Arc::new(Documents::new(host, root, cache, device));
527 let (notebook, place, background) = if folder {
528 let mut notebook = Notebook::open_provider(Arc::clone(&documents))?;
529 let background = Background::provider(Arc::clone(&documents), sync_woken)?;
530 background.watch(notebook.replicas());
531 (Some(notebook), Place::Provider(documents), Some(background))
532 } else {
533 (None, Place::Document(documents, name), None)
534 };
535 Ok(Self {
536 notebook: Mutex::new(notebook),
537 place,
538 cache: cache.to_owned(),
539 index: Mutex::default(),
540 background,
541 open: Mutex::default(),
542 offline: AtomicBool::new(false),
543 keys: Default::default(),
544 pause: if local { Duration::ZERO } else { PAUSE },
545 })
546 }
547
548 /// Has the background sync a share notebook's sections, `files` as `Listed` keeps them.
549 fn watch(&self, files: &BTreeMap<String, String>) {
550 let (Some(background), Some(replicas)) = (&self.background, self.share_replicas()) else {
551 return;
552 };
553 background.watch(
554 files
555 .iter()
556 .map(|(path, identity)| Known {
557 path: path.clone(),
558 replica: Some(replicas.join(format!("{identity}.sqlite"))),
559 found: None,
560 image: None,
561 })
562 .collect(),
563 );
564 }
565
566 /// The cache folder of a share notebook's replicas.
567 fn share_replicas(&self) -> Option<PathBuf> {
568 let Place::Share { server, root, .. } = &self.place else {
569 return None;
570 };
571 let location = notebook::location::smb(&server.address, &server.share, root);
572 Some(notebook::location::folder(&self.cache, &location))
573 }
574
575 /// Where a share notebook's last listing is kept.
576 fn listing(&self) -> Option<PathBuf> {
577 let Place::Share { server, root, .. } = &self.place else {
578 return None;
579 };
580 let name: String = format!("{}/{}/{root}", server.address, server.share)
581 .chars()
582 .map(|char| if char.is_alphanumeric() { char } else { '_' })
583 .collect();
584 Some(self.cache.join("smb").join(format!("{name}.json")))
585 }
586
587 fn listed(&self) -> Option<Listed> {
588 let bytes = std::fs::read(self.listing()?).ok()?;
589 serde_json::from_slice(&bytes).ok()
590 }
591
592 fn notebook(&self) -> std::sync::MutexGuard<'_, Option<Notebook>> {
593 self.notebook
594 .lock()
595 .unwrap_or_else(|error| error.into_inner())
596 }
597
598 fn client(&self) -> Option<Arc<Client>> {
599 match &self.place {
600 Place::Share { client, .. } => client
601 .lock()
602 .unwrap_or_else(|error| error.into_inner())
603 .clone(),
604 _ => None,
605 }
606 }
607
608 /// Runs `change`, which reads or with `write` changes the notebook's files, on the
609 /// notebook: a local folder under the host's file coordination; on a share not reached
610 /// yet or whose connection dropped, connecting again and retrying once.
611 fn with_notebook<T>(
612 &self,
613 write: bool,
614 mut change: impl FnMut(&mut Notebook) -> Result<T>,
615 ) -> Result<T> {
616 let mut notebook = self.notebook();
617 let Place::Share {
618 server,
619 client,
620 root,
621 } = &self.place
622 else {
623 let notebook = notebook.as_mut().ok_or("A lone section has no notebook")?;
624 return match &self.place {
625 Place::Folder(root) => coordinated(root, write, || change(notebook))?,
626 _ => change(notebook),
627 };
628 };
629 if let Some(current) = notebook.as_mut()
630 && let Ok(result) = change(current)
631 {
632 return Ok(result);
633 }
634 let fresh = Arc::new(server.connect()?);
635 *client.lock().unwrap_or_else(|error| error.into_inner()) = Some(Arc::clone(&fresh));
636 let reopened = notebook.insert(Notebook::open_smb(fresh, root, &self.cache)?);
637 change(reopened)
638 }
639
640 fn unlocked(&self) -> std::sync::MutexGuard<'_, HashMap<String, Key>> {
641 self.keys.lock().unwrap_or_else(|error| error.into_inner())
642 }
643
644 /// Unlocks the protected section at catalog `path` with `password`; whether it matched.
645 pub(crate) fn unlock(&self, path: &str, password: &str) -> Result<bool> {
646 let key = self.with_notebook(false, |notebook| match notebook.unlock(path, password) {
647 Ok(key) => Ok(Some(key)),
648 Err(notebook::Error::Protected(_)) => Ok(None),
649 Err(error) => Err(error.into()),
650 })?;
651 let unlocked = key.is_some();
652 if let Some(key) = key {
653 self.unlocked().insert(path.to_owned(), key);
654 }
655 Ok(unlocked)
656 }
657
658 /// The notebook's colour, COLORREF, as its table of contents held it when last read.
659 fn color(&self) -> Option<u32> {
660 self.notebook().as_ref()?.catalog().toc.as_ref()?.color
661 }
662
663 /// The notebook's sections, read again.
664 pub(crate) fn tabs(&self) -> Result<Vec<Tab>> {
665 let lone = match &self.place {
666 Place::File(file) => Some((stem(&file.to_string_lossy()), file.to_string_lossy())),
667 Place::Document(_, name) => {
668 Some((stem(name.as_deref().unwrap_or_default()), "".into()))
669 }
670 _ => None,
671 };
672 if let Some((name, path)) = lone {
673 return Ok(vec![Tab {
674 name,
675 path: path.into_owned(),
676 group: String::new(),
677 color: srgb(SECTION_COLOR),
678 readable: true,
679 locked: false,
680 downloading: false,
681 problem: None,
682 }]);
683 }
684 let listed = self.with_notebook(false, |notebook| {
685 notebook.refresh()?;
686 let mut listed = Listed {
687 tabs: Vec::new(),
688 files: BTreeMap::new(),
689 };
690 tabs(notebook.catalog(), &self.unlocked(), &mut listed.tabs);
691 if matches!(self.place, Place::Share { .. }) {
692 files(notebook.catalog(), &mut listed.files);
693 }
694 if let (Place::Folder(_) | Place::Hosted(_) | Place::Provider(_), Some(background)) =
695 (&self.place, &self.background)
696 {
697 background.watch(notebook.replicas());
698 }
699 Ok(listed)
700 });
701 let Some(listing) = self.listing() else {
702 return Ok(listed?.tabs);
703 };
704 match listed {
705 Ok(listed) => {
706 std::fs::create_dir_all(listing.parent().ok_or("No cache")?)?;
707 std::fs::write(&listing, serde_json::to_vec(&listed)?)?;
708 self.watch(&listed.files);
709 Ok(listed.tabs)
710 }
711 Err(error) => Ok(self.listed().ok_or(error)?.tabs),
712 }
713 }
714
715 /// The section at catalog `path`, through a replica in the cache, following Work
716 /// Offline. The background may hold the replica for a step, which takes a network round
717 /// trip, so a busy replica is tried again for a while.
718 pub(crate) fn section(
719 &self,
720 path: &str,
721 notify: impl Fn() + Send + Sync + 'static,
722 ) -> Result<session::Section> {
723 let deadline = Instant::now() + TIMEOUT * 3;
724 let notify = Arc::new(notify);
725 let section = loop {
726 let notify = Arc::clone(&notify);
727 match self.section_once(path, move || notify()) {
728 Err(error)
729 if Instant::now() < deadline
730 && error
731 .downcast_ref::<notebook::Error>()
732 .is_some_and(notebook::Error::busy) =>
733 {
734 std::thread::sleep(Duration::from_millis(50));
735 }
736 opened => break opened?,
737 }
738 };
739 section.set_offline(self.offline.load(Ordering::Relaxed));
740 section.set_pause(self.pause);
741 if let Some(background) = &self.background {
742 background.hold(path, &section);
743 }
744 Ok(section)
745 }
746
747 fn section_once(
748 &self,
749 path: &str,
750 notify: impl Fn() + Send + Sync + 'static,
751 ) -> Result<session::Section> {
752 let offline = self.offline.load(Ordering::Relaxed);
753 match &self.place {
754 Place::File(file) => Ok(session::Section::open_with(
755 file,
756 &self.cache,
757 |file| Ok(Coordinated(file.to_owned())),
758 offline,
759 notify,
760 )?),
761 Place::Folder(_) => {
762 let notebook = self.notebook();
763 let notebook = notebook.as_ref().ok_or("A lone section has no notebook")?;
764 let connect = |file: &Path| Ok(Coordinated(file.to_owned()));
765 Ok(match self.unlocked().get(path) {
766 Some(key) => {
767 notebook.section_unlocked_with(path, key, connect, offline, notify)?
768 }
769 None => notebook.section_with(path, connect, offline, notify)?,
770 })
771 }
772 Place::Share { server, root, .. } => {
773 let file = on_share(root, path);
774 let key = self.unlocked().get(path).cloned();
775 // A protected section's replica is named by the identity its file holds.
776 let cache = match &key {
777 Some(_) => self
778 .notebook()
779 .as_ref()
780 .ok_or("The notebook hasn’t been read")?
781 .replica_path(path)?,
782 None => {
783 let identity = self
784 .listed()
785 .and_then(|mut listed| listed.files.remove(path))
786 .ok_or("The notebook hasn’t listed this section")?;
787 let replicas = self.share_replicas().ok_or("Not a share")?;
788 replicas.join(format!("{identity}.sqlite"))
789 }
790 };
791 std::fs::create_dir_all(cache.parent().ok_or("No cache")?)?;
792 let replica = Replica::open_or_create(&cache, key.as_ref(), || {
793 let client = self.client().ok_or_else(|| {
794 std::io::Error::new(
795 std::io::ErrorKind::NotConnected,
796 "The server can’t be reached, and this section hasn’t been opened here before.",
797 )
798 })?;
799 Ok(client.read_storage(&file, LIMIT)?)
800 })?;
801 let server = Arc::clone(server);
802 Ok(session::Section::resume_smb(
803 file,
804 replica,
805 LIMIT,
806 move || server.connect(),
807 offline,
808 notify,
809 )?)
810 }
811 Place::Hosted(guest) => Ok(session::Section::resume_hosted(
812 path.to_owned(),
813 self.served_replica(path)?,
814 Arc::clone(guest),
815 offline,
816 notify,
817 )?),
818 Place::Provider(documents) => Ok(session::Section::resume_provider(
819 path.to_owned(),
820 self.served_replica(path)?,
821 Arc::clone(documents),
822 offline,
823 notify,
824 )?),
825 Place::Document(documents, _) => Ok(session::Section::open_provider(
826 Arc::clone(documents),
827 offline,
828 notify,
829 )?),
830 }
831 }
832
833 /// The replica of the section at catalog `path` of a notebook another program serves,
834 /// made from the section where there is none.
835 fn served_replica(&self, path: &str) -> Result<Replica> {
836 let notebook = self.notebook();
837 let notebook = notebook
838 .as_ref()
839 .ok_or_else(|| io::Error::from(io::ErrorKind::NotConnected))?;
840 let cache = notebook.replica_path(path)?;
841 std::fs::create_dir_all(cache.parent().unwrap_or(&self.cache))?;
842 let key = self.unlocked().get(path).cloned();
843 Ok(Replica::open_or_create(&cache, key.as_ref(), || {
844 notebook.read_section(path)
845 })?)
846 }
847}
848
849/// What saving has come to, as the page shows it.
850#[repr(u8)]
851#[derive(Clone, Copy)]
852pub(crate) enum Status {
853 Saved = 1,
854 Saving = 2,
855 /// The file cannot be reached; edits wait in the cache.
856 Offline = 3,
857 /// An edit was refused or the replica failed.
858 NotSaving = 4,
859}
860
861/// An open section as its page views share it.
862pub(crate) struct Shared {
863 pub section: session::Section,
864 /// Who the edits name, as OneNote names the Office user.
865 pub author: Mutex<String>,
866 pub status: AtomicU8,
867}
868
869impl Shared {
870 pub fn author(&self) -> String {
871 self.author
872 .lock()
873 .unwrap_or_else(|error| error.into_inner())
874 .clone()
875 }
876
877 pub fn apply(&self, edit: Edit) -> Result<()> {
878 self.apply_with(edit, |_| {})
879 }
880
881 pub fn apply_with(
882 &self,
883 edit: Edit,
884 reply: impl FnOnce(std::result::Result<u64, notebook::Error>) + Send + 'static,
885 ) -> Result<()> {
886 self.status.store(Status::Saving as u8, Ordering::Relaxed);
887 if let Err(error) = self.section.apply_with(&self.author(), edit, reply) {
888 self.status
889 .store(Status::NotSaving as u8, Ordering::Relaxed);
890 return Err(error.into());
891 }
892 Ok(())
893 }
894
895 /// The page in `space` as a view shows it: a conflict page highlighted and read-only.
896 pub fn page(&self, space: ExGuid) -> Result<(Page, bool)> {
897 let page = self.section.page(space)?;
898 let conflict = self
899 .section
900 .conflicts()?
901 .into_iter()
902 .flat_map(|(_, versions)| versions)
903 .find(|version| version.space == space);
904 Ok(match conflict {
905 Some(version) => (canvas::conflict::highlighted(page, &version.objects), true),
906 None => (page, false),
907 })
908 }
909}
910
911/// A section open for editing, with the notebook it belongs to.
912pub struct Section {
913 pub(crate) shared: Arc<Shared>,
914 pub(crate) library: Arc<Library>,
915}
916
917#[derive(serde::Serialize)]
918pub(crate) struct Row {
919 pub(crate) id: String,
920 pub(crate) title: String,
921 /// 1 at the top, 2 for a subpage and so on.
922 pub(crate) level: u32,
923 /// Conflict pages: versions with changes a merge could not take.
924 pub(crate) versions: Vec<Version>,
925}
926
927#[derive(serde::Serialize)]
928pub(crate) struct Version {
929 pub(crate) id: String,
930 /// Whose version it is.
931 pub(crate) user: String,
932 /// When the merge made it, in seconds since 1970.
933 pub(crate) created: Option<i64>,
934}
935
936pub(crate) fn unix(filetime: u64) -> i64 {
937 (filetime / 10_000_000) as i64 - 11_644_473_600
938}
939
940/// FILETIME now: when an edit happened, which its modification times record.
941pub(crate) fn filetime() -> u64 {
942 let unix = std::time::SystemTime::now()
943 .duration_since(std::time::UNIX_EPOCH)
944 .unwrap_or_default();
945 (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100)
946}
947
948/// A page OneNote 2010 would create now, titled `date` and `time` as the host formats
949/// them: the long date and the short time; titled in the Default font's face.
950fn dated(author: &str, date: &str, time: &str) -> Result<PageCreation> {
951 let font = crate::options().1.clone();
952 Ok(PageCreation::new(None, Some(""), author)?
953 .titled_in(&font.face, font.color)?
954 .dated(date, time)?)
955}
956
957impl Section {
958 /// The notebook's themes; none for a lone section.
959 pub(crate) fn themes(&self) -> notebook::sidecar::themes::Themes {
960 self.library
961 .notebook()
962 .as_ref()
963 .and_then(|notebook| crate::report(notebook.themes().map_err(Into::into)))
964 .unwrap_or_default()
965 }
966
967 /// Merges `change` into the notebook's themes.
968 pub(crate) fn save_themes(&self, change: notebook::sidecar::themes::Themes) -> Result<()> {
969 self.library
970 .notebook()
971 .as_ref()
972 .ok_or("A section on its own keeps no themes")?
973 .save_themes(change)?;
974 Ok(())
975 }
976
977 /// The theme scopes of page `id`: the page, its section and the notebook, where known.
978 pub(crate) fn scopes(&self, id: &str) -> Result<[Option<notebook::sidecar::themes::Scope>; 3]> {
979 use notebook::sidecar::themes::Scope;
980 let (page, _) = self.shared.page(id.parse()?)?;
981 Ok([
982 page.identity.map(Scope::page),
983 self.shared.section.identity().ok().map(Scope::section),
984 Some(Scope::Notebook),
985 ])
986 }
987
988 /// The paragraph styles page `identity` of this section wears in its theme, in this
989 /// section's colour; none where no scope names a theme.
990 pub(crate) fn sheet(
991 &self,
992 identity: Option<[u8; 16]>,
993 ) -> std::collections::BTreeMap<String, onestore::page::Definition> {
994 let section = self.shared.section.identity().ok();
995 let Some(theme) = self.themes().effective(section, identity) else {
996 return Default::default();
997 };
998 let color = section.and_then(|section| {
999 let notebook = self.library.notebook();
1000 let listed = notebook
1001 .as_ref()?
1002 .catalog()
1003 .sections()
1004 .find(|listed| listed.file_id == section)?;
1005 match listed.state {
1006 SectionState::Readable { color, .. } => color,
1007 _ => None,
1008 }
1009 });
1010 theme.sheet(color)
1011 }
1012
1013 /// The section at catalog `path` of `library`, its edits naming `author`.
1014 pub(crate) fn open(
1015 library: Arc<Library>,
1016 path: &str,
1017 author: String,
1018 notify: impl Fn() + Send + Sync + 'static,
1019 ) -> Result<Self> {
1020 let shared = Arc::new(Shared {
1021 section: library.section(path, notify)?,
1022 author: Mutex::new(author),
1023 status: AtomicU8::new(0),
1024 });
1025 let mut open = library
1026 .open
1027 .lock()
1028 .unwrap_or_else(|error| error.into_inner());
1029 shared
1030 .section
1031 .set_offline(library.offline.load(Ordering::Relaxed));
1032 open.push((path.to_owned(), Arc::downgrade(&shared)));
1033 drop(open);
1034 Ok(Self { shared, library })
1035 }
1036
1037 pub(crate) fn rows(&self) -> Result<Vec<Row>> {
1038 let section = &self.shared.section;
1039 let conflicts = section.conflicts()?;
1040 Ok(section
1041 .pages()?
1042 .into_iter()
1043 .map(|(space, title, level)| Row {
1044 id: space.to_string(),
1045 title: one_line(&title),
1046 level,
1047 versions: conflicts
1048 .iter()
1049 .find(|(page, _)| *page == space)
1050 .map(|(_, versions)| {
1051 versions
1052 .iter()
1053 .map(|version| Version {
1054 id: version.space.to_string(),
1055 user: version.user.clone(),
1056 created: version.created.map(unix),
1057 })
1058 .collect()
1059 })
1060 .unwrap_or_default(),
1061 })
1062 .collect())
1063 }
1064
1065 /// Adds a page at the end, or a subpage after `parent` and its subpages, as OneNote's
1066 /// New Page and New Subpage do; returns its space.
1067 pub(crate) fn new_page(
1068 &self,
1069 parent: Option<ExGuid>,
1070 date: &str,
1071 time: &str,
1072 ) -> Result<ExGuid> {
1073 let creation = dated(&self.shared.author(), date, time)?;
1074 let space = creation.space();
1075 let mut ops = vec![Op::Section(SectionOp::Create(creation))];
1076 if let Some(parent) = parent {
1077 let pages = self.shared.section.pages()?;
1078 let at = pages
1079 .iter()
1080 .position(|(space, ..)| *space == parent)
1081 .ok_or("The page is not listed")?;
1082 let level = pages[at].2;
1083 let next = pages[at + 1..]
1084 .iter()
1085 .find(|(.., below)| *below <= level)
1086 .map(|(space, ..)| *space);
1087 ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::move_to(
1088 space,
1089 next,
1090 (level + 1).min(3),
1091 )?])));
1092 }
1093 self.shared.apply(Edit {
1094 at: filetime(),
1095 ops,
1096 })?;
1097 Ok(space)
1098 }
1099
1100 /// Recycles an ordinary notebook page before submitting its deletion; a lone section's
1101 /// page or a conflict page goes for good. A section left empty gets a new page.
1102 pub(crate) fn delete_page(&self, space: ExGuid, date: &str, time: &str) -> Result<()> {
1103 let section = &self.shared.section;
1104 let listed = section.pages()?;
1105 let mut ops = vec![Op::Section(SectionOp::Delete(vec![space]))];
1106 if listed.iter().any(|(page, ..)| *page == space) {
1107 if !matches!(self.library.place, Place::File(_) | Place::Document(..)) {
1108 let page = section.page(space)?;
1109 let author = self.shared.author();
1110 self.library.with_notebook(true, |notebook| {
1111 Ok(notebook.recycle_pages(std::slice::from_ref(&page), &author)?)
1112 })?;
1113 }
1114 if listed.len() == 1 {
1115 ops.push(Op::Section(SectionOp::Create(dated(
1116 &self.shared.author(),
1117 date,
1118 time,
1119 )?)));
1120 }
1121 }
1122 self.shared.apply(Edit {
1123 at: filetime(),
1124 ops,
1125 })
1126 }
1127
1128 /// Puts the pages `moved` where `order` lists them, at their levels, as one edit; false
1129 /// where none of them is listed any more.
1130 pub(crate) fn arrange(&self, order: &[(ExGuid, u32)], moved: &[ExGuid]) -> Result<bool> {
1131 let edits = session::arrange(&self.shared.section.pages()?, order, moved)?;
1132 if edits.is_empty() {
1133 return Ok(false);
1134 }
1135 self.shared.apply(Edit {
1136 at: filetime(),
1137 ops: vec![Op::Section(SectionOp::Pages(edits))],
1138 })?;
1139 Ok(true)
1140 }
1141
1142 /// Applies the events since the last poll to the shared status.
1143 pub(crate) fn poll(&self) -> u32 {
1144 let mut flags = 0;
1145 for event in self.shared.section.events() {
1146 let status = match event {
1147 Event::Changed(_) => {
1148 flags |= LISTED | CHANGED;
1149 continue;
1150 }
1151 Event::Rejected { error, .. } => {
1152 eprintln!("snowbound: saving failed: {error}");
1153 flags |= REJECTED;
1154 Status::NotSaving
1155 }
1156 Event::Attempt {
1157 status: notebook::EditStatus::Published { .. },
1158 ..
1159 } => Status::Saved,
1160 Event::Attempt { .. } => Status::Saving,
1161 Event::Unreachable(error) => {
1162 eprintln!("snowbound: the section cannot be reached: {error}");
1163 Status::Offline
1164 }
1165 Event::Failed(error) => {
1166 eprintln!("snowbound: not saving: {error}");
1167 Status::NotSaving
1168 }
1169 };
1170 self.shared.status.store(status as u8, Ordering::Relaxed);
1171 }
1172 flags
1173 }
1174
1175 /// Waits until every edit is in the cache, then publishes them within `limit`: true when
1176 /// nothing waits to be published.
1177 pub(crate) fn flush(&self, limit: Duration) -> bool {
1178 let section = &self.shared.section;
1179 if report(section.written().map_err(Into::into)).is_none() {
1180 return false;
1181 }
1182 let deadline = Instant::now() + limit;
1183 loop {
1184 match section.pending() {
1185 Ok(pending) if pending.is_empty() => return true,
1186 Ok(_) if Instant::now() < deadline => {
1187 section.wake();
1188 std::thread::sleep(Duration::from_millis(50));
1189 }
1190 _ => return false,
1191 }
1192 }
1193 }
1194}
1195
1196/// `sb_section_poll`: the page list or conflict pages changed.
1197const LISTED: u32 = 1;
1198/// A change made elsewhere reached the section; open pages reload.
1199const CHANGED: u32 = 2;
1200/// The section refused an edit; the page shown returns to what is stored.
1201const REJECTED: u32 = 4;
1202
1203#[derive(serde::Serialize)]
1204#[serde(rename_all = "camelCase")]
1205pub(crate) struct Found {
1206 /// Which of the notebooks searched holds the page.
1207 pub(crate) notebook: usize,
1208 /// The section's catalog path.
1209 pub(crate) section: String,
1210 pub(crate) page: String,
1211 pub(crate) title: String,
1212 /// Every word is in the title, as OneNote's "Title contains" lists it.
1213 pub(crate) in_title: bool,
1214 /// Where the words match `title`, as UTF-16 `[start, end]`.
1215 pub(crate) title_hits: Vec<[usize; 2]>,
1216 /// The line around the first match.
1217 pub(crate) snippet: String,
1218 pub(crate) snippet_hits: Vec<[usize; 2]>,
1219}
1220
1221/// Pages of `libraries` holding every word of `query` in their title or text, as the desktop
1222/// searches and lists them; only of the section at catalog `section` where given.
1223pub(crate) fn search(
1224 libraries: &[&Library],
1225 section: Option<&str>,
1226 query: &str,
1227) -> Result<Vec<Found>> {
1228 let query = Query::new(query);
1229 let mut found = Vec::new();
1230 for (notebook, library) in libraries.iter().enumerate() {
1231 library.indexed(|index| {
1232 let scope = |key: &str| section.is_none_or(|section| key == section);
1233 found.extend(
1234 index
1235 .search(&query, scope)
1236 .into_iter()
1237 .map(|found| (notebook, found)),
1238 );
1239 })?;
1240 }
1241 found.sort_by_key(|(_, found)| found.rank());
1242 Ok(found
1243 .into_iter()
1244 .map(|(notebook, found)| Found {
1245 notebook,
1246 page: found.space.to_string(),
1247 title: one_line(&found.title),
1248 in_title: found.in_title,
1249 title_hits: utf16(&found.title, found.title_hits),
1250 snippet_hits: utf16(&found.snippet, found.snippet_hits),
1251 snippet: found.snippet,
1252 section: found.section,
1253 })
1254 .collect())
1255}
1256
1257/// Byte ranges of `text` as UTF-16 `[start, end]`, as `NSRange` counts.
1258fn utf16(text: &str, ranges: Vec<std::ops::Range<usize>>) -> Vec<[usize; 2]> {
1259 let units = |end: usize| text[..end].encode_utf16().count();
1260 ranges
1261 .into_iter()
1262 .map(|range| [units(range.start), units(range.end)])
1263 .collect()
1264}
1265
1266impl Library {
1267 /// The notebook's tagged paragraphs in page order, as OneNote's Tags Summary lists
1268 /// them: once for each of their tags.
1269 pub(crate) fn tagged(&self) -> Result<Vec<Tag>> {
1270 self.indexed(|index| {
1271 index
1272 .tagged(|_| true)
1273 .into_iter()
1274 .map(|tagged: Tagged| Tag {
1275 section: tagged.section,
1276 page: tagged.space.to_string(),
1277 title: one_line(&tagged.title),
1278 paragraph: tagged.paragraph.to_string(),
1279 name: tagged.name,
1280 shape: tagged.shape,
1281 checked: tagged.checked,
1282 text: tagged.text,
1283 })
1284 .collect()
1285 })
1286 }
1287
1288 /// `read` over the notebook's pages: the sections open as their edits leave them, others
1289 /// as stored, read again when their file changed.
1290 fn indexed<T>(&self, read: impl FnOnce(&Index) -> T) -> Result<T> {
1291 let open = self.open_sections();
1292 let tabs: Vec<String> = self
1293 .tabs()?
1294 .into_iter()
1295 .filter(|tab| tab.readable)
1296 .map(|tab| tab.path)
1297 .collect();
1298 let mut indexed = self.index.lock().unwrap_or_else(|error| error.into_inner());
1299 let (checked, index) = &mut *indexed;
1300 for path in &tabs {
1301 match open.iter().find(|(open, _)| open == path) {
1302 Some((_, shared)) => {
1303 index_replica(index, path, shared.section.replica())?;
1304 checked.remove(path);
1305 }
1306 None => {
1307 report(self.reindex(path, checked, index));
1308 }
1309 }
1310 }
1311 index.retain(|entry| tabs.contains(&entry.section));
1312 Ok(read(index))
1313 }
1314
1315 /// Each section's sync status in the notebook's order: an open section's from its
1316 /// session, the others' from the background.
1317 pub(crate) fn sync_status(&self) -> Vec<Synced> {
1318 let mut sections = self
1319 .background
1320 .as_ref()
1321 .map(Background::status)
1322 .unwrap_or_default();
1323 for (path, shared) in self.open_sections() {
1324 let Some(status) = report(shared.section.sync_status().map_err(Into::into)) else {
1325 continue;
1326 };
1327 match sections.iter_mut().find(|(listed, _)| *listed == path) {
1328 Some((_, listed)) => *listed = status,
1329 None => sections.push((path, status)),
1330 }
1331 }
1332 sections
1333 .into_iter()
1334 .map(|(path, status): (String, SyncStatus)| Synced {
1335 state: status.state() as u8,
1336 synced: status.synced.map(unix),
1337 queued: status.queued,
1338 error: status.error.map(|error| error.to_string()),
1339 path,
1340 })
1341 .collect()
1342 }
1343
1344 /// Publishes every edit the notebook's replicas hold within `limit`, then lets go of the
1345 /// notebook as OneNote closes one: nothing syncs from then on, and each replica holding
1346 /// nothing unpublished is deleted once no session holds it. An error leaves it syncing.
1347 pub(crate) fn close(&self, limit: Duration) -> Result<()> {
1348 self.flush(limit)?;
1349 if let Some(background) = &self.background {
1350 background.discard();
1351 }
1352 Ok(())
1353 }
1354
1355 /// Publishes every edit the notebook's replicas hold within `limit`, keeping them and the
1356 /// offline copies.
1357 pub(crate) fn flush(&self, limit: Duration) -> Result<()> {
1358 const WAITING: &str = "Some changes haven’t been saved to the notebook yet.";
1359 let deadline = Instant::now() + limit;
1360 let open = self.open_sections();
1361 for (_, shared) in &open {
1362 shared.section.written().map_err(|_| WAITING)?;
1363 while !shared.section.pending()?.is_empty() {
1364 if Instant::now() >= deadline {
1365 return Err(WAITING.into());
1366 }
1367 shared.section.wake();
1368 std::thread::sleep(Duration::from_millis(50));
1369 }
1370 }
1371 let Some(background) = &self.background else {
1372 return Ok(());
1373 };
1374 let before: HashMap<String, Option<u64>> = background
1375 .status()
1376 .into_iter()
1377 .map(|(path, status)| (path, status.synced))
1378 .collect();
1379 // Sections without a replica hold nothing to publish.
1380 let replicas: Vec<&String> = {
1381 let notebook = self.notebook();
1382 before
1383 .keys()
1384 .filter(|path| !open.iter().any(|(held, _)| held == *path))
1385 .filter(|path| {
1386 notebook
1387 .as_ref()
1388 .and_then(|notebook| notebook.replica_path(path).ok())
1389 .is_some_and(|replica| replica.exists())
1390 })
1391 .collect()
1392 };
1393 loop {
1394 // Working offline, the background checks only when woken.
1395 background.wake();
1396 let status = background.status();
1397 let mut waiting = replicas.iter().filter_map(|path| {
1398 match status.iter().find(|(listed, _)| listed == *path) {
1399 Some((_, status))
1400 if status.queued == 0
1401 && status.error.is_none()
1402 && status.synced > before[*path] =>
1403 {
1404 None
1405 }
1406 Some((_, status)) => Some(status.error.as_ref().map(ToString::to_string)),
1407 None => Some(None),
1408 }
1409 });
1410 let Some(error) = waiting.next() else { break };
1411 if Instant::now() >= deadline {
1412 return Err(error.unwrap_or_else(|| WAITING.to_owned()).into());
1413 }
1414 std::thread::sleep(Duration::from_millis(50));
1415 }
1416 Ok(())
1417 }
1418
1419 /// Runs `change` on the section at catalog `path`: through its session where one is open,
1420 /// else through one opened for it and closed after, its edits left to the background.
1421 fn with_section<T>(
1422 &self,
1423 path: &str,
1424 change: impl FnOnce(&session::Section) -> Result<T>,
1425 ) -> Result<T> {
1426 if let Some((_, shared)) = self
1427 .open_sections()
1428 .into_iter()
1429 .find(|(open, _)| open == path)
1430 {
1431 return change(&shared.section);
1432 }
1433 let section = self.section(path, || {})?;
1434 let result = change(&section);
1435 section.close()?;
1436 result
1437 }
1438
1439 /// Moves page `space` of the section at catalog `from` into the section at `to`, before
1440 /// `before` at `level` (last without), as OneNote moves a page dropped on a section's tab:
1441 /// it keeps its identity, title, date and content there. A section left without pages
1442 /// gains one titled `date` and `time`; `discard`, the page `to` gained when this page
1443 /// left it, goes while it holds nothing, as Undo takes the move back.
1444 #[allow(clippy::too_many_arguments)]
1445 pub(crate) fn move_page(
1446 &self,
1447 from: &str,
1448 space: ExGuid,
1449 to: &str,
1450 before: Option<ExGuid>,
1451 level: u32,
1452 discard: Option<ExGuid>,
1453 author: &str,
1454 [date, time]: [&str; 2],
1455 ) -> Result<MovedPage> {
1456 if from == to {
1457 return Err("The page is in that section already".into());
1458 }
1459 let (page, was) = self.with_section(from, |section| {
1460 let listed = section.pages()?;
1461 let at = (listed.iter().position(|(listed, ..)| *listed == space))
1462 .ok_or("The page is not listed")?;
1463 let next = listed.get(at + 1).map(|(next, ..)| *next);
1464 Ok((section.replica().moving(space)?, (next, listed[at].2)))
1465 })?;
1466 let import = session::moved(page.page(), author)?;
1467 let Op::Section(SectionOp::Import { creation, .. }) = &import else {
1468 unreachable!("notebook::session::moved imports");
1469 };
1470 let arrived = creation.space();
1471 self.with_section(to, |section| {
1472 let listed = section.pages()?;
1473 let has = |page: &ExGuid| listed.iter().any(|(space, ..)| space == page);
1474 let mut ops = vec![import];
1475 let before = before.filter(has);
1476 if before.is_some() || level != 1 {
1477 ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::move_to(
1478 arrived, before, level,
1479 )?])));
1480 }
1481 if let Some(discard) = discard.filter(has)
1482 && session::blank(&section.page(discard)?)
1483 {
1484 ops.push(Op::Section(SectionOp::Delete(vec![discard])));
1485 }
1486 let edit = Edit {
1487 at: filetime(),
1488 ops,
1489 };
1490 Ok(section.replica().apply(author, edit)?)
1491 })?;
1492 let retired = self.with_section(from, |section| {
1493 let mut ops = vec![Op::Section(SectionOp::Delete(vec![space]))];
1494 let added = match section.pages()?.as_slice() {
1495 [(only, ..)] if *only == space => {
1496 let fresh = dated(author, date, time)?;
1497 let added = fresh.space();
1498 ops.push(Op::Section(SectionOp::Create(fresh)));
1499 Some(added)
1500 }
1501 _ => None,
1502 };
1503 let edit = Edit {
1504 at: filetime(),
1505 ops,
1506 };
1507 section.replica().retire(author, edit, page)?;
1508 Ok(added)
1509 });
1510 let id = |space: Option<ExGuid>| space.map(|space| space.to_string());
1511 let (added, copied) = match retired {
1512 Ok(added) => (id(added), None),
1513 Err(error) => (None, Some(error.to_string())),
1514 };
1515 Ok(MovedPage {
1516 page: arrived.to_string(),
1517 added,
1518 before: id(was.0),
1519 level: was.1,
1520 copied,
1521 })
1522 }
1523
1524 /// Puts the section or group at catalog `path` into the group `folder` ("" for the
1525 /// notebook's top), moving it there unless it is there, as OneNote moves one dragged
1526 /// onto a group, then orders `folder` as `paths` lists its entries, as they are named
1527 /// once moved; entries left out follow in their order. Returns its catalog path.
1528 pub(crate) fn place(&self, path: &str, folder: &str, paths: &[String]) -> Result<String> {
1529 let parent = path.rsplit_once('/').map_or("", |(parent, _)| parent);
1530 self.with_notebook(true, |notebook| {
1531 notebook.refresh()?;
1532 let placed = match parent == folder {
1533 true => path.to_owned(),
1534 false => notebook.move_entry(path, folder)?,
1535 };
1536 if !paths.is_empty() {
1537 let paths: Vec<&str> = paths.iter().map(String::as_str).collect();
1538 notebook.reorder(folder, &paths)?;
1539 }
1540 Ok(placed)
1541 })
1542 }
1543
1544 fn open_sections(&self) -> Vec<(String, Arc<Shared>)> {
1545 let mut open = self.open.lock().unwrap_or_else(|error| error.into_inner());
1546 open.retain(|(_, shared)| shared.strong_count() > 0);
1547 open.iter()
1548 .filter_map(|(path, shared)| Some((path.clone(), shared.upgrade()?)))
1549 .collect()
1550 }
1551}
1552
1553/// A page `Library::move_page` moved, by id: where it went, the page the section it left
1554/// gained, and where it was there: before `before`, at `level`.
1555#[derive(serde::Serialize)]
1556pub(crate) struct MovedPage {
1557 pub(crate) page: String,
1558 pub(crate) added: Option<String>,
1559 pub(crate) before: Option<String>,
1560 pub(crate) level: u32,
1561 /// Why the page also stayed where it was: the move only copied it.
1562 pub(crate) copied: Option<String>,
1563}
1564
1565#[derive(serde::Serialize)]
1566pub(crate) struct Synced {
1567 pub(crate) path: String,
1568 /// `SyncState` by its place in the enum, best first.
1569 pub(crate) state: u8,
1570 /// When the section file was last reached, in seconds since 1970.
1571 pub(crate) synced: Option<i64>,
1572 /// Changes the file does not hold yet.
1573 pub(crate) queued: u64,
1574 pub(crate) error: Option<String>,
1575}
1576
1577/// A tagged paragraph, from `sb_library_tagged`.
1578#[derive(serde::Serialize)]
1579pub(crate) struct Tag {
1580 pub(crate) section: String,
1581 pub(crate) page: String,
1582 pub(crate) title: String,
1583 pub(crate) paragraph: String,
1584 pub(crate) name: String,
1585 pub(crate) shape: u16,
1586 pub(crate) checked: bool,
1587 pub(crate) text: String,
1588}
1589
1590impl Library {
1591 /// Reads the section at catalog `path` into `index` again when its file changed, and
1592 /// checks at most every 30 seconds.
1593 fn reindex(
1594 &self,
1595 path: &str,
1596 checked: &mut HashMap<String, Checked>,
1597 index: &mut Index,
1598 ) -> Result<()> {
1599 if checked
1600 .get(path)
1601 .is_some_and(|known| known.at.elapsed() < Duration::from_secs(30))
1602 {
1603 return Ok(());
1604 }
1605 let (stamp, read): (Vec<u8>, Reader) = match &self.place {
1606 Place::Share { root, .. } => {
1607 let client = self.client().ok_or("Not a share")?;
1608 let file = on_share(root, path);
1609 let stamp = client.stamp(&file)?;
1610 let mut key = stamp.header.to_vec();
1611 key.extend(stamp.length.to_le_bytes());
1612 (
1613 key,
1614 Box::new(move || Ok(client.read_storage(&file, LIMIT)?)),
1615 )
1616 }
1617 Place::Folder(root) => local(root.join(path))?,
1618 Place::File(file) => local(file.clone())?,
1619 // Indexed from its session while it is open.
1620 Place::Document(..) => return Ok(()),
1621 Place::Hosted(_) | Place::Provider(_) => {
1622 let cache = self
1623 .notebook()
1624 .as_ref()
1625 .ok_or_else(|| io::Error::from(io::ErrorKind::NotConnected))?
1626 .replica_path(path)?;
1627 let key = self.unlocked().get(path).cloned();
1628 let replica = Replica::open_or_create(cache, key.as_ref(), || {
1629 Err(io::Error::from(io::ErrorKind::NotFound).into())
1630 })?;
1631 index_replica(index, path, &replica)?;
1632 checked.remove(path);
1633 return Ok(());
1634 }
1635 };
1636 if checked.get(path).is_none_or(|known| known.stamp != stamp) {
1637 let pages = session::stored_pages(&read()?)?;
1638 index.retain(|entry| entry.section != path);
1639 for stored in pages {
1640 let modified = stored.modified.map_or(0, |filetime| {
1641 (filetime / 10_000_000).saturating_sub(11_960_006_400)
1642 });
1643 index.set(Entry::new(path, stored.space, &stored.page, modified));
1644 }
1645 }
1646 checked.insert(
1647 path.to_owned(),
1648 Checked {
1649 at: Instant::now(),
1650 stamp,
1651 },
1652 );
1653 Ok(())
1654 }
1655}
1656
1657fn index_replica(index: &mut Index, path: &str, replica: &Replica) -> Result<()> {
1658 let mut entries = Vec::new();
1659 for (space, ..) in replica.pages()? {
1660 let modified = index.get(path, space).map_or(0, |entry| entry.modified);
1661 entries.push(Entry::new(path, space, &replica.page(space)?, modified));
1662 }
1663 index.retain(|entry| entry.section != path);
1664 entries.into_iter().for_each(|entry| index.set(entry));
1665 Ok(())
1666}
1667
1668type Reader = Box<dyn Fn() -> Result<Vec<u8>>>;
1669
1670/// A local file's change key, its length and modification time, with its reader.
1671fn local(file: PathBuf) -> Result<(Vec<u8>, Reader)> {
1672 let metadata = std::fs::metadata(&file)?;
1673 let modified = metadata
1674 .modified()?
1675 .duration_since(std::time::UNIX_EPOCH)
1676 .unwrap_or_default();
1677 let mut key = metadata.len().to_le_bytes().to_vec();
1678 key.extend(modified.as_nanos().to_le_bytes());
1679 Ok((
1680 key,
1681 Box::new(move || Ok(coordinated(&file, false, || onestore::read_file(&file))??)),
1682 ))
1683}
1684
1685/// Writes `error` for the host to show, freed with `sb_string_free`.
1686fn failed(error: Box<dyn std::error::Error>, out: *mut *mut c_char) {
1687 eprintln!("snowbound: {error}");
1688 if !out.is_null() {
1689 // SAFETY: the caller passes null or a place for one string.
1690 unsafe { out.write(owned(error.to_string())) };
1691 }
1692}
1693
1694fn boxed<T>(result: Result<T>, error: *mut *mut c_char) -> *mut T {
1695 match result {
1696 Ok(value) => Box::into_raw(Box::new(value)),
1697 Err(cause) => {
1698 failed(cause, error);
1699 std::ptr::null_mut()
1700 }
1701 }
1702}
1703
1704fn shared(result: Result<Library>, error: *mut *mut c_char) -> *const Library {
1705 match result {
1706 Ok(library) => Arc::into_raw(Arc::new(library)),
1707 Err(cause) => {
1708 failed(cause, error);
1709 std::ptr::null()
1710 }
1711 }
1712}
1713
1714pub(crate) fn json(value: Result<impl serde::Serialize>) -> *mut c_char {
1715 report(value.and_then(|value| Ok(serde_json::to_string(&value)?)))
1716 .map_or(std::ptr::null_mut(), owned)
1717}
1718
1719pub(crate) fn optional(text: *const c_char) -> Option<String> {
1720 (!text.is_null()).then(|| string(text))
1721}
1722
1723/// Runs on the joining thread, with a token the host keeps alive until `sb_live_join`
1724/// returns; false cancels the join, and `approving` reports pending host approval.
1725pub type JoinWait = extern "C" fn(token: usize, approving: bool) -> bool;
1726
1727/// Joins through explicitly supplied relay/LAN routes, blocking off the UI thread. Returns
1728/// private JSON containing `credential`, or a machine-readable `refusal`; free it with
1729/// `sb_string_free`. The caller must durably keep the credential before opening its hosted library.
1730/// Cancellation cannot undo an admission the host has already granted.
1731///
1732/// # Safety
1733/// `author`, `code` and `password` are NUL-terminated UTF-8; `device` and `relay` may be null.
1734/// `waiting` and its token remain valid until this call returns; callbacks never overlap.
1735#[unsafe(no_mangle)]
1736pub unsafe extern "C" fn sb_live_join(
1737 author: *const c_char,
1738 device: *const c_char,
1739 code: *const c_char,
1740 password: *const c_char,
1741 relay: *const c_char,
1742 local_network: bool,
1743 waiting: JoinWait,
1744 token: usize,
1745) -> *mut c_char {
1746 use live::share::Refusal;
1747 let password = zeroize::Zeroizing::new(string(password));
1748 let relay = optional(relay);
1749 let joined = Hello::new(string(author), None)
1750 .map_err(|_| Refusal::Unreachable(live::Trouble::Other))
1751 .and_then(|mut hello| {
1752 hello.device = optional(device);
1753 live::share::join_while(
1754 hello,
1755 &string(code),
1756 &password,
1757 local_network.then_some(Reach::Network),
1758 relay.as_deref(),
1759 |approving| waiting(token, approving),
1760 )
1761 });
1762 let value = match joined {
1763 Ok(welcome) => serde_json::json!({"credential": Joined {
1764 share: welcome.share,
1765 secret: welcome.secret,
1766 protocol: live::wire::VERSION,
1767 notebook: welcome.notebook,
1768 host: welcome.host,
1769 }}),
1770 Err(refusal) => {
1771 let reason = match refusal {
1772 Refusal::Malformed => "malformed",
1773 Refusal::Version { theirs_newer } => {
1774 return json(Ok(
1775 serde_json::json!({"refusal": "version", "theirs_newer": theirs_newer}),
1776 ));
1777 }
1778 Refusal::Wrong => "wrong",
1779 Refusal::NoOne => "no_one",
1780 Refusal::Expired => "expired",
1781 Refusal::TooMany(wait) => {
1782 return json(Ok(
1783 serde_json::json!({"refusal": "too_many", "retry_seconds": wait.map(|wait| wait.as_secs_f64())}),
1784 ));
1785 }
1786 Refusal::Busy => "busy",
1787 Refusal::Unreachable(trouble) => {
1788 let (trouble, status) = match trouble {
1789 live::Trouble::Dns => ("dns", None),
1790 live::Trouble::Unreachable => ("unreachable", None),
1791 live::Trouble::TimedOut => ("timed_out", None),
1792 live::Trouble::ProxyUnreachable => ("proxy_unreachable", None),
1793 live::Trouble::ProxyAuthentication => ("proxy_authentication", None),
1794 live::Trouble::ProxyRefused(status) => ("proxy_refused", Some(status)),
1795 live::Trouble::Certificate => ("certificate", None),
1796 live::Trouble::Blocked => ("blocked", None),
1797 live::Trouble::Other => ("other", None),
1798 };
1799 return json(Ok(
1800 serde_json::json!({"refusal": "unreachable", "trouble": trouble, "status": status}),
1801 ));
1802 }
1803 Refusal::TimedOut => "timed_out",
1804 Refusal::Declined => "declined",
1805 Refusal::Cancelled => "cancelled",
1806 Refusal::NotAdmitted => "not_admitted",
1807 };
1808 serde_json::json!({"refusal": reason})
1809 }
1810 };
1811 json(Ok(value))
1812}
1813
1814/// Reopens an already persisted join against its original replicas. A cached notebook
1815/// opens while the host is absent; the first opening waits at most ten seconds to meet it.
1816/// `offline` applies before cached edits are watched.
1817/// Neither this function nor `sb_live_join` persists credentials.
1818///
1819/// # Safety
1820/// `credential`, `author` and `cache` are NUL-terminated UTF-8; `device` and `relay` may be
1821/// null. `credential` is the private object returned by `sb_live_join`, without its wrapper.
1822/// `error` is null or a place for a string; credential parse errors never echo its contents.
1823#[unsafe(no_mangle)]
1824pub unsafe extern "C" fn sb_library_hosted(
1825 credential: *const c_char,
1826 author: *const c_char,
1827 device: *const c_char,
1828 relay: *const c_char,
1829 local_network: bool,
1830 offline: bool,
1831 cache: *const c_char,
1832 error: *mut *mut c_char,
1833) -> *const Library {
1834 let credential = zeroize::Zeroizing::new(string(credential));
1835 let relay = optional(relay);
1836 shared(
1837 serde_json::from_str(&credential)
1838 .map_err(|_| io::Error::from(io::ErrorKind::InvalidData).into())
1839 .and_then(|joined| {
1840 Library::hosted(
1841 joined,
1842 string(author),
1843 optional(device),
1844 relay.as_deref(),
1845 local_network,
1846 offline,
1847 Path::new(&string(cache)),
1848 )
1849 }),
1850 error,
1851 )
1852}
1853
1854/// Derived Live Share status (`connected`, `not_connected`, `stopped`, or `removed`) and
1855/// the connected host's name, never its credential; null for other notebook sources.
1856/// On return to connected, the host can call `sb_library_sync_now` while not Work Offline.
1857#[unsafe(no_mangle)]
1858pub extern "C" fn sb_library_live_status(library: &Library) -> *mut c_char {
1859 let Place::Hosted(guest) = &library.place else {
1860 return std::ptr::null_mut();
1861 };
1862 let host = guest.host();
1863 let state = match (host.is_some(), guest.ended()) {
1864 (true, _) => "connected",
1865 (_, Some(live::share::Ended::Stopped)) => "stopped",
1866 (_, Some(live::share::Ended::Removed)) => "removed",
1867 _ => "not_connected",
1868 };
1869 json(Ok(
1870 serde_json::json!({"state": state, "host": host.map(|host| host.name.clone())}),
1871 ))
1872}
1873
1874/// The notebook folder or lone section file at `path`, its section replicas kept in the
1875/// directory `cache`; null with `error` set if it cannot be read. A folder `local` to this
1876/// device has the host report its changes to `sb_library_touched`; any other is checked
1877/// every few seconds and kept in offline copies.
1878///
1879/// # Safety
1880/// `path` and `cache` are NUL-terminated UTF-8; `error` is null or a place for a string.
1881#[unsafe(no_mangle)]
1882pub unsafe extern "C" fn sb_library_open(
1883 path: *const c_char,
1884 cache: *const c_char,
1885 local: bool,
1886 error: *mut *mut c_char,
1887) -> *const Library {
1888 shared(
1889 Library::open(Path::new(&string(path)), Path::new(&string(cache)), local),
1890 error,
1891 )
1892}
1893
1894/// The notebook folder `root`, `/`-separated from the top of `share` on the server at
1895/// `address` (`host` or `host:port`), opened as `user` or as a guest when `user` is empty.
1896/// While the server cannot be reached, a notebook listed before opens with its sections as
1897/// last listed. Blocks for up to ten seconds.
1898///
1899/// # Safety
1900/// Every string is NUL-terminated UTF-8; `error` is null or a place for a string.
1901#[unsafe(no_mangle)]
1902pub unsafe extern "C" fn sb_library_server(
1903 address: *const c_char,
1904 share: *const c_char,
1905 user: *const c_char,
1906 password: *const c_char,
1907 domain: *const c_char,
1908 root: *const c_char,
1909 cache: *const c_char,
1910 error: *mut *mut c_char,
1911) -> *const Library {
1912 let server = server(address, share, user, password, domain);
1913 shared(
1914 Library::server(server, &string(root), Path::new(&string(cache))),
1915 error,
1916 )
1917}
1918
1919/// Creates the notebook folder `path` holding one section with one page titled with `date`
1920/// and `time`, as OneNote's New Notebook does; `sb_library_open` opens it. False, with
1921/// `error` set, when it cannot be made.
1922///
1923/// # Safety
1924/// Every string is NUL-terminated UTF-8; `error` is null or a place for a string.
1925#[unsafe(no_mangle)]
1926pub unsafe extern "C" fn sb_notebook_create(
1927 path: *const c_char,
1928 cache: *const c_char,
1929 author: *const c_char,
1930 date: *const c_char,
1931 time: *const c_char,
1932 error: *mut *mut c_char,
1933) -> bool {
1934 use notebook::sidecar::themes::{Assignment, DEFAULT, Scope, Themes};
1935 let created = dated(&string(author), &string(date), &string(time)).and_then(|page| {
1936 let notebook = Notebook::create(string(path), string(cache), Notebook::NEW_COLOR, &page)?;
1937 notebook.save_themes(Themes {
1938 assignments: vec![Assignment {
1939 scope: Scope::Notebook,
1940 theme: Some(DEFAULT.into()),
1941 assigned: filetime(),
1942 }],
1943 ..Default::default()
1944 })?;
1945 Ok(())
1946 });
1947 created.map_err(|cause| failed(cause, error)).is_ok()
1948}
1949
1950/// Moves the replicas of the notebook folder or lone section file the app has just moved
1951/// from `from` to `to`, so that its edits waiting there still publish. False, with `error`
1952/// set, when they cannot be moved.
1953///
1954/// # Safety
1955/// Every string is NUL-terminated UTF-8; `error` is null or a place for a string.
1956#[unsafe(no_mangle)]
1957pub unsafe extern "C" fn sb_notebook_moved(
1958 cache: *const c_char,
1959 from: *const c_char,
1960 to: *const c_char,
1961 error: *mut *mut c_char,
1962) -> bool {
1963 let moved = || -> Result<()> {
1964 let from = notebook::location::local(Path::new(&string(from)))?;
1965 let to = notebook::location::local(Path::new(&string(to)))?;
1966 Ok(notebook::location::moved(
1967 Path::new(&string(cache)),
1968 &from,
1969 &to,
1970 )?)
1971 };
1972 moved().map_err(|cause| failed(cause, error)).is_ok()
1973}
1974
1975/// The notebook's sections read again, as JSON: each with `name`, `path`, `group`,
1976/// `color` as sRGB bytes, `readable`, `downloading` and `problem`, in the notebook's order; null
1977/// if it cannot be read.
1978#[unsafe(no_mangle)]
1979pub extern "C" fn sb_library_sections(library: &Library) -> *mut c_char {
1980 json(library.tabs())
1981}
1982
1983/// The notebook's colour as `0xRRGGBB`, as its sections were last read; -1 where it has none.
1984#[unsafe(no_mangle)]
1985pub extern "C" fn sb_library_color(library: &Library) -> i32 {
1986 library.color().map_or(-1, |color| {
1987 let [red, green, blue] = srgb(color);
1988 i32::from_be_bytes([0, red, green, blue])
1989 })
1990}
1991
1992/// Creates a section named `name` in the group at catalog path `folder` ("" for the
1993/// notebook's top) holding one page titled with `date` and `time`, as OneNote creates one;
1994/// returns its catalog path, or null.
1995///
1996/// # Safety
1997/// Every string is NUL-terminated UTF-8.
1998#[unsafe(no_mangle)]
1999pub unsafe extern "C" fn sb_library_new_section(
2000 library: &Library,
2001 folder: *const c_char,
2002 name: *const c_char,
2003 author: *const c_char,
2004 date: *const c_char,
2005 time: *const c_char,
2006) -> *mut c_char {
2007 let (folder, name) = (string(folder), string(name));
2008 let page = dated(&string(author), &string(date), &string(time));
2009 report(page.and_then(|page| {
2010 library.with_notebook(true, |notebook| {
2011 Ok(notebook.create_section(&folder, &name, &page)?)
2012 })
2013 }))
2014 .map_or(std::ptr::null_mut(), owned)
2015}
2016
2017/// Moves page `id` of the section at catalog `from` into the section at `to` (`Library::move_page`),
2018/// before page `before` at `level`, or last with `before` null; `discard`, unless null, is
2019/// the page `to` gained when this page left it. Returns JSON with `page`, its id there,
2020/// `added`, the page titled `date` and `time` that `from` gained, or null, and `before` and
2021/// `level`, where it was in `from`, and `copied`, why it also stayed in `from` where the move
2022/// only copied it, or null; null when it cannot move. Writes to both sections, so call it
2023/// off the main thread.
2024///
2025/// # Safety
2026/// Every string is NUL-terminated UTF-8; `before` and `discard` may be null.
2027#[unsafe(no_mangle)]
2028pub unsafe extern "C" fn sb_library_move_page(
2029 library: &Library,
2030 from: *const c_char,
2031 id: *const c_char,
2032 to: *const c_char,
2033 before: *const c_char,
2034 level: u32,
2035 discard: *const c_char,
2036 author: *const c_char,
2037 date: *const c_char,
2038 time: *const c_char,
2039) -> *mut c_char {
2040 let space = |text: *const c_char| optional(text).map(|id| id.parse()).transpose();
2041 json((|| {
2042 library.move_page(
2043 &string(from),
2044 string(id).parse()?,
2045 &string(to),
2046 space(before)?,
2047 level,
2048 space(discard)?,
2049 &string(author),
2050 [&string(date), &string(time)],
2051 )
2052 })())
2053}
2054
2055/// Puts the section or group at catalog `path` into the group at catalog path `folder` (""
2056/// for the notebook's top), moving it unless it is there, then orders `folder` as `paths`, a
2057/// JSON array of catalog paths as they are named after the move; entries left out follow
2058/// in their order. Returns its catalog path, or null.
2059///
2060/// # Safety
2061/// Every string is NUL-terminated UTF-8.
2062#[unsafe(no_mangle)]
2063pub unsafe extern "C" fn sb_library_place(
2064 library: &Library,
2065 path: *const c_char,
2066 folder: *const c_char,
2067 paths: *const c_char,
2068) -> *mut c_char {
2069 let placed = serde_json::from_str::<Vec<String>>(&string(paths))
2070 .map_err(Into::into)
2071 .and_then(|paths| library.place(&string(path), &string(folder), &paths));
2072 report(placed).map_or(std::ptr::null_mut(), owned)
2073}
2074
2075/// Pages of the `count` notebooks at `libraries` whose title or text holds `query`, title
2076/// matches first and then the most recently changed, as JSON: each with `notebook` (its
2077/// place in `libraries`), `section` (a catalog path), `page`, `title`, `inTitle`,
2078/// `snippet`, and `titleHits` and `snippetHits`, UTF-16 `[start, end]`. Only the section
2079/// at catalog path `section` where non-null. Open sections are searched as their edits leave
2080/// them; reads the notebooks' sections, so call it off the main thread.
2081///
2082/// # Safety
2083/// `libraries` holds `count` libraries; `query` and a non-null `section` are NUL-terminated
2084/// UTF-8.
2085#[unsafe(no_mangle)]
2086pub unsafe extern "C" fn sb_search(
2087 libraries: *const &Library,
2088 count: usize,
2089 section: *const c_char,
2090 query: *const c_char,
2091) -> *mut c_char {
2092 // SAFETY: the caller passes `count` libraries.
2093 let libraries = unsafe { std::slice::from_raw_parts(libraries, count) };
2094 json(search(
2095 libraries,
2096 optional(section).as_deref(),
2097 &string(query),
2098 ))
2099}
2100
2101/// The notebook's tagged paragraphs in page order as JSON, once for each of their tags:
2102/// each with `section`, `page`, `title`, `paragraph`, `name`, `shape` (the tag's symbol, 0
2103/// for a highlighting tag), `checked` and `text`. Open sections are read as their edits
2104/// leave them; reads the notebook's sections, so call it off the main thread.
2105#[unsafe(no_mangle)]
2106pub extern "C" fn sb_library_tagged(library: &Library) -> *mut c_char {
2107 json(library.tagged())
2108}
2109
2110/// Unlocks the password-protected section at catalog `path` with `password`, as OneNote's
2111/// Protected Section dialog does; false for a wrong password. The key stays in memory until
2112/// `sb_library_lock_all`.
2113///
2114/// # Safety
2115/// `path` and `password` are NUL-terminated UTF-8.
2116#[unsafe(no_mangle)]
2117pub unsafe extern "C" fn sb_library_unlock(
2118 library: &Library,
2119 path: *const c_char,
2120 password: *const c_char,
2121) -> bool {
2122 let password = zeroize::Zeroizing::new(string(password));
2123 report(library.unlock(&string(path), &password)).unwrap_or(false)
2124}
2125
2126/// Locks every protected section of the notebook, forgetting their keys: they open again only
2127/// once unlocked.
2128#[unsafe(no_mangle)]
2129pub extern "C" fn sb_library_lock_all(library: &Library) {
2130 library.unlocked().clear();
2131}
2132
2133/// Each section's sync status as JSON, in the notebook's order: `path`, `state` (`SyncState`
2134/// by its place, best first), `synced` (seconds since 1970, or null before the file was
2135/// reached), `queued` changes and `error`.
2136#[unsafe(no_mangle)]
2137pub extern "C" fn sb_library_sync_status(library: &Library) -> *mut c_char {
2138 json(Ok(library.sync_status()))
2139}
2140
2141/// Works offline, or online again: edits wait in the cache until then or until
2142/// `sb_library_sync_now`. Sections opened later follow.
2143#[unsafe(no_mangle)]
2144pub extern "C" fn sb_library_set_offline(library: &Library, offline: bool) {
2145 let mut open = library
2146 .open
2147 .lock()
2148 .unwrap_or_else(|error| error.into_inner());
2149 library.offline.store(offline, Ordering::Relaxed);
2150 if let Some(background) = &library.background {
2151 background.set_offline(offline);
2152 }
2153 open.retain(|(_, shared)| shared.strong_count() > 0);
2154 let sections: Vec<_> = open
2155 .iter()
2156 .filter_map(|(_, shared)| shared.upgrade())
2157 .collect();
2158 for shared in &sections {
2159 shared.section.set_offline(offline);
2160 }
2161 // Releasing the last Shared can join its replica's threads.
2162 drop(open);
2163}
2164
2165/// Checks the sections at or below `path`, `/`-separated from the notebook's folder, as the
2166/// host's watch on a local folder reports them changed; `""` names the folder.
2167///
2168/// # Safety
2169/// `path` is NUL-terminated UTF-8.
2170#[unsafe(no_mangle)]
2171pub unsafe extern "C" fn sb_library_touched(library: &Library, path: *const c_char) {
2172 if let Some(background) = &library.background {
2173 background.touched(&[string(path)]);
2174 }
2175}
2176
2177/// Syncs every section of the notebook now, working offline included.
2178#[unsafe(no_mangle)]
2179pub extern "C" fn sb_library_sync_now(library: &Library) {
2180 if let Some(background) = &library.background {
2181 background.wake();
2182 }
2183 for (_, shared) in library.open_sections() {
2184 shared.section.wake();
2185 }
2186}
2187
2188/// Publishes every edit the notebook holds within `seconds`, then lets go of it
2189/// (`Library::close`) so its folder can be moved or deleted: false, with `error` set, while
2190/// an edit is still unpublished, and the notebook syncs on.
2191///
2192/// # Safety
2193/// `error` is null or a place for a string.
2194#[unsafe(no_mangle)]
2195pub unsafe extern "C" fn sb_library_close(
2196 library: &Library,
2197 seconds: f64,
2198 error: *mut *mut c_char,
2199) -> bool {
2200 library
2201 .close(Duration::from_secs_f64(seconds))
2202 .map_err(|cause| failed(cause, error))
2203 .is_ok()
2204}
2205
2206/// Publishes every edit the notebook holds within `seconds`, keeping its offline copies, as an
2207/// app does that stops while the notebook stays open: false, with `error` set, while an edit
2208/// is still unpublished. Close Notebook is `sb_library_close`.
2209///
2210/// # Safety
2211/// `error` is null or a place for a string.
2212#[unsafe(no_mangle)]
2213pub unsafe extern "C" fn sb_library_flush(
2214 library: &Library,
2215 seconds: f64,
2216 error: *mut *mut c_char,
2217) -> bool {
2218 library
2219 .flush(Duration::from_secs_f64(seconds))
2220 .map_err(|cause| failed(cause, error))
2221 .is_ok()
2222}
2223
2224/// The notebook folder whose document is `root`, or with `folder` false the lone section
2225/// `root` names, as the provider `sb_set_provider` registered keeps it; `local` as
2226/// `sb_library_open` takes it, and `device` naming this device on the conflict pages its own
2227/// edits make when another device's upload replaced them. While the provider can't be reached, a folder listed before
2228/// opens as last listed, and sections opened before open from their copies; a revoked grant
2229/// refuses to open and keeps them.
2230///
2231/// # Safety
2232/// `root`, `device` and `cache` are NUL-terminated UTF-8; `error` is null or a place for a
2233/// string.
2234#[unsafe(no_mangle)]
2235pub unsafe extern "C" fn sb_library_provider(
2236 root: *const c_char,
2237 folder: bool,
2238 local: bool,
2239 device: *const c_char,
2240 cache: *const c_char,
2241 error: *mut *mut c_char,
2242) -> *const Library {
2243 let cache = string(cache);
2244 shared(
2245 Library::provider(
2246 &string(root),
2247 folder,
2248 local,
2249 &string(device),
2250 Path::new(&cache),
2251 ),
2252 error,
2253 )
2254}
2255
2256/// # Safety
2257/// `library` came from `sb_library_open`, `sb_library_server`, `sb_library_hosted` or
2258/// `sb_library_provider` and is not used again.
2259#[unsafe(no_mangle)]
2260pub unsafe extern "C" fn sb_library_free(library: *const Library) {
2261 drop(unsafe { Arc::from_raw(library) });
2262}
2263
2264/// Connects to `share` on the server at `address` (`host` or `host:port`) as `user`, or
2265/// as a guest when `user` is empty. Blocks for up to ten seconds.
2266///
2267/// # Safety
2268/// Every string is NUL-terminated UTF-8; `error` is null or a place for a string.
2269#[unsafe(no_mangle)]
2270pub unsafe extern "C" fn sb_share_connect(
2271 address: *const c_char,
2272 share: *const c_char,
2273 user: *const c_char,
2274 password: *const c_char,
2275 domain: *const c_char,
2276 error: *mut *mut c_char,
2277) -> *mut Share {
2278 let server = server(address, share, user, password, domain);
2279 boxed(server.connect().map_err(Into::into).map(Share), error)
2280}
2281
2282fn server(
2283 address: *const c_char,
2284 share: *const c_char,
2285 user: *const c_char,
2286 password: *const c_char,
2287 domain: *const c_char,
2288) -> Server {
2289 let mut address = string(address);
2290 if !address.contains(':') {
2291 address.push_str(":445");
2292 }
2293 Server {
2294 address,
2295 share: string(share),
2296 user: string(user),
2297 password: string(password),
2298 domain: string(domain),
2299 }
2300}
2301
2302#[derive(serde::Serialize)]
2303struct Listing {
2304 /// The folder holds a notebook's table of contents.
2305 notebook: bool,
2306 /// Folders, then section files, by name.
2307 folders: Vec<String>,
2308 sections: Vec<String>,
2309}
2310
2311/// The folder at `path` on the share, as JSON: `notebook`, `folders` and `sections`.
2312///
2313/// # Safety
2314/// `path` is NUL-terminated UTF-8.
2315#[unsafe(no_mangle)]
2316pub unsafe extern "C" fn sb_share_list(share: &Share, path: *const c_char) -> *mut c_char {
2317 json(
2318 share
2319 .0
2320 .read_dir(&string(path), 100_000)
2321 .map_err(Into::into)
2322 .map(|entries| {
2323 let extension = |name: &str, wanted: &str| {
2324 Path::new(name)
2325 .extension()
2326 .is_some_and(|extension| extension.eq_ignore_ascii_case(wanted))
2327 };
2328 // Hidden and system folders, OneNote's recycle bin among them, stay out.
2329 let shown = |name: &str| !name.starts_with('.') && name != "OneNote_RecycleBin";
2330 let mut listing = Listing {
2331 notebook: entries
2332 .iter()
2333 .any(|entry| extension(&entry.name, "onetoc2")),
2334 folders: Vec::new(),
2335 sections: Vec::new(),
2336 };
2337 for entry in entries.into_iter().filter(|entry| shown(&entry.name)) {
2338 if entry.attributes & 0x10 != 0 {
2339 listing.folders.push(entry.name);
2340 } else if extension(&entry.name, "one") {
2341 listing.sections.push(entry.name);
2342 }
2343 }
2344 listing.folders.sort_by_key(|name| name.to_lowercase());
2345 listing.sections.sort_by_key(|name| name.to_lowercase());
2346 listing
2347 }),
2348 )
2349}
2350
2351/// # Safety
2352/// `share` came from `sb_share_connect` and is not used again.
2353#[unsafe(no_mangle)]
2354pub unsafe extern "C" fn sb_share_free(share: *mut Share) {
2355 drop(unsafe { Box::from_raw(share) });
2356}
2357
2358/// Called on a background thread with the token `sb_section_open` took when the section
2359/// has events for `sb_section_poll`.
2360pub type Wake = extern "C" fn(token: usize);
2361
2362/// Opens the section at catalog `path` of `library` for editing, its edits naming
2363/// `author`. Reads the whole section the first time, so call it off the main thread.
2364///
2365/// # Safety
2366/// `path` and `author` are NUL-terminated UTF-8; `error` is null or a place for a string.
2367#[unsafe(no_mangle)]
2368pub unsafe extern "C" fn sb_section_open(
2369 library: &Library,
2370 path: *const c_char,
2371 author: *const c_char,
2372 wake: Wake,
2373 token: usize,
2374 error: *mut *mut c_char,
2375) -> *mut Section {
2376 // SAFETY: the host keeps the library alive while this call runs.
2377 let library = unsafe {
2378 Arc::increment_strong_count(library);
2379 Arc::from_raw(library as *const Library)
2380 };
2381 boxed(
2382 Section::open(library, &string(path), string(author), move || wake(token)),
2383 error,
2384 )
2385}
2386
2387/// The section's pages in order as JSON: each with `id`, `title`, `level` and `versions`,
2388/// its conflict pages, each with `id`, `user` and `created` (seconds since 1970, or null).
2389#[unsafe(no_mangle)]
2390pub extern "C" fn sb_section_pages(section: &Section) -> *mut c_char {
2391 json(section.rows())
2392}
2393
2394/// Takes the section's events: `LISTED` (1) the page list or conflict pages changed,
2395/// `CHANGED` (2) a change made elsewhere arrived, so open pages reload; `REJECTED` (4) an
2396/// edit was refused. Writes the saving status: 1 saved, 2 saving, 3 offline, 4 not saving,
2397/// or 0 before anything happened.
2398#[unsafe(no_mangle)]
2399pub extern "C" fn sb_section_poll(section: &Section, status: &mut u8) -> u32 {
2400 let flags = section.poll();
2401 *status = section.shared.status.load(Ordering::Relaxed);
2402 flags
2403}
2404
2405/// Adds a page, or with `parent` a subpage beneath that page, titled with `date` and
2406/// `time`; returns its id, or null.
2407///
2408/// # Safety
2409/// `date` and `time` are NUL-terminated UTF-8; `parent` too unless null.
2410#[unsafe(no_mangle)]
2411pub unsafe extern "C" fn sb_section_new_page(
2412 section: &Section,
2413 parent: *const c_char,
2414 date: *const c_char,
2415 time: *const c_char,
2416) -> *mut c_char {
2417 let page = optional(parent)
2418 .map(|parent| Ok::<_, Box<dyn std::error::Error>>(parent.parse()?))
2419 .transpose()
2420 .and_then(|parent| section.new_page(parent, &string(date), &string(time)));
2421 report(page).map_or(std::ptr::null_mut(), |space| owned(space.to_string()))
2422}
2423
2424/// The notebook's themes for page `id` as JSON: `themes`, each with `id` and `name`, then
2425/// the id each of `page`, `section` and `notebook` names itself, or null.
2426///
2427/// # Safety
2428/// `id` is NUL-terminated UTF-8.
2429#[unsafe(no_mangle)]
2430pub unsafe extern "C" fn sb_section_themes(section: &Section, id: *const c_char) -> *mut c_char {
2431 use notebook::sidecar::themes::Scope;
2432 #[derive(serde::Serialize)]
2433 struct Listed {
2434 id: String,
2435 name: String,
2436 }
2437 #[derive(serde::Serialize)]
2438 struct Shown {
2439 themes: Vec<Listed>,
2440 page: Option<String>,
2441 section: Option<String>,
2442 notebook: Option<String>,
2443 }
2444 let shown = (|| {
2445 let scopes = section.scopes(&string(id))?;
2446 let themes = section.themes();
2447 let named = |scope: &Option<Scope>| themes.assigned(scope.as_ref()?).map(|theme| theme.id);
2448 Ok(Shown {
2449 themes: themes
2450 .all()
2451 .into_iter()
2452 .map(|theme| Listed {
2453 id: theme.id,
2454 name: theme.name,
2455 })
2456 .collect(),
2457 page: named(&scopes[0]),
2458 section: named(&scopes[1]),
2459 notebook: named(&scopes[2]),
2460 })
2461 })();
2462 json(shown)
2463}
2464
2465/// Gives theme `theme` (null for none of its own) to page `id` (`scope` 0), its section (1)
2466/// or its notebook (2); the page wears it when next opened. Writes to the notebook, so call
2467/// it off the main thread.
2468///
2469/// # Safety
2470/// Every string is NUL-terminated UTF-8; `theme` may be null.
2471#[unsafe(no_mangle)]
2472pub unsafe extern "C" fn sb_section_set_theme(
2473 section: &Section,
2474 id: *const c_char,
2475 scope: u8,
2476 theme: *const c_char,
2477) -> bool {
2478 use notebook::sidecar::themes::{Assignment, Themes};
2479 let set = (|| {
2480 let scope = section
2481 .scopes(&string(id))?
2482 .into_iter()
2483 .nth(usize::from(scope))
2484 .flatten()
2485 .ok_or("No such scope")?;
2486 section.save_themes(Themes {
2487 assignments: vec![Assignment {
2488 scope,
2489 theme: optional(theme),
2490 assigned: filetime(),
2491 }],
2492 ..Default::default()
2493 })
2494 })();
2495 report(set).is_some()
2496}
2497
2498/// Recycles notebook page `id` before submitting its deletion; a lone section's page or a
2499/// conflict page goes for good. A section left empty gets one titled `date` and `time`.
2500/// Writes to the notebook, so call it off the main thread.
2501///
2502/// # Safety
2503/// Every string is NUL-terminated UTF-8.
2504#[unsafe(no_mangle)]
2505pub unsafe extern "C" fn sb_section_delete_page(
2506 section: &Section,
2507 id: *const c_char,
2508 date: *const c_char,
2509 time: *const c_char,
2510) -> bool {
2511 let deleted = string(id)
2512 .parse()
2513 .map_err(Into::into)
2514 .and_then(|space| section.delete_page(space, &string(date), &string(time)));
2515 report(deleted).is_some()
2516}
2517
2518/// Puts the pages `moved`, a JSON array of ids, where `order`, a JSON array of `{id, level}`
2519/// listing the section's pages, places them, at their levels, as one edit: a page dragged
2520/// to a new place, made a subpage or promoted, or put back as Undo does. False when none of
2521/// them is listed any more.
2522///
2523/// # Safety
2524/// `order` and `moved` are NUL-terminated UTF-8.
2525#[unsafe(no_mangle)]
2526pub unsafe extern "C" fn sb_section_arrange(
2527 section: &Section,
2528 order: *const c_char,
2529 moved: *const c_char,
2530) -> bool {
2531 #[derive(serde::Deserialize)]
2532 struct Placed {
2533 id: String,
2534 level: u32,
2535 }
2536 let arranged = (|| {
2537 let order: Vec<Placed> = serde_json::from_str(&string(order))?;
2538 let order = order
2539 .into_iter()
2540 .map(|placed| Ok((placed.id.parse()?, placed.level)))
2541 .collect::<Result<Vec<_>>>()?;
2542 let moved: Vec<String> = serde_json::from_str(&string(moved))?;
2543 let moved = moved
2544 .iter()
2545 .map(|id| Ok(id.parse()?))
2546 .collect::<Result<Vec<_>>>()?;
2547 section.arrange(&order, &moved)
2548 })();
2549 report(arranged).unwrap_or(false)
2550}
2551
2552/// Waits for every edit to be stored in the cache, then up to `seconds` for them to be
2553/// published; true when nothing waits. For the host's background time.
2554#[unsafe(no_mangle)]
2555pub extern "C" fn sb_section_flush(section: &Section, seconds: f64) -> bool {
2556 section.flush(Duration::from_secs_f64(seconds))
2557}
2558
2559/// Names `author` in the section's edits from now on, as when the reader changes their name.
2560///
2561/// # Safety
2562/// `author` is NUL-terminated UTF-8.
2563#[unsafe(no_mangle)]
2564pub unsafe extern "C" fn sb_section_set_author(section: &Section, author: *const c_char) {
2565 *section
2566 .shared
2567 .author
2568 .lock()
2569 .unwrap_or_else(|error| error.into_inner()) = string(author);
2570}
2571
2572/// Asks the section to look for changes made elsewhere now, as when the app returns.
2573#[unsafe(no_mangle)]
2574pub extern "C" fn sb_section_wake(section: &Section) {
2575 section.shared.section.wake();
2576}
2577
2578/// # Safety
2579/// `section` came from `sb_section_open` and is not used again.
2580#[unsafe(no_mangle)]
2581pub unsafe extern "C" fn sb_section_free(section: *mut Section) {
2582 drop(unsafe { Box::from_raw(section) });
2583}
2584
2585#[cfg(test)]
2586mod tests {
2587 use super::*;
2588
2589 #[test]
2590 fn deleting_a_cold_offline_share_page_preserves_its_source_and_queue() {
2591 let directory = tempfile::tempdir().unwrap();
2592 let root = directory.path().join("notebook");
2593 let cache = directory.path().join("cache");
2594 std::fs::create_dir(&root).unwrap();
2595 let bytes =
2596 onestore::create_section("Read.one", "Offline source content", "Fixture").unwrap();
2597 std::fs::write(root.join("Read.one"), &bytes).unwrap();
2598 let notebook = Notebook::open(&root, &cache).unwrap();
2599 let mut listed = Listed {
2600 tabs: Vec::new(),
2601 files: BTreeMap::new(),
2602 };
2603 tabs(notebook.catalog(), &HashMap::new(), &mut listed.tabs);
2604 files(notebook.catalog(), &mut listed.files);
2605 let identity = listed.files["Read.one"].clone();
2606 drop(notebook);
2607
2608 let address = "127.0.0.1:0";
2609 let location = notebook::location::smb(address, "agent", "Notebook");
2610 let replicas = notebook::location::folder(&cache, &location);
2611 std::fs::create_dir_all(&replicas).unwrap();
2612 let original = replicas.join(format!("{identity}.sqlite"));
2613 let replica = Replica::create(&original, &bytes).unwrap();
2614 let space = replica.pages().unwrap()[0].0;
2615 replica
2616 .apply(
2617 "Fixture",
2618 Edit {
2619 at: filetime(),
2620 ops: vec![Op::Page {
2621 space,
2622 op: onestore::op::PageOp::Color(Some(0x00ef_cdab)),
2623 }],
2624 },
2625 )
2626 .unwrap();
2627 let page = replica.page(space).unwrap();
2628 let pending = replica.pending().unwrap();
2629 assert_eq!(pending.len(), 1);
2630 drop(replica);
2631 let listing = cache.join("smb");
2632 std::fs::create_dir_all(&listing).unwrap();
2633 std::fs::write(
2634 listing.join("127_0_0_1_0_agent_Notebook.json"),
2635 serde_json::to_vec(&listed).unwrap(),
2636 )
2637 .unwrap();
2638
2639 let library = Arc::new(
2640 Library::server(
2641 Server {
2642 address: address.into(),
2643 share: "agent".into(),
2644 user: String::new(),
2645 password: String::new(),
2646 domain: String::new(),
2647 },
2648 "Notebook",
2649 &cache,
2650 )
2651 .unwrap(),
2652 );
2653 sb_library_set_offline(&library, true);
2654 assert!(library.notebook().is_none());
2655 let section =
2656 Section::open(Arc::clone(&library), "Read.one", "Fixture".into(), || {}).unwrap();
2657 assert_eq!(section.shared.section.page(space).unwrap(), page);
2658 assert_eq!(section.shared.section.pending().unwrap(), pending);
2659 assert_eq!(
2660 hex(&section.shared.section.replica().identity().unwrap()),
2661 identity
2662 );
2663
2664 assert!(section.delete_page(space, "Date", "Time").is_err());
2665 section.shared.section.written().unwrap();
2666 assert!(library.notebook().is_none());
2667 assert_eq!(section.shared.section.page(space).unwrap(), page);
2668 assert_eq!(section.shared.section.pending().unwrap(), pending);
2669 assert_eq!(std::fs::read(root.join("Read.one")).unwrap(), bytes);
2670 }
2671}