| 1 | # notebook |
| 2 | |
| 3 | The application-facing crate: notebook discovery, a durable local replica with |
| 4 | reconnect reconciliation, external-asset caching, recovery export and optional |
| 5 | embedded SMB access. A replica keeps one section's queue in a local SQLite database: |
| 6 | the image the queued edits apply to (the base, in 16 KiB chunks), the edits as |
| 7 | `onestore::op` ops in publication batches, and picture and attachment bytes once |
| 8 | each by hash. Local success does not acknowledge publication to a shared notebook. |
| 9 | |
| 10 | ```text |
| 11 | editor ── Edit ──► section thread ─ Section::apply, INSERT edit ─┐ |
| 12 | (the parsed section: base + sealed + open) │ one fsync per burst |
| 13 | sync thread: remote.stamp() == base ? publish(sealed batch) ──────┤ base chunks += transaction |
| 14 | otherwise read once, replay the queue on it ─────────┘ base := remote, conflict pages queued |
| 15 | ``` |
| 16 | |
| 17 | ## Edits |
| 18 | |
| 19 | `Replica::apply(author, edit)` applies an `onestore::op::Edit` to the section the |
| 20 | queue leaves and returns its id once written; a refused edit returns `Rejected` |
| 21 | and changes nothing. `page(space)` and `pages()` read what the queue leaves, for |
| 22 | opening and reloading. Edits collect in an open batch until the sync thread seals |
| 23 | it, so edits arriving while a publication is in flight publish together. |
| 24 | |
| 25 | ```no_run |
| 26 | use onestore::{ExGuid, op::{Edit, Op, PageOp}}; |
| 27 | use notebook::Replica; |
| 28 | # fn example(path: &std::path::Path, source: &[u8], space: ExGuid, text: ExGuid) |
| 29 | # -> Result<(), Box<dyn std::error::Error>> { |
| 30 | let cache = Replica::create(path, source)?; |
| 31 | let typed = PageOp::Text { text, range: 0..0, with: "Hello ".into() }; |
| 32 | let id = cache.apply("Author", Edit { at: 133_000_000_000_000_000, ops: vec![Op::Page { space, op: typed }] })?; |
| 33 | drop(cache); |
| 34 | |
| 35 | let reopened = Replica::open(path)?; |
| 36 | assert_eq!(reopened.pending()?.last().map(|edit| edit.id), Some(id)); |
| 37 | # Ok(()) |
| 38 | # } |
| 39 | ``` |
| 40 | |
| 41 | A move across sections reads the page with `moving(space)`, together with its conflict pages |
| 42 | and versions, which no copy carries; copies it there; then retires it here with |
| 43 | `retire(author, edit, moving)`, which applies the edit only while all of them are as read, |
| 44 | checked on the section thread in order with every other edit, and refuses it as `Rejected` |
| 45 | otherwise, so an edit made since the copy keeps the page. |
| 46 | |
| 47 | Page reads never wait for the sync thread: a rebase or reread of the queue runs on a |
| 48 | new thread while the current one keeps answering `page` and `pages` from the section as |
| 49 | it was, then hands its requests over; edits sent meanwhile apply to the rebuilt section. |
| 50 | |
| 51 | Share one `Replica` between threads. Keep the cache on a local filesystem: the |
| 52 | connection holds exclusive ownership between transactions, and a second open fails |
| 53 | busy. No network wait occurs in a local edit. An edit a session submitted that the cache |
| 54 | cannot write for now (a full disk, an I/O error) stays applied in memory and is written |
| 55 | again, in order, each second; meanwhile `written` returns `WouldBlock`. A replica dropped |
| 56 | meanwhile keeps its cache open and its edits retrying until they are written or the cache is |
| 57 | deleted, and only then releases it; `notebook::unwritten()` counts the edits waiting in the |
| 58 | process, for a host to ask before it quits. After a commit fails, a fresh connection |
| 59 | decides whether it landed. An edit the cache could never write is refused alone, and the |
| 60 | section thread rereads the queue. |
| 61 | |
| 62 | ## Sessions |
| 63 | |
| 64 | `session::Notebook::open(root, cache_dir)` discovers a notebook directory, reading only |
| 65 | the files its folders list otherwise than when last opened (`discover::Cache`, kept under |
| 66 | `cache_dir/listings`), and |
| 67 | `session::Section::open(file, cache_dir, offline, notify)` opens one section file through |
| 68 | a replica named by the file's location and the section's document identity, so the same |
| 69 | file reopens the same queue after a relaunch and a copy of it elsewhere has its own |
| 70 | (`notebook::location`: `folder`, `local`, `smb`, `moved` for a notebook the app moves, |
| 71 | and `forget` for one it deletes). |
| 72 | `Notebook::section(path, offline, notify)` opens a mounted notebook's section; a replica already |
| 73 | named by the identity its discovery read resumes without reading the file, which the |
| 74 | worker checks next, as on a share. `offline` seeds Work Offline before the worker starts; |
| 75 | explicit `wake()` still synchronizes while offline. |
| 76 | A section is `Send + Sync`: `apply(author, edit)` returns at once and reports a refusal as `Event::Rejected`; `events()` drains |
| 77 | remote changes (`Changed(spaces)`), publication outcomes, unreachable files and |
| 78 | failures; `notify` runs on a background thread whenever an event waits. |
| 79 | `conflicts()` lists each page's conflict pages, which `page` reads and `delete_pages` |
| 80 | removes; `release(id, archive, Resolution)` ends an uncertain attempt. The author an |
| 81 | edit names is the host's: the app passes the account's full name. |
| 82 | `import_page` creates a page holding a copy of another; `delete_pages` removes pages. |
| 83 | `set_pause(pause)` has edits publish once `pause` passes without another (at most 30 s after the |
| 84 | first waiting), as a notebook on a cloud drive does; `wake()` publishes them at once. |
| 85 | `sync_status()` gives when the section file was last reached, why it could not be since |
| 86 | and how many edits wait for it. `set_offline(true)` works offline as OneNote does: the |
| 87 | worker stops connecting and edits queue until `wake()` (Sync Now) or `set_offline(false)`. |
| 88 | |
| 89 | `session::Background` keeps the sections no session holds in sync, as OneNote 2010 keeps |
| 90 | every section of an open notebook: `Background::smb(root, limit, connect, notify)` on a |
| 91 | share, `Notebook::background(watched, copies, notify)` for a mounted notebook, then |
| 92 | `watch(notebook.replicas())`. On a share, one CHANGE_NOTIFY on the notebook's folder |
| 93 | (`smb::Client::watch`) reports what changed; a host watching a mounted folder passes the |
| 94 | changed paths to `touched`, and says whether it reports every change as `watched`. A |
| 95 | reported section is checked a second later; a reported folder is listed a second later and |
| 96 | only its sections listed otherwise are checked. A failing section is tried every 31 seconds, |
| 97 | and any other every `Background::BACKSTOP` while a watch reports, `UNWATCHED` otherwise. On |
| 98 | connecting, and on working online again, every folder is listed: a section whose file lists as |
| 99 | discovery or the last check found it needs no reading, the others are checked one at a time |
| 100 | 100 ms apart. A check reads the file's stamp; a section whose replica has edits waiting, or |
| 101 | whose file moved past the replica's base, has the replica opened for the synchronization steps |
| 102 | that publish or rebase it and closed again, and a section without a replica gets one from the |
| 103 | file, its offline copy, on a share or with `copies`. `hold(path, section)` leaves an open |
| 104 | section to its session: its worker no longer polls while a watch reports, the watch wakes it |
| 105 | instead, and once its replica is released the background checks the file at once. A replica |
| 106 | a session holds is otherwise skipped (`Error::busy`), so opening a section may wait out one |
| 107 | step. `status()` gives each section's `SyncStatus`, `changed()` the sections another client |
| 108 | changed, `wake` and `set_offline` follow Sync Now and Work Offline, and `discard` stops the |
| 109 | thread and deletes the replicas holding nothing unpublished, as when the notebook closes. |
| 110 | `Notebook::replica_path(path)` names a section's replica for either kind of notebook. |
| 111 | |
| 112 | `Section::resume(file, replica, offline, notify)` starts from an owned `Replica` without |
| 113 | consulting the remote file; with the `smb` feature, `Section::resume_smb(path, |
| 114 | replica, limit, connect, offline, notify)` binds a share-relative path, `connect` running on |
| 115 | the worker again after transport failure. |
| 116 | |
| 117 | ## Reconciliation |
| 118 | |
| 119 | `sync_once(&mut remote)` returns what one step did as `Synced { edit, changed }`: |
| 120 | the state the step's batch reached, named by its newest edit, and the pages a remote |
| 121 | change replaced. While the remote's stamp (header and length) equals the base's, the |
| 122 | oldest sealed batch publishes as its `Transaction`, and the base's chunks take its |
| 123 | writes; nothing is read. When the stamp moved, the remote image is read once and the |
| 124 | queue replays on it (`merge.rs`): an op whose objects the remote left alone applies |
| 125 | as it is; a text op shifts past the remote's changes to the same text when its |
| 126 | range stays clear of them; a move, deletion or property the remote already made is |
| 127 | done; a page the remote already holds as the local edits leave it drops their ops. |
| 128 | Anything else conflicts, as in OneNote 2010: the op is dropped with every later op naming |
| 129 | what it named, the remote's version stays the page, and the local version becomes a |
| 130 | conflict page under it (`SectionOp::Conflict`, queued as one more edit and published with |
| 131 | the rest), its conflicting objects marked, named for the author of the first edit that did |
| 132 | not replay. Page lists merge as OneNote 2010 merges page series: a page the remote moved |
| 133 | keeps the remote's place, a page moved here goes before the next page in the local order |
| 134 | the remote left in place, or last, and a page the remote removed comes back as a new copy |
| 135 | of the local one, placed the same way (`corpus/conflict-page/native-pages`, |
| 136 | `native-restore`). Nothing blocks the queue. |
| 137 | |
| 138 | A remote a file provider keeps may list versions of the file beside it (`Remote::versions`, |
| 139 | `version`, `retire`), as iCloud Drive keeps a commit that lost to another device's. Each step |
| 140 | merges them first (`resolve.rs`): per object space, the newest revision of the version's that |
| 141 | the file holds, or holds merged, is where the two last agreed; the section opened at those |
| 142 | revisions (`onestore::Section::open_at`) is the base the version's changes lower from |
| 143 | (`lower_page`), and they replay on the file through the rebase rules above, conflict pages where |
| 144 | they clash. The revisions the merge writes are named after the version's it merged |
| 145 | (`Section::seal_as`), so a version merged once, here or on another device, merges as nothing |
| 146 | again. It is published on the file's stamp, then retired; one of another section, or one that |
| 147 | cannot be read, is retired with `keep` for the remote to keep beside the file. |
| 148 | |
| 149 | Publication attempts are recorded before network I/O. An attempt confirms only |
| 150 | when the remote holds its revisions, or every page it changed as it changed them, |
| 151 | followed by `Remote::confirm`; otherwise it stays `AwaitingConfirmation` and is never |
| 152 | replayed. `release(id, archive, Resolution)` exports the queue first, then |
| 153 | publishes the batch again (`Mine`) or abandons every unpublished edit (`Theirs`). |
| 154 | A durable `Published` receipt survives reopening. |
| 155 | |
| 156 | A schema-14 cache is converted when opened: it is exported to |
| 157 | `<cache>.v14-recovery`, each queued page is lowered against the page the conversion |
| 158 | has so far, and every converted page must equal the page in the old working image, |
| 159 | or the conversion rolls back and the open fails naming the archive; a page it held in |
| 160 | conflict becomes a conflict page. A schema-15 cache's batches lose the review conflict |
| 161 | they recorded when opened. |
| 162 | |
| 163 | With the optional `smb` feature, `SmbRemote::new(client, path, limit)` binds an |
| 164 | `notebook::smb::Client` to one share-relative file and snapshot limit. Remote identity uses the logical root |
| 165 | object space, which survives the tested native compaction that replaces the file ID. |
| 166 | |
| 167 | An `Arc<Replica>` can own one background worker. Supply a connection factory, poll |
| 168 | interval and observer; successful publications drain immediately, durable local |
| 169 | edits wake the worker, and `wake()` requests an immediate reachability retry. |
| 170 | While nothing is queued, or the queue waits on a remote that has not changed since, |
| 171 | a remote whose `Remote::stamp` holds is not read again; a worker a `Background` holds reads |
| 172 | the stamp only when the notebook's watch wakes it. |
| 173 | Transport failures discard the old connection and retry through the factory; |
| 174 | Read contention and `NotCommitted` operations with `WouldBlock` or `ResourceBusy` |
| 175 | reuse the connection. Contended `NotCommitted` operations use randomized backoff, |
| 176 | capped at one second, to separate competing retry cycles. Cancellation interrupts this delay; local wake notifications |
| 177 | remain coalesced until its end. |
| 178 | `RemoteIo` distinguishes connection/read failures from |
| 179 | local `Io` errors. Cache/document errors stop the worker. |
| 180 | |
| 181 | ```no_run |
| 182 | # #[cfg(feature = "smb")] |
| 183 | # fn example(cache: std::sync::Arc<notebook::Replica>, username: String, password: String) |
| 184 | # -> Result<(), Box<dyn std::error::Error>> { |
| 185 | use notebook::SmbRemote; |
| 186 | use notebook::smb::{Client, Credentials}; |
| 187 | use std::time::Duration; |
| 188 | |
| 189 | let worker = cache.start_sync( |
| 190 | Duration::from_secs(2), |
| 191 | move || { |
| 192 | Client::connect( |
| 193 | "server:445", "notes", |
| 194 | Credentials { username: &username, password: &password, domain: "" }, |
| 195 | Duration::from_secs(5), |
| 196 | ).map(|client| SmbRemote::new(client, "Personal/Video.one", 64 * 1024 * 1024)) |
| 197 | }, |
| 198 | |result| { |
| 199 | if let Err(error) = result { eprintln!("{error}"); } |
| 200 | }, |
| 201 | )?; |
| 202 | // Retain `worker` while synchronization should run; local edits wake it automatically. |
| 203 | worker.stop()?; |
| 204 | # Ok(()) |
| 205 | # } |
| 206 | ``` |
| 207 | |
| 208 | `stop()` cancels future steps and joins the worker, returning a fatal cache error |
| 209 | or worker panic. Dropping it requests cancellation without waiting. Remote |
| 210 | operations and callbacks must have bounded execution times if shutdown latency |
| 211 | matters. Credentials belong to the factory, not the cache database. |
| 212 | |
| 213 | `create` refuses an existing path; `open_or_create(path, key, source)` opens the cache at |
| 214 | `path`, reading `source` only to make it where there is none, and opens one another thread |
| 215 | made meanwhile. Opening validates database integrity and replays |
| 216 | the queue on its base. SQLite runs in WAL mode under exclusive locking with FULL |
| 217 | synchronization and fullfsync, each queried back: every commit is durable, and the only |
| 218 | file beside the cache is `<cache>-wal`, which holds committed pages until a checkpoint |
| 219 | and must travel with the cache when it is copied. Recovery archives are single files. |
| 220 | The cache contains notebook content. |
| 221 | |
| 222 | The SMB-enabled `smb_offline_client` example is an owned-lab workload for |
| 223 | `tools/native_collaboration.py --offline --embedded-smb`; its append-specific |
| 224 | conflict policy lives in the client. |
| 225 | |
| 226 | ## Recovery archives |
| 227 | |
| 228 | `export_recovery(new_path)` captures the base and remote images, the edit queue, |
| 229 | uncertain attempts, receipts, downloaded media and the edit-ID sequence in one |
| 230 | SQLite snapshot. It refuses existing destinations and leaves the live queue |
| 231 | unchanged. Export to a local directory from a background thread: copying holds |
| 232 | the cache mutex while capturing the database. Failure after the final rename can |
| 233 | leave a complete archive at the requested path; it never acknowledges a remote |
| 234 | edit. Archives contain notebook content and use a separate database identity, so |
| 235 | `Replica::open` rejects them as writable caches. |
| 236 | |
| 237 | ```no_run |
| 238 | use notebook::{Recovery, Replica}; |
| 239 | # fn example(cache: &Replica) -> Result<(), Box<dyn std::error::Error>> { |
| 240 | cache.export_recovery("review.sqlite")?; |
| 241 | let review = Recovery::open("review.sqlite")?; |
| 242 | let counts = review.summary()?; |
| 243 | let local = review.snapshot()?; |
| 244 | let remote = review.remote_snapshot()?; |
| 245 | let pending = review.pending()?; |
| 246 | let receipts = review.receipts()?; |
| 247 | # Ok(()) |
| 248 | # } |
| 249 | ``` |
| 250 | |
| 251 | `Recovery` provides read-only inspection and no synchronization or restore method. |
| 252 | `status(id)` preserves the same state interpretation as the live replica. |
| 253 | `recovery_summary()` on the live replica and `summary()` on an archive return |
| 254 | counts and byte sizes without notebook text, paths, authors or credentials. |
| 255 | Opening an archive validates its schema and images without migration. A recovery |
| 256 | archive is evidence for a reviewed recovery decision, not a second active queue. |
| 257 | |
| 258 | ## Downloaded media |
| 259 | |
| 260 | `fetch_asset(source, section, filename, limit)` resolves a declared external |
| 261 | payload through `notebook::discover::Source` and durably caches its exact bytes. |
| 262 | The section path is relative to the source root; the filename comes from a |
| 263 | `FileDataReference::External` in the retained working or remote image. Local and |
| 264 | SMB sources use the same API. Downloads release the cache mutex during network |
| 265 | I/O and recheck the reference before committing; edits can continue meanwhile. |
| 266 | |
| 267 | `cached_asset(filename, limit)` reads previously downloaded bytes without network |
| 268 | access. `None` means never downloaded; `Some(Vec::new())` is a downloaded empty |
| 269 | payload. Each read checks the stored SHA-256 and enforces the byte limit before |
| 270 | loading the payload. Cache contents describe the prior download, not current |
| 271 | server reachability or presence. A failed fetch returns its error without |
| 272 | silently substituting cached bytes. Different bytes for an already cached file |
| 273 | identity return `AssetChanged` and preserve the previous download. |
| 274 | |
| 275 | Downloads do not change pending edits, publication attempts or receipts. Recovery |
| 276 | archives include cached media and expose the same bounded `cached_asset` lookup. |
| 277 | |
| 278 | ## Discovery |
| 279 | |
| 280 | `notebook::discover` provides read-only notebook discovery over a caller-supplied |
| 281 | root, keeping directory traversal out of the single-file storage parser. |
| 282 | |
| 283 | Discovery returns ordered sections and nested groups with file identities and |
| 284 | share-relative paths. Section display-name overrides remain distinct from file |
| 285 | names. TOC references whose identities are absent from the directory remain |
| 286 | inspectable; a cached filename never substitutes for an identity match. |
| 287 | Reserved `_onefiles` directories are excluded from section-group traversal; |
| 288 | OneNote's `OneNote_RecycleBin` is listed as the section group OneNote shows. |
| 289 | Dot files and folders (`.DS_Store`, AppleDouble `._` companions, `.snowbound`) and Office's |
| 290 | `~$` owner files are skipped wherever they are, never read as sections. |
| 291 | Encrypted sections and valid storage with an unreadable document graph retain |
| 292 | their identity as `Locked` or `Unreadable`, without being presented as empty pages. |
| 293 | A child folder or section file that is denied or gone while listing (a folder |
| 294 | another client holds delete-pending reads as denied) is kept as `unavailable` and |
| 295 | tried again on the next discovery; the root itself, malformed storage, other failed |
| 296 | reads and ambiguous identities reject the discovery. |
| 297 | |
| 298 | `Cache::discover` keeps each file's listing (size and last write time), stamp and what |
| 299 | discovery took from it; the next discovery reads only the files listed otherwise, taking one |
| 300 | from a copy where `Source::copy` has it as it stands (on a share, a replica whose base has the |
| 301 | file's stamp), and `found(path)` gives a section's listing and stamp for `Background::watch`. |
| 302 | `take` hands on, once, the sections it read from the source, up to a memory bound, which |
| 303 | `Notebook::replicas` gives `Background::watch` so that its first check need not read them |
| 304 | again. A file written during discovery is read again next time; a failed discovery leaves the |
| 305 | cache as it was. |
| 306 | |
| 307 | Each file read must be a consistent, bounded snapshot. The result is an |
| 308 | observation across multiple files, not an atomic notebook transaction or |
| 309 | authorization to publish an edit. Refresh rejects observed topology changes. Of |
| 310 | several section files with one identity (a `Name 2.one` copy), each lists, as OneNote opens |
| 311 | each; all but the one its folder's TOC names (else the one with the shortest path) are |
| 312 | marked `copy`, have replicas of their own, and are never placed or listed anew by structure |
| 313 | operations, which touch only a TOC entry OneNote gave the copy's name. Of several groups |
| 314 | with one TOC identity, the one its parent's TOC names, else the newest, lists; the others |
| 315 | list in `unavailable` as `Reason::Copy`. iOS's |
| 316 | `.Name.one.icloud` placeholders, and macOS's dataless files, list there under their real names |
| 317 | as `Reason::Evicted`, unless listed unchanged since discovery last read them. |
| 318 | A file listed but not reachable, as a cloud provider offline lists from its cache, stands as |
| 319 | it was last read, and is read again next time; one never read fails the discovery. |
| 320 | Retain the last accepted catalog if discovery fails; a connection failure does not mean |
| 321 | files were deleted. |
| 322 | |
| 323 | `read_external_asset` resolves a validated UUID `.onebin` filename beneath the |
| 324 | selected section's sibling `_onefiles` folder and returns exact bounded bytes. |
| 325 | Missing files, permissions and size failures retain their I/O error kinds; an |
| 326 | empty payload is a successful empty buffer. Embedded payloads remain available |
| 327 | directly from the core document model. |
| 328 | |
| 329 | ## Notebook structure |
| 330 | |
| 331 | `Notebook::create`, `create_section`, `create_group`, `rename`, `move_entry`, |
| 332 | `set_section_color`, `set_color`, `reorder` and `delete` change a notebook the way OneNote does: the |
| 333 | table of contents (`Open Notebook.onetoc2`, created when a folder has none) gains, |
| 334 | renames, reorders or loses entries; a new notebook holds "New Section 1", and a new |
| 335 | section an empty section's file plus the page its `PageCreation` makes, in OneNote's |
| 336 | new-section colour order; a section's colour lives in its own metadata, the notebook's |
| 337 | in its root table of contents (`corpus/section-color`); a deleted |
| 338 | section moves into `OneNote_RecycleBin`, a group with its own TOC, and a deleted group's |
| 339 | sections move there too before its folders go. `recycle_pages` keeps copies of pages a |
| 340 | section is about to delete in `OneNote_RecycleBin/OneNote_DeletedPages.one`, with their |
| 341 | identities, titles, dates and creation times, as OneNote does |
| 342 | (`corpus/notebook-management`); `unrecycle_pages` takes them out again by identity, as |
| 343 | OneNote's Undo of a page delete does. A bin or deleted-pages file its TOC does not list is |
| 344 | placed and listed again, a bin whose TOC OneNote named otherwise keeps it, and an |
| 345 | unavailable bin refuses the delete (`corpus/recycle-bin-repair`). `empty_recycle_bin` is OneNote's |
| 346 | Empty Recycle Bin: Deleted Pages loses every page in one revision and each binned section's |
| 347 | file goes, the bin's TOC left as it was (`corpus/recycle-bin-view`). An entry a TOC still |
| 348 | lists for a file gone from its folder gives way to the file an edit gives its name. |
| 349 | `rename_folder` renames the notebook's own folder, which OneNote 2010 opens again from its |
| 350 | new name with no file inside changed (it drops the old one from its list): on a share it |
| 351 | first takes and lets go of OneNote's writer locks on every section and TOC, refusing with |
| 352 | `WouldBlock` while another writer holds one, then the replicas and listing follow. |
| 353 | Edits keep entries in the order their ordering numbers give; a rename or colour keeps |
| 354 | the numbers. Every created, renamed or moved file is placed with |
| 355 | `onestore::place_file`, which sets the header's ancestor to the parent TOC's |
| 356 | identity and the name CRC OneNote checks on open; a file without them is |
| 357 | re-identified and listed anew. They run over `Storage`: a mounted directory |
| 358 | (`Notebook::open`) or an SMB share (`Notebook::open_smb` with a |
| 359 | `smb::Client`, which gained create, directory creation, rename, delete and |
| 360 | header placement under native writer coordination); `tools/test_smb_structure.py |
| 361 | VM OUTPUT` drives them against a disposable Samba lab VM. |
| 362 | |
| 363 | `Notebook::find_page(url)` resolves a stored internal link to a section path |
| 364 | and page space by identity: the linked section first, then every readable |
| 365 | section, so a link follows its page when the section is renamed or moved and |
| 366 | when the page itself was moved to another section. Other URLs and unknown |
| 367 | targets are `None`. |
| 368 | |
| 369 | `Notebook::read_section(path)` reads a section file as stored, without opening a |
| 370 | replica, and `session::stored_pages(image)` builds its pages with each page's |
| 371 | `LastModifiedTime` and the author of its latest change: the app's search indexes the |
| 372 | sections it has not opened this way, and unread changes leave out the reader's own. |
| 373 | |
| 374 | A password-protected section lists as `Locked`. `Notebook::unlock(path, password)` opens |
| 375 | its key (a wrong password is `Error::Protected(PasswordMismatch)`), and |
| 376 | `section_unlocked(path, &key, offline, notify)` (`section_unlocked_with`, or |
| 377 | `Replica::open_or_create(path, Some(&key), source)` for a host that opens replicas itself) |
| 378 | opens it as any section opens: edits queue, publish, merge and become conflict pages as an ordinary section's do, |
| 379 | each revision sealed under the section's key. The replica keeps none of it in the clear: |
| 380 | its base and transactions are the file's own ciphertext, and its queued edits and payloads |
| 381 | are sealed with AES-256-GCM (a random nonce each, the row's kind as associated data) under |
| 382 | a key HMAC-SHA256 derives from the section's, payloads named by HMAC rather than SHA-256. |
| 383 | Only the author's name, payload names and revision identities stay readable, and the |
| 384 | queue opens only under the key (a recovery archive with `Recovery::open_unlocked`). |
| 385 | `stored_pages_unlocked(image, &key)` reads a protected section's pages for search. The |
| 386 | background never opens a locked section. |
| 387 | |
| 388 | `Notebook::set_password(path, key, password)` sets, changes or removes a password as |
| 389 | OneNote 2010 does: the section is written anew under new identities |
| 390 | (`onestore::protected::rekey`), next to the file as a dot file that `Storage::supersede` |
| 391 | puts in its place under the writers' coordination while the file's stamp holds (on a share as |
| 392 | OneNote's maintenance does it: the old file aside, the new one in, the old one deleted), and |
| 393 | its TOC entry takes the new identity (`TocEdit::Reidentify`). The old replica, a cache of the |
| 394 | old file, is deleted, so the section's session must be closed and its edits published first; |
| 395 | a section whose edits wait refuses. |
| 396 | |
| 397 | Another device learns of the password when it reads the notebook again (`open`, `refresh`): |
| 398 | a replica of the file the protected section superseded, found by the placement the rewritten |
| 399 | header keeps, is deleted when nothing waits in it. Edits that wait stay until `unlock` on that |
| 400 | device queues them under the key, each page they changed coming back as a copy, as a page |
| 401 | another client removed does. A session open meanwhile stops as `SyncState::Protected`. |
| 402 | |
| 403 | `Section::import_page(page, author)` copies a page, usually read from another |
| 404 | section, to the end of this one as one `SectionOp::Import` queued like the |
| 405 | user's own edits, under fresh identities (`Page::copy`); payloads travel with |
| 406 | the model, so the copy publishes offline later like any edit. Content outside |
| 407 | the model refuses to copy. A move is an import here followed by |
| 408 | `Section::delete_pages` there. |
| 409 | |
| 410 | `Notebook::refresh` rereads the directory on the caller's schedule and reports |
| 411 | what another client changed as `Change`s keyed by file identity: a renamed or |
| 412 | moved section is `Moved`, not removed and added, and a deleted section moves |
| 413 | into `OneNote_RecycleBin`; a folder whose surviving entries changed sequence |
| 414 | is `Reordered`. A failed read returns the error and keeps |
| 415 | the previous catalog, so an unreachable share never reads as an emptied |
| 416 | notebook. |
| 417 | |
| 418 | ## Packages |
| 419 | |
| 420 | `package` reads and writes OneNote packages (`.onepkg`), cabinet files of a notebook's tables |
| 421 | of contents and sections at their paths in its folder. `notebook_files(notebook, image)` gathers |
| 422 | what OneNote 2010's Save As packs (each folder's TOC and sections, groups after, the recycle bin |
| 423 | and copies left out, its TOC entry kept), taking a section the caller holds edits for from |
| 424 | `image` (a replica's `snapshot`); `pack` writes them MSZIP-compressed, each file outside any |
| 425 | notebook (no `guidAncestor` or `crcName`), as OneNote's are. `read` takes MSZIP or OneNote's LZX, |
| 426 | refusing a path leaving the folder or more than a byte limit unpacked, and `unpack(files, root)` |
| 427 | writes a new folder as Unpack Notebook does: every file takes an identity of its own |
| 428 | (`onestore::reidentify`) and is placed under its folder's TOC, whose entries follow it where |
| 429 | OneNote lists each anew beside the stale one. `section_copy` and `page_section` are Save As's |
| 430 | section and page: a copy under a new identity, and a new section holding the page with its |
| 431 | identity, both outside any notebook (`corpus/notebook-package`). |
| 432 | |
| 433 | ## Snowbound's folder |
| 434 | |
| 435 | `sidecar` keeps Snowbound-only data in the notebook's `.snowbound` folder, which |
| 436 | OneNote 2010 skips because it has the Windows hidden attribute: `Storage::hide` sets it |
| 437 | on a share with SET_INFO FileBasicInformation (always, since Samba reports a dot name hidden |
| 438 | without storing it), `SetFileAttributesW` on Windows, or macOS's |
| 439 | `UF_HIDDEN`, which an smbfs mount passes to the share; Linux keeps no such attribute. Discovery skips dot names, and a notebook |
| 440 | without the folder is whole. |
| 441 | |
| 442 | ```text |
| 443 | .snowbound/ |
| 444 | ├ tags.json [{ name, shape, art, mapped }]: a tag's name and symbol → its art |
| 445 | ├ tags/ <SHA-256>.png | .svg, written through a temporary name, never rewritten |
| 446 | └ themes.json { themes: [{ id, name, styles, modified, deleted }], |
| 447 | assignments: [{ scope, theme, assigned }] }: style themes and who wears them |
| 448 | ``` |
| 449 | |
| 450 | `Notebook::map_tag_art(name, shape, bytes, extension)` makes the folder and hides it, |
| 451 | keeps the picture, then rereads `tags.json`, merges its mapping and replaces the file, |
| 452 | reading it back and merging again until its own holds. `sidecar::merge` keeps every tag |
| 453 | either side mapped; one tag mapped by both keeps the later `mapped`, then the greater art |
| 454 | name. A mapping two writers replace within one round trip of each other can still lose |
| 455 | one side's new tag until that side maps it again. `tag_art` reads the mappings, passing |
| 456 | over entries that name no picture, and `tag_art_file` returns a picture once its bytes |
| 457 | match its name. |
| 458 | |
| 459 | `sidecar::themes` keeps style themes: each gives OneNote 2010's eleven gallery styles |
| 460 | (`STYLES`: `h1`…`h6`, `PageTitle`, `cite`, `blockquote`, `code`, `p`) a font, size, |
| 461 | weight, slant, colour and spacing, and `Theme::sheet` turns them into the paragraph style |
| 462 | definitions a page stores. `built_in()` lists the shipped themes, which never change once |
| 463 | shipped. A scope is the notebook, a section by file identity or a page by its |
| 464 | notebook-management identity; `Themes::effective(section, page)` takes the page's, else the |
| 465 | section's, else the notebook's. `Notebook::themes` reads the file and `save_themes(change)` |
| 466 | merges a change in as `map_tag_art` does: themes by id and assignments by scope, the later |
| 467 | timestamp winning, then the greater entry. `onestore::op::restyle(page, sheet)` gives the |
| 468 | `Restyle` ops that put a page in a sheet. |
| 469 | |
| 470 | ## SMB (feature `smb`) |
| 471 | |
| 472 | `notebook::smb` provides blocking SMB access for OneNote sections and |
| 473 | table-of-contents files. The core `onestore` crate remains independent of network runtimes. This is an |
| 474 | experimental Rust API with native interoperability evidence in the repository's |
| 475 | [Milestone 9](../../evidence/MILESTONE9.md). |
| 476 | |
| 477 | ```no_run |
| 478 | # #[cfg(feature = "smb")] { |
| 479 | use notebook::smb::{Client, Credentials}; |
| 480 | use std::time::Duration; |
| 481 | |
| 482 | let client = Client::connect( |
| 483 | "server:445", |
| 484 | "notes", |
| 485 | Credentials { username: "user", password: "password", domain: "" }, |
| 486 | Duration::from_secs(5), |
| 487 | )?; |
| 488 | let snapshot = client.read("Personal/Video.one", 64 * 1024 * 1024)?; |
| 489 | let store = onestore::Store::parse(&snapshot)?; |
| 490 | let revisions = onestore::RevisionIndex::parse(&store)?; |
| 491 | let document = onestore::document::Document::parse(&revisions)?; |
| 492 | # } |
| 493 | # Ok::<(), Box<dyn std::error::Error>>(()) |
| 494 | ``` |
| 495 | |
| 496 | Paths are relative to the share. The read limit bounds the complete physical |
| 497 | snapshot. Call from a background thread outside a Tokio runtime. |
| 498 | `Client::commit_transaction` publishes a `Transaction` from `Section::seal`; its errors |
| 499 | retain `onestore::CommitState`. `Client::stamp` reads a file's header and length in one |
| 500 | round trip without coordination, for polling. `Client::confirm` checks and flushes a |
| 501 | stamp, then refreshes its header version metadata without adding a revision. The caller |
| 502 | must first establish which intents that image contains and reread before another commit. |
| 503 | |
| 504 | A commit is eight round trips while its appended bytes and patches fit fifteen 64 KiB |
| 505 | writes: open; the coordination locks, identity queries and the stamp check's reads as one |
| 506 | compound request; the path's identity; the appended bytes and patches, the header, the |
| 507 | counter (twice at a carry) and the version, each as its writes and a flush in one |
| 508 | compound; close. The server |
| 509 | performs a compound in order and answers its flush once the writes are durable, so the |
| 510 | writes carry no write-through flag. |
| 511 | |
| 512 | Readers use shared native guards while writers publish under native write-open |
| 513 | and byte-lock exclusion. Maintenance is excluded during each operation; pathname |
| 514 | identity is checked after acquiring the guards. Connection loss retires the |
| 515 | client. Reconnect for subsequent operations, and reconcile an `Unknown` edit |
| 516 | before retrying it. The transport does not automatically replay requests. |
| 517 | |
| 518 | `Client::read_dir(path, entry_limit)` enumerates a directory, including the share |
| 519 | root with an empty path. It follows every response page and returns no partial |
| 520 | list on interruption, entry-limit overflow or close failure. Entries retain exact |
| 521 | Unicode names, observed sizes, last write times and MS-FSCC attributes, including |
| 522 | directory/reparse flags. Concurrent directory changes are not an atomic snapshot; repeated names |
| 523 | are rejected with `ResourceBusy`. Notebook identities come from the files, not |
| 524 | directory names or sizes. Missing paths, denied access and non-directory paths |
| 525 | have distinct I/O error kinds. |
| 526 | |
| 527 | `smb::shares(address, credentials, timeout)` lists the disk shares a server offers the |
| 528 | account, over `IPC$` and srvsvc. `smb::Refusal::of(&error)` names why `Client::connect`, |
| 529 | `shares` or `read_dir` failed as a person remedies it: unreachable, SMB1 only, sign-in |
| 530 | refused, no such share, denied, no such folder. A server that turns SMB2's negotiation |
| 531 | away is asked for SMB1's `NT LM 0.12` once, which an SMB1-only server agrees to. |
| 532 | |
| 533 | `Client::watch(path, changed)` arms one CHANGE_NOTIFY with WATCH_TREE on a directory, as |
| 534 | OneNote 2010 watches a notebook's folder, and keeps it armed on the client's runtime without |
| 535 | probing the connection, so an idle watch sends nothing. `changed` hears each batch of changed |
| 536 | paths relative to the directory, `""` when the server lost count, and an error when the watch |
| 537 | ends with its connection. |
| 538 | |
| 539 | An external payload on a share (`discover::Smb`) is read under a read-only share handle that excludes writes and deletion. Empty files succeed; limits, |
| 540 | interrupted reads and failed close never return partial bytes. This payload read |
| 541 | does not parse a OneStore header or acquire its reader-coordination bytes. |
| 542 | |
| 543 | `python3 tools/test_smb_directory.py VM OUTPUT` checks a caller-owned disposable |
| 544 | Linux lab VM against its filesystem listing and interrupts directory requests, |
| 545 | responses and close. It creates synthetic files in that VM; the caller retains |
| 546 | responsibility for VM teardown. The parser also has bounded-record/truncation |
| 547 | tests independent of the server. |
| 548 | |
| 549 | Device and simulator builds link for iOS. Native acceptance uses disposable |
| 550 | OneNote 2010 clients and Samba; it does not establish on-device execution or |
| 551 | physical power-loss durability. |
| 552 | |
| 553 | ## Document providers |
| 554 | |
| 555 | `provider` opens notebooks a document provider keeps, as Android's Storage Access Framework |
| 556 | reaches them. The host implements `provider::Provider` (a document's description, a folder's |
| 557 | children, `open` in a `ContentResolver` mode as an owned `File`, `close` with the provider's |
| 558 | verdict, `rename` returning the new id) and reports an unreachable provider as `NotConnected`, |
| 559 | a revoked grant as `PermissionDenied` and a gone document as `NotFound`. |
| 560 | `provider::Documents::new(provider, root, cache, device)` holds one root, the app's |
| 561 | cache and the device's name: a picked folder opens with `Notebook::open_provider(documents)`, its sections with `Section::resume_provider` |
| 562 | and the rest with `Background::provider`, as a Live Share guest's do; a picked document opens |
| 563 | as a lone section with `Section::open_provider`. Catalog paths resolve to ids by listing their |
| 564 | folders, again once when an id has gone, as a rename leaves it; of documents sharing a name the |
| 565 | first by id lists and the rest are `unavailable`, named with their ids. |
| 566 | |
| 567 | A publication resolves the document's id once, from the last listing, and writes nothing to |
| 568 | a document that took its name since. Without a write grant a document is only read, and |
| 569 | publication is refused as `ReadOnlyFilesystem`; where its `r` descriptor seeks, `rw` opens it |
| 570 | and a commit checks the stamp and appends in place exactly as on a disk; a document that only |
| 571 | streams is read whole for each stamp, and a commit reads it, checks its stamp, applies the |
| 572 | transaction and writes the whole image with `wt`, or `w` where that is refused, images only |
| 573 | growing. No provider offers a lock or compare-and-swap: the check sees only this device's view |
| 574 | of the document, and another device's upload can replace this one's. So each image a section |
| 575 | publishes is kept in the cache, beside its replica: once written in place, as the image it was |
| 576 | published on (kept the same way) with the transaction applied, else read back; once a whole |
| 577 | upload has opened, as uploaded. Once the document is seen otherwise it is listed as a |
| 578 | `Remote::versions` version, until the document holds the image's revisions: a lost publication |
| 579 | merges back as a conflict version does, conflict pages where it clashes, named for the device |
| 580 | `Documents::new` was given. A document still standing as the image an upload was made on is |
| 581 | given a minute for reads to catch up before the upload counts as lost. Only this device's |
| 582 | publications are kept so, and one that cannot merge is set aside in the cache, as no new |
| 583 | document can be made beside it. A document read torn as this device's own upload cut off on a |
| 584 | provider that writes in place leaves it, the start of an image it kept, is written again from |
| 585 | that image, once a step; any other image that does not parse is left as it is, and is never a |
| 586 | base. An open |
| 587 | section checks its file every `Background::UNWATCHED`, the host's change reports waking it |
| 588 | between. While the provider can't be reached the notebook opens as its folders were last |
| 589 | listed and sections from their replicas; a revoked grant keeps them, and the notebook does not |
| 590 | open until access returns. Creating, deleting and moving documents and renaming the folder are |
| 591 | `Unsupported`; renaming within a folder is not. |
| 592 | |
| 593 | ## Live presence and Live Share (feature `live`) |
| 594 | |
| 595 | `live::Live::start(hello, room, reach, relay, events)` listens on a TCP port and, with a |
| 596 | `Reach`, advertises `_snowbound._tcp` by mDNS on every network or on loopback alone, |
| 597 | connecting to the peers in the same `Room` that it finds: a notebook's or a share's random |
| 598 | secret (`Room::Notebook`), or a code typed on both (`Room::join("7KQ-4MZ-9XR", password)`). |
| 599 | With a `relay` (`wss://live.example.net`, `crates/relay`) it also joins the room there and |
| 600 | meets its peers through it; the end sharing a code (`Room::share`) has the relay number its |
| 601 | secret, `code()` then has the whole code, and it burns a code after too many wrong tries. |
| 602 | Codes are Crockford base32 (`live::code`): two symbols numbering the room, six of secret |
| 603 | (30 bits) and a check symbol that refuses a typo before it spends one of the relay's tries. |
| 604 | `connect(address)` meets a peer discovery did not find. Peers meet through SPAKE2 on the |
| 605 | room's secret, then every frame is AES-256-GCM under the keys it agreed: its number, which is |
| 606 | also its nonce, then a message kind and a CBOR map (`live::wire`). A frame lost, repeated, |
| 607 | reordered or forged on the way fails where it lands; the connection is dropped as broken, |
| 608 | nothing from it after the fault is applied, and the ends meet again from scratch. A reader |
| 609 | skips kinds and map keys it doesn't know, so later versions add both freely. |
| 610 | `set_presence` says which section, page and caret this end has (text object and UTF-16 |
| 611 | offset, as ops address text); a connection sends only the newest. `peers()` lists each |
| 612 | connected peer's `Hello` (name, picture) and presence; `events` hears when they change, who |
| 613 | was met and left, and every other frame with the `Line` to answer on. `leave(reason)` says |
| 614 | `Bye` first; dropping the `Live` leaves at once. `Notebook::presence_room()` is the secret of |
| 615 | a notebook's presence room, kept in `.snowbound/live.json` and made where it has none. |
| 616 | |
| 617 | `live::share` is Live Share. `Host::start(storage, hello, sharing, name, reach, relay, events)` |
| 618 | serves `Notebook::into_storage()` to the peers in the share's room and welcomes whoever knows |
| 619 | `Sharing::code` (and its password) from the code's room; `code()` replaces a burned code with |
| 620 | a new secret, `guests()` lists who is connected, `touched(paths)` passes the host's own changes |
| 621 | on, and `stop()` lets every guest go. `join(hello, code, password, reach, relay)` returns the |
| 622 | `Welcome` (the share and its secret), or a `Refusal` saying why not: a wrong code, no one |
| 623 | sharing it, an expired code, too many wrong tries, or no relay. `Guest::start` joins the share; |
| 624 | `Notebook::open_hosted(guest, cache)`, `Background::hosted(guest, notify)` and |
| 625 | `Section::resume_hosted(path, replica, guest, offline, notify)` then work as on a share, with |
| 626 | `Guest::host()` and `stopped()` for whether the host is there and still sharing. |
| 627 | |
| 628 | ## Queue measurement |
| 629 | |
| 630 | `cargo run -p notebook --release --example queue_scale -- NEW_DIRECTORY 1000` |
| 631 | measures alternating-page edits, cache reopen, recovery export and one local-file |
| 632 | publication of the queue. `keystroke_probe PARAGRAPHS KEYSTROKES` types into a large |
| 633 | page and reports the bytes the cache and the section file write per keystroke. |