| 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 | |
| 5 | use crate::{Result, owned, report, srgb, string}; |
| 6 | use canvas::search::{Entry, Index, Query, Tagged}; |
| 7 | use notebook::{ |
| 8 | Remote, Replica, |
| 9 | discover::{Folder, Reason, SectionState}, |
| 10 | session::{self, Background, Event, Known, Notebook, SyncStatus}, |
| 11 | smb::{Client, Credentials}, |
| 12 | }; |
| 13 | use onestore::{ |
| 14 | CommitError, CommitState, ExGuid, PageCreation, PageEdit, Stamp, Transaction, |
| 15 | op::{Edit, Op, SectionOp}, |
| 16 | page::Page, |
| 17 | protected::Key, |
| 18 | }; |
| 19 | use 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. |
| 32 | const TIMEOUT: Duration = Duration::from_secs(10); |
| 33 | /// The largest section file read whole. |
| 34 | const LIMIT: usize = 256 * 1024 * 1024; |
| 35 | /// The tab colour OneNote gives a section that stores none, as the desktop shows it. |
| 36 | const SECTION_COLOR: u32 = 0x00e4_a88a; |
| 37 | |
| 38 | /// Called on a background thread when a notebook's sync status may have changed. |
| 39 | static 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)] |
| 44 | pub extern "C" fn sb_set_sync_wake(wake: extern "C" fn()) { |
| 45 | let _ = SYNC_WAKE.set(wake); |
| 46 | } |
| 47 | |
| 48 | fn 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. |
| 57 | pub 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 | |
| 64 | static 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)] |
| 69 | pub 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. |
| 75 | pub(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. |
| 94 | pub 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. |
| 102 | pub type Retire = extern "C" fn(path: *const c_char, id: *const c_char, keep: bool) -> bool; |
| 103 | |
| 104 | static 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)] |
| 109 | pub 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. |
| 114 | struct Coordinated(PathBuf); |
| 115 | |
| 116 | impl 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 | |
| 180 | fn 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. |
| 188 | pub(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 | |
| 196 | impl 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. |
| 212 | pub struct Share(Client); |
| 213 | |
| 214 | enum 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)] |
| 229 | struct 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 | |
| 235 | fn 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. |
| 242 | pub(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`. |
| 254 | pub(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. |
| 263 | struct Checked { |
| 264 | at: Instant, |
| 265 | stamp: Vec<u8>, |
| 266 | } |
| 267 | |
| 268 | pub 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. |
| 289 | const PAUSE: Duration = Duration::from_secs(3); |
| 290 | |
| 291 | #[derive(serde::Serialize, serde::Deserialize)] |
| 292 | pub(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. |
| 315 | fn one_line(title: &str) -> String { |
| 316 | title.replace(|char: char| char.is_control(), " ") |
| 317 | } |
| 318 | |
| 319 | fn 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. |
| 328 | fn 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 | |
| 375 | impl 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)] |
| 695 | pub(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. |
| 705 | pub(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 | |
| 712 | impl 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. |
| 743 | pub struct Section { |
| 744 | pub(crate) shared: Arc<Shared>, |
| 745 | pub(crate) library: Arc<Library>, |
| 746 | } |
| 747 | |
| 748 | #[derive(serde::Serialize)] |
| 749 | pub(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)] |
| 759 | pub(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 | |
| 767 | pub(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. |
| 772 | pub(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. |
| 781 | fn 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 | |
| 788 | impl 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. |
| 1025 | const LISTED: u32 = 1; |
| 1026 | /// A change made elsewhere reached the section; open pages reload. |
| 1027 | const CHANGED: u32 = 2; |
| 1028 | /// The section refused an edit; the page shown returns to what is stored. |
| 1029 | const REJECTED: u32 = 4; |
| 1030 | |
| 1031 | #[derive(serde::Serialize)] |
| 1032 | #[serde(rename_all = "camelCase")] |
| 1033 | pub(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. |
| 1051 | pub(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. |
| 1086 | fn 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 | |
| 1094 | impl 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)] |
| 1380 | pub(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)] |
| 1388 | pub(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)] |
| 1401 | pub(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 | |
| 1412 | impl 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 | |
| 1461 | type Reader = Box<dyn Fn() -> Result<Vec<u8>>>; |
| 1462 | |
| 1463 | /// A local file's change key, its length and modification time, with its reader. |
| 1464 | fn 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`. |
| 1479 | fn 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 | |
| 1487 | fn 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 | |
| 1497 | fn 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 | |
| 1507 | pub(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 | |
| 1512 | pub(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)] |
| 1524 | pub 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)] |
| 1544 | pub 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)] |
| 1568 | pub 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)] |
| 1599 | pub 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)] |
| 1621 | pub 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)] |
| 1627 | pub 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)] |
| 1641 | pub 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)] |
| 1669 | pub 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)] |
| 1704 | pub 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)] |
| 1727 | pub 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)] |
| 1747 | pub 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)] |
| 1758 | pub 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)] |
| 1770 | pub 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)] |
| 1778 | pub 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)] |
| 1785 | pub 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)] |
| 1801 | pub 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)] |
| 1809 | pub 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)] |
| 1825 | pub 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)] |
| 1839 | pub 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)] |
| 1849 | pub 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 | |
| 1861 | fn 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)] |
| 1882 | struct 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)] |
| 1895 | pub 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)] |
| 1933 | pub 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`. |
| 1939 | pub 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)] |
| 1947 | pub 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)] |
| 1969 | pub 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)] |
| 1978 | pub 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)] |
| 1990 | pub 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)] |
| 2009 | pub 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)] |
| 2051 | pub 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)] |
| 2084 | pub 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)] |
| 2105 | pub 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)] |
| 2134 | pub 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)] |
| 2143 | pub 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)] |
| 2153 | pub 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)] |
| 2160 | pub unsafe extern "C" fn sb_section_free(section: *mut Section) { |
| 2161 | drop(unsafe { Box::from_raw(section) }); |
| 2162 | } |