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