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