authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-29 17:45:49-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-29 18:49:54-07:00
loga861b59985b1920526baa1ea2be635a760965f52
treec4f311ac4712c74a65dec0c1d645717bd679f363
parent3b58d8f8a9667e9578deab17b4bcd96e05515ad8
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: drawing tools and audio recording

- Drawing tools (#21): pens and highlighters from OneNote 2010's gallery, the stroke eraser, lasso select, move and delete, and line, arrow, rectangle and oval shapes. Each stroke is its own ink drawing, stored as OneNote stores it, with highlighters drawn by a multiply blend. - Audio recording (#36): Record Audio inserts a recording and OneNote's started line, links notes typed while recording to their moment, and plays them back from a transport over the page. Recordings are stored as the spec requires, as IMA ADPCM WAV that OneNote 2010 plays, and undo of a page delete restores them. Assisted-by: claude-opus-5.5

190 files changed, 7478 insertions(+), 848 deletions(-)

apps/ios/Snowbound/Notebooks.swift+75-10
...@@ -159,6 +159,8 @@ final class Notebook {...@@ -159,6 +159,8 @@ final class Notebook {
159 private(set) var problem: String?159 private(set) var problem: String?
160 /// The Files location being read, held open while the notebook is.160 /// The Files location being read, held open while the notebook is.
161 private var accessed: URL?161 private var accessed: URL?
162 /// Reports other apps' changes to a folder on this device to the library.
163 private var presenter: FolderPresenter?
162164
163 init(id: UUID = UUID(), name: String, source: Source) {165 init(id: UUID = UUID(), name: String, source: Source) {
164 self.id = id166 self.id = id
...@@ -167,6 +169,7 @@ final class Notebook {...@@ -167,6 +169,7 @@ final class Notebook {
167 }169 }
168170
169 deinit {171 deinit {
172 presenter?.stop()
170 if let handle { sb_library_free(handle) }173 if let handle { sb_library_free(handle) }
171 accessed?.stopAccessingSecurityScopedResource()174 accessed?.stopAccessingSecurityScopedResource()
172 }175 }
...@@ -175,39 +178,46 @@ final class Notebook {...@@ -175,39 +178,46 @@ final class Notebook {
175 /// thread either way.178 /// thread either way.
176 func open(done: @escaping () -> Void) {179 func open(done: @escaping () -> Void) {
177 let source = source180 let source = source
178 background({ () -> (OpaquePointer?, URL?, String?) in181 // The library, the Files location held open, why it failed, and a local folder to watch.
182 background({ () -> (OpaquePointer?, URL?, String?, URL?) in
179 var error: UnsafeMutablePointer<CChar>?183 var error: UnsafeMutablePointer<CChar>?
180 switch source {184 switch source {
181 case .server(let server):185 case .server(let server):
182 let library = sb_library_server(186 let library = sb_library_server(
183 server.host, server.share, server.user, Keychain.password(server) ?? "", "", server.root,187 server.host, server.share, server.user, Keychain.password(server) ?? "", "", server.root,
184 cacheDirectory.path, &error)188 cacheDirectory.path, &error)
185 return (library, nil, take(error))189 return (library, nil, take(error), nil)
186 case .files(let bookmark):190 case .files(let bookmark):
187 var stale = false191 var stale = false
188 guard let url = try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale) else {192 guard let url = try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale) else {
189 return (nil, nil, "The notebook was moved or deleted.")193 return (nil, nil, "The notebook was moved or deleted.", nil)
190 }194 }
191 let accessed = url.startAccessingSecurityScopedResource() ? url : nil195 let accessed = url.startAccessingSecurityScopedResource() ? url : nil
196 let local = onThisDevice(url)
192 var library: OpaquePointer?197 var library: OpaquePointer?
193 // A coordinated read brings a cloud folder's files down before they are read.198 // A coordinated read brings a cloud folder's files down before they are read.
194 var coordination: NSError?199 var coordination: NSError?
195 NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordination) { url in200 NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordination) { url in
196 library = sb_library_open(url.path, cacheDirectory.path, &error)201 library = sb_library_open(url.path, cacheDirectory.path, local, &error)
197 }202 }
198 return (library, accessed, take(error) ?? coordination?.localizedDescription)203 return (library, accessed, take(error) ?? coordination?.localizedDescription, local ? url : nil)
199 case .documents(let path):204 case .documents(let path):
200 let library = sb_library_open(205 let url = documentsDirectory.appendingPathComponent(path)
201 documentsDirectory.appendingPathComponent(path).path, cacheDirectory.path, &error)206 let library = sb_library_open(url.path, cacheDirectory.path, true, &error)
202 return (library, nil, take(error))207 return (library, nil, take(error), url)
203 case .path(let path):208 case .path(let path):
204 return (sb_library_open(path, cacheDirectory.path, &error), nil, take(error))209 let url = URL(fileURLWithPath: path)
210 let local = onThisDevice(url)
211 return (sb_library_open(path, cacheDirectory.path, local, &error), nil, take(error), local ? url : nil)
205 }212 }
206 }) { [self] (library, accessed, problem) in213 }) { [self] (library, accessed, problem, watched) in
207 self.accessed?.stopAccessingSecurityScopedResource()214 self.accessed?.stopAccessingSecurityScopedResource()
208 self.accessed = accessed215 self.accessed = accessed
216 presenter?.stop()
217 presenter = nil
209 if let handle { sb_library_free(handle) }218 if let handle { sb_library_free(handle) }
210 handle = library219 handle = library
220 if let library, let watched { presenter = FolderPresenter(watching: watched, for: library) }
211 self.problem = library == nil ? (problem ?? "The notebook can’t be read.") : nil221 self.problem = library == nil ? (problem ?? "The notebook can’t be read.") : nil
212 follow()222 follow()
213 reload(done: done)223 reload(done: done)
...@@ -244,6 +254,61 @@ final class Notebook {...@@ -244,6 +254,61 @@ final class Notebook {
244 }254 }
245}255}
246256
257/// Whether `url` is kept on this device, rather than by a file provider that keeps it elsewhere
258/// too, as iCloud Drive does.
259private func onThisDevice(_ url: URL) -> Bool {
260 let values = try? url.resourceValues(forKeys: [.volumeIsLocalKey, .isUbiquitousItemKey])
261 return values?.volumeIsLocal == true && values?.isUbiquitousItem != true
262}
263
264/// Tells a library which files of its folder on this device changed, as file coordination
265/// reports other apps' writes; the library checks just those sections.
266final class FolderPresenter: NSObject, NSFilePresenter {
267 let presentedItemURL: URL?
268 let presentedItemOperationQueue: OperationQueue = {
269 let queue = OperationQueue()
270 queue.maxConcurrentOperationCount = 1
271 return queue
272 }()
273 private let root: String
274 private let library: OpaquePointer
275
276 init(watching url: URL, for library: OpaquePointer) {
277 presentedItemURL = url
278 root = url.standardizedFileURL.resolvingSymlinksInPath().path
279 self.library = library
280 super.init()
281 NSFileCoordinator.addFilePresenter(self)
282 }
283
284 /// Stops reporting, before the library goes.
285 func stop() {
286 NSFileCoordinator.removeFilePresenter(self)
287 presentedItemOperationQueue.waitUntilAllOperationsAreFinished()
288 }
289
290 func presentedItemDidChange() { touched("") }
291 func presentedSubitemDidChange(at url: URL) { touched(url) }
292 func presentedSubitemDidAppear(at url: URL) { touched(url) }
293 func presentedSubitem(at oldURL: URL, didMoveTo newURL: URL) {
294 touched(oldURL)
295 touched(newURL)
296 }
297 func accommodatePresentedSubitemDeletion(at url: URL, completionHandler: @escaping (Error?) -> Void) {
298 touched(url)
299 completionHandler(nil)
300 }
301
302 private func touched(_ url: URL) {
303 let path = url.standardizedFileURL.resolvingSymlinksInPath().path
304 touched(path.hasPrefix(root + "/") ? String(path.dropFirst(root.count + 1)) : "")
305 }
306
307 private func touched(_ path: String) {
308 sb_library_touched(library, path)
309 }
310}
311
247/// The notebooks the list shows, kept across launches.312/// The notebooks the list shows, kept across launches.
248enum Notebooks {313enum Notebooks {
249 private struct Entry: Codable {314 private struct Entry: Codable {
arc/canvas.md+27
...@@ -106,6 +106,33 @@ on the page itself, as OneNote 2010 places it: the same column at the caret's...@@ -106,6 +106,33 @@ on the page itself, as OneNote 2010 places it: the same column at the caret's
106grid point, outside any outline. It selects, drags on the grid, deletes and106grid point, outside any outline. It selects, drags on the grid, deletes and
107moves with Insert Space as a picture does, and opens and saves as above.107moves with Insert Space as a picture does, and opens and saves as above.
108108
109## Recordings
110
111Record Audio works as OneNote 2010's does. The caret's paragraph splits around
112a line saying when recording started, in OneNote's grey `cite` style, and the
113caret goes on below it. Text written while recording links to the moment it
114was written. On Stop, the file goes in above that line, named after the page.
115Hovering a linked note or a recording shows OneNote's blue play button in the
116margin, and a click asks the host to play from that moment. The host records a
11716 kHz WAV and stores it as IMA ADPCM, which OneNote plays.
118
119## Drawing
120
121The Draw tab's tools work as OneNote 2010's do with a mouse (`corpus/ink-tools`).
122Every stroke and every shape is a drawing of its own, added on top of the page
123when the pen lifts: one edit and one undo step, published as one appended
124revision, or in one with the strokes queued behind it during a sync round trip,
125as keystrokes are. Shapes are ink too. Their corners snap to the placement grid, and the
126drawing keeps the shape's kind and anchors beside its strokes, which OneNote
127edits it by. A highlighter's rectangular tip multiplies what lies beneath, so
128text under it stays dark. The stroke eraser takes whole drawings, or only the
129strokes it touches in an older drawing of several. The lasso picks the drawings
130with most of their points inside it. They move by their offset, as OneNote
131moves ink, and Delete removes them. Escape returns to Select & Type, where a
132click on ink picks it. The default pen draws in the open section's accent at
133the light theme's shade, so a stroke stores one real colour and shows it in both
134themes.
135
109## Content the editor doesn't understand136## Content the editor doesn't understand
110137
111Nothing is lost for being unfamiliar. A paragraph the canvas can't draw138Nothing is lost for being unfamiliar. A paragraph the canvas can't draw
arc/file-format.md+8-5
...@@ -165,11 +165,14 @@ MS-ONESTORE and MS-ONE are good, but they are not the whole truth:...@@ -165,11 +165,14 @@ MS-ONESTORE and MS-ONE are good, but they are not the whole truth:
165 stores beside every author name. Snowbound writes them too.165 stores beside every author name. Snowbound writes them too.
166- Some things the spec requires aren't needed by OneNote to open a file.166- Some things the spec requires aren't needed by OneNote to open a file.
167 Snowbound writes them anyway, because other readers exist.167 Snowbound writes them anyway, because other readers exist.
168- Ink, equations and media recordings are absent from MS-ONE entirely. Ink and168- Ink and equations are absent from MS-ONE entirely, and so is the link from a
169 math were reverse-engineered, each against an independent oracle. For ink,169 note to a moment in a recording. Ink and math were reverse-engineered, each
170 the stroke extents from OneNote's own export. For math, the MathML it exports,170 against an independent oracle. For ink, the stroke extents from OneNote's own
171 matched byte for byte. Recordings and embedded objects are read and kept, but171 export, and for the Draw tab's pens, highlighters and shapes, the objects
172 never authored.172 OneNote stored while drawing them in the lab (`corpus/ink-tools`). For math, the MathML it exports, matched byte for byte. Recordings
173 and their links follow what OneNote stored while recording in the lab, read
174 back through its XML export (`corpus/recording`). Embedded objects are read
175 and kept, but never authored.
173176
174Readers stay tolerant (files in the wild are older, odder, or written by other177Readers stay tolerant (files in the wild are older, odder, or written by other
175tools), while the writer stays strict.178tools), while the writer stays strict.
arc/sync.md+36-18
...@@ -16,7 +16,7 @@ happens when the share is busy, changed or gone....@@ -16,7 +16,7 @@ happens when the share is busy, changed or gone.
16```text16```text
17UI thread (winit, UIKit) section thread sync thread17UI thread (winit, UIKit) section thread sync thread
18──────────────────────── ────────────────────────────── ──────────────────────────────18──────────────────────── ────────────────────────────── ──────────────────────────────
19editor emits Edit (ops) ───â–º apply to the parsed Section poll the stamp (header + length)19editor emits Edit (ops) ───â–º apply to the parsed Section read the stamp (header + length)
20 and returns at once write each burst to SQLite unchanged: ask for a seal,20 and returns at once write each burst to SQLite unchanged: ask for a seal,
21page reads â—„──────────────── answered from the Section publish it21page reads â—„──────────────── answered from the Section publish it
22events â—„──────────────────── Changed, Rejected, published changed: read once, ask22events â—„──────────────────── Changed, Rejected, published changed: read once, ask
...@@ -33,8 +33,9 @@ events â—„──────────────────── Changed,...@@ -33,8 +33,9 @@ events â—„──────────────────── Changed,
33 fresh thread that builds its own section while the old one keeps answering33 fresh thread that builds its own section while the old one keeps answering
34 reads, so opening a page never waits on the network.34 reads, so opening a page never waits on the network.
35- **The sync thread** (`notebook::worker`, stepping `notebook::sync`) does all35- **The sync thread** (`notebook::worker`, stepping `notebook::sync`) does all
36 network I/O. It polls, publishes and rereads. When the queue is idle it36 network I/O. It reads the stamp when a local edit waits or the notebook's watch
37 reads nothing but the header.37 reports the file (every two seconds where nothing watches), then publishes or
38 rereads. Idle, it reads nothing.
3839
39`notebook::session` wraps this up for apps. `Notebook` covers discovery and40`notebook::session` wraps this up for apps. `Notebook` covers discovery and
40structure, `Section` covers one section's pages, edits, events and conflict41structure, `Section` covers one section's pages, edits, events and conflict
...@@ -88,7 +89,7 @@ else:...@@ -88,7 +89,7 @@ else:
88```89```
8990
90A batch stays open while the sync thread is busy, so keystrokes that arrive91A batch stays open while the sync thread is busy, so keystrokes that arrive
91during one round trip publish together in the next. Polling costs a92during one round trip publish together in the next. A check costs a
92kilobyte-sized read. A publication costs one appended revision, not the93kilobyte-sized read. A publication costs one appended revision, not the
93section.94section.
9495
...@@ -159,7 +160,7 @@ client with the account the system keeps for that mount. It falls back to the...@@ -159,7 +160,7 @@ client with the account the system keeps for that mount. It falls back to the
159mount only when it can't sign in that way.160mount only when it can't sign in that way.
160161
161A file's identity is its root object space, not its server file ID, which162A file's identity is its root object space, not its server file ID, which
162changes when maintenance replaces the file. Polling opens by path each time163changes when maintenance replaces the file. A check opens by path each time
163for the same reason.164for the same reason.
164165
165## Offline166## Offline
...@@ -180,31 +181,48 @@ OneNote keeps every section of an open notebook in sync and on this computer, no...@@ -180,31 +181,48 @@ OneNote keeps every section of an open notebook in sync and on this computer, no
180the one on screen. Watched in the lab with a notebook of 200 sections on Samba, OneNote181the one on screen. Watched in the lab with a notebook of 200 sections on Samba, OneNote
1812010:1822010:
182183
183- reads every section file once when the notebook opens (all 200 within 15 seconds), so a184- reads every section file once when it first opens the notebook (all 200 within 15
184 section never shown before opens with the share gone;185 seconds), so a section never shown before opens with the share gone; opening it again
186 with its cache warm, it only lists the folders and reads the files listed otherwise;
185- arms one CHANGE_NOTIFY on the notebook's folder, with WATCH_TREE and a filter of names,187- arms one CHANGE_NOTIFY on the notebook's folder, with WATCH_TREE and a filter of names,
186 attributes, size and last write, and otherwise sends nothing: 22 idle minutes put no SMB188 attributes, size and last write, and otherwise sends nothing: 22 idle minutes put no SMB
187 request on the wire, not even an echo;189 request on the wire, not even an echo, and the section on screen waits on the same watch;
188- lists the folder once when the server reports a change, and reads only the section that190- lists the folder once when the server reports a change, and reads only the section that
189 changed, some seconds later;191 changed, some seconds later;
190- tries a section it could not reach again about every 31 seconds.192- tries a section it could not reach again about every 31 seconds.
191193
192Snowbound's `session::Background` does the same, with one thread per notebook:194Snowbound does the same. Opening a notebook lists its folders and reads only the files whose
195listed size or last write time changed since it last read them, keeping what it took from
196each, with the file's stamp, in the cache (`discover::Cache`). On a share, a changed section
197whose replica already holds it as it stands, as after its own edits published, costs just a
198stamp read. `session::Background` then keeps the sections in sync, one thread per notebook:
193199
194```text200```text
195share ─ CHANGE_NOTIFY (tree) ─â–º the sections it names are due in 1 s ─â–º stamp ─â–º moved: rebase201connect: arm the watch, list every folder
196first pass: one section every 100 ms; a section without a replica gets its offline copy202 listed as before: compare the known stamp with the replica, locally
197failing: again in 31 s otherwise: again in an hour, against a lost notification203 listed otherwise: check, one section every 100 ms
204share ─ CHANGE_NOTIFY (tree) ─â–º the file it names is due in 1 s ─â–º stamp ─â–º moved: rebase
205 └â–º a folder it names is listed in 1 s ─â–º the files listed otherwise
206failing: again in 31 s otherwise: again in an hour, against a lost notification
198```207```
199208
200A check costs one stamp read. Only a section whose replica has edits waiting, or whose file209A check costs one stamp read. Only a section whose replica has edits waiting, or whose file
201moved past the replica's base, has its replica opened for the usual sync steps, and it is210moved past the replica's base, has its replica opened for the usual sync steps, and it is
202closed again afterwards; the open section is left to its own session. A connection that211closed again afterwards; a section without one gets its offline copy. The section open on
203drops takes its watch with it, so the next one checks every section again. A notebook in a212screen is left to its session, whose worker no longer polls: the watch wakes it, and once the
204folder on this computer has no copies and learns of changes from FSEvents or inotify; one on213session lets go of the replica the background checks the file at once. A connection that drops
205a network volume the system mounted, whose server's changes those never report, keeps copies214takes its watch with it, so the next one lists every folder again, and so does working online
206and is checked every 15 seconds. Closing a notebook deletes its copies, except any with215again.
207edits still waiting.216
217A notebook in a folder on this computer has no copies and learns of changes from FSEvents or
218inotify. One on a network volume the system mounted keeps copies. A Mac's SMB mount reports
219another client's change to FSEvents only for a folder watched in its own right, and names just
220that folder, so every folder of the notebook is watched and a report lists it. A Linux SMB
221mount reports none of another client's changes to inotify, so there each section's stamp is
222read every 15 seconds. On iOS, a folder on the device (Snowbound's own, or On My iPhone) has no
223copies and learns of other apps' writes through file coordination (`NSFilePresenter`); one a
224file provider keeps elsewhere, as iCloud Drive does, keeps copies and is checked every 15
225seconds. Closing a notebook deletes its copies, except any with edits still waiting.
208226
209## Notebook structure227## Notebook structure
210228
corpus/ink-tools/README.md created+55
...@@ -0,0 +1,55 @@
1# The Draw tab's tools
2
3`native-ui/notebook/ink.one` is OneNote 2010's own work in a lab clone, drawn with the
4mouse from the Draw tab (screenshots of the tab, the pen gallery and Color & Thickness
5were taken alongside):
6
7- "Pens": the first favourite pen (black, 35 HIMETRIC wide), a second stroke drawn at once
8 beside it, the red thick pen, the yellow highlighter, and shapes: rectangle, line,
9 arrow and oval with the default shape pen (black, 50 wide).
10- "Gallery": one stroke with each of the 14 favourite pens in the gallery's order.
11- "Edits": four strokes, the second removed by the stroke eraser, the third lassoed and
12 dragged 60 by 20 pixels, the fourth lassoed and deleted, then a rectangle and a line.
13- "Shapes2": arrows at other angles and pen widths, a rectangle moved after drawing, and
14 lines in each of Color & Thickness's nine widths.
15
16What it shows, and `crates/onestore/tests/page_ink.rs` pins:
17
18- Every stroke and every shape is an ink container of its own, on top of the page; OneNote
19 never adds a stroke to an earlier drawing. Erasing or deleting removes the container;
20 moving sets its offset and leaves the strokes as they were.
21- A pen's drawing attributes ignore pressure. A highlighter's tip is a 70 by 400
22 rectangle (pen tip 1) with transparency 127 and raster operation 9 (MaskPen), and its
23 stroke's bias is 2. Strokes are numbered through the page.
24- Pen widths are 25, 35, 50, 70, 100, 150, 200, 350 and 500 HIMETRIC. The favourites are
25 black, red `0x241ced`, blue `0xbb6531`, green `0x367d17` and grey `0x808080` at 35 and
26 50, and yellow, cyan, green and magenta highlighters; black is stored as no colour.
27- A shape's container stores 2 in `0x14001d4e` (1 for a drawn stroke), its kind in
28 `0x0c001d4f` (11 a line or arrow, 12 a closed shape) and its geometry: a line's ends in
29 half inches (`0x1c001dac`), or a closed shape's transform from the unit square in half
30 inches and its anchors in the unit square (`0x1c001daa`: 8 for a rectangle, 4 for an
31 oval). Its strokes carry only their path and pen, whose dimension table spans the whole
32 32-bit range at resolution 1000. A rectangle is drawn clockwise from its top left, an
33 oval in 50 steps clockwise from its right end, and an arrow's head is a second stroke of
34 two barbs at atan(1/2) to the line, 8 points plus twice the pen width long. Shape
35 corners snap to the 18 pt grid.
36
37`native-ui/read` is a fresh OneNote 2010 read of that file.
38
39`drawn/candidate` is the page view's output for
40`drawing_tools_store_each_stroke_and_undo_it_in_one_step` in
41`crates/canvas/tests/ink_tools.rs` (`CANVAS_INK_EXPORT`): a pen stroke in an accent
42colour, a yellow highlighter, and a rectangle, oval, arrow and line dragged out with the
43shape tools, each its own edit. `drawn/cold` is a fresh OneNote 2010 read: six
44`InkDrawing`s, the four shapes with their `ShapeInfo` anchors, drawn as Snowbound drew
45them (`page-000.png`).
46
47`edited/candidate` is `onenote_drawings_take_erasing_moving_and_more_strokes` in
48`page_ink.rs` (`ONESTORE_INK_TOOLS_EDIT_EXPORT`): the native file with the red zigzag on
49"Pens" deleted, the vertical line moved by (36, 18), and the six drawings above added
50below. `edited/cold` shows OneNote reading OneNote's own drawings where they were, the
51moved one offset, and Snowbound's beside them.
52
53`tools/test_ink_tools.py` checks all three without a VM. Regenerate the candidates with
54the export variables set to new absolute directories, then cold-open each with
55`tools/native_runner.py OUTPUT COLD --expected-pages N --collect-notebook --screenshots`.
corpus/ink-tools/drawn/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/ink-tools/drawn/candidate/Open Notebook.onetoc2 differ
corpus/ink-tools/drawn/candidate/ink.one created
Binary files /dev/null and b/corpus/ink-tools/drawn/candidate/ink.one differ
corpus/ink-tools/drawn/cold/commands.jsonl created+3
...@@ -0,0 +1,3 @@
1{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-68F30CFB -ExpectedPages 1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
corpus/ink-tools/drawn/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-68f30cfb", "hostname": "ONE-M6-68F30CFB"}
corpus/ink-tools/drawn/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/ink-tools/drawn/cold/notebook/Open Notebook.onetoc2 differ
corpus/ink-tools/drawn/cold/notebook/ink.one created
Binary files /dev/null and b/corpus/ink-tools/drawn/cold/notebook/ink.one differ
corpus/ink-tools/drawn/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-68F30CFB",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/ink-tools/drawn/cold/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{194A10C2-CAC1-4FA7-8B8B-C54AC6F4AF88}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T01:08:33.000Z" color="#B49EDE"><one:Section name="ink" ID="{F417F32E-49B0-07C3-19B1-FEF33492497E}{1}{B0}" path="C:\one-tests\runs\capture\notebook\ink.one" lastModifiedTime="2026-09-30T01:08:33.000Z" color="#8AA8E4"><one:Page ID="{B7246258-E7DF-06F3-078F-026817A32A8F}{1}{B0}" name="Drawn in Snowbound" dateTime="2026-09-30T01:04:56.000Z" lastModifiedTime="2026-09-30T01:08:33.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/ink-tools/drawn/cold/read/page-000.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Drawn in Snowbound - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-tools/drawn/cold/read/page-000.png created
Binary files /dev/null and b/corpus/ink-tools/drawn/cold/read/page-000.png differ
corpus/ink-tools/drawn/cold/read/page-000.xml created+19
...@@ -0,0 +1,19 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{B7246258-E7DF-06F3-078F-026817A32A8F}{1}{B0}" name="Drawn in Snowbound" dateTime="2026-09-30T01:04:56.000Z" lastModifiedTime="2026-09-30T01:08:33.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" lastModifiedTime="2026-09-30T01:04:56.000Z" objectID="{F417F32E-49B0-07C3-19B1-FEF33492497E}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="135.9570770263672" height="13.4277172088623"/><one:OEChildren><one:OE creationTime="2026-09-30T01:04:56.000Z" lastModifiedTime="2026-09-30T01:04:56.000Z" objectID="{F417F32E-49B0-07C3-19B1-FEF33492497E}{24}{B0}" alignment="left"><one:T><![CDATA[Drawn in Snowbound]]></one:T></one:OE></one:OEChildren></one:Outline><one:InkDrawing lastModifiedTime="2022-06-18T04:26:41.000Z" objectID="{8AF0CB4B-77CC-0D78-37A4-D8914A8FD516}{1}{B0}"><one:Position x="60.00944900512695" y="138.0188903808594" z="1"/><one:Size width="160.0157470703125" height="23.98110198974609"/><one:Data>AKACHQOuA0IBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
3Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREn7Tq
4A0UjRiMFCAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQAqDASmC/j27+OVQJQEo
5CUBKAIfwuaeF+mIRaPSKUSaUSKRRqIQyAQeGxmNyWSyuSymPx2KwuBwSGRaNSKTSaUSaQR6JAAUU
6RgBAAAAJBAUURgAAAAASABEgx3HwO0nsdkiiPkWOgG81fAoAESCAFrmby4LYAQMVRgBACgARIGia
70z8rfHRA
8</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2022-06-18T04:26:43.000Z" objectID="{8AF0CB4B-77CC-0D78-37A4-D8914A8FD516}{7}{B0}"><one:Position x="252.0" y="140.4000091552734" z="3"/><one:Size width="72.02835845947266" height="54.02833938598632"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="252.0" y="140.3999938964844"/><one:AnchorPoint x="288.0" y="140.3999938964844"/><one:AnchorPoint x="324.0" y="140.3999938964844"/><one:AnchorPoint x="324.0" y="167.3999786376953"/><one:AnchorPoint x="324.0" y="194.3999786376953"/><one:AnchorPoint x="288.0" y="194.3999786376953"/><one:AnchorPoint x="252.0" y="194.3999786376953"/><one:AnchorPoint x="252.0" y="167.3999786376953"/></one:ShapeInfo><one:Data>AFMdBMQBlAEBECoAGh+LQnBFhHWAvtBF2QQDC0gQRJ+06gNFMkYyBQILZGQDFUYAQAoiBYfxn5eI
9BvwEr+AlfwErAIfwqYeFTHtYO1h7WEADFUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2022-06-18T04:26:44.000Z" objectID="{8AF0CB4B-77CC-0D78-37A4-D8914A8FD516}{10}{B0}"><one:Position x="342.0" y="140.4566955566406" z="4"/><one:Size width="90.02835845947266" height="53.9149513244629"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="387.0" y="140.3999938964844"/><one:AnchorPoint x="432.0" y="167.3999786376953"/><one:AnchorPoint x="387.0" y="194.3999786376953"/><one:AnchorPoint x="342.0" y="167.3999786376953"/></one:ShapeInfo><one:Data>AK0BHQT0AZQBARAqABofi0JwRYR1gL7QRdkEAwtIEESftOoDRTJGMgUCC2RkAxVGAEAKfDOH8yx3
10mWpl8vl8rk8ljsbicPhMBgEIh0SjUckkmlUul0umUul0ulUmkkcjUSh0IgEBhMPicbjslk8rl8vl
114Ibw5xeG/WDhYuNj5eTm5mbnZ2dnZuZm5OXj42LhYOBhISKjI6WkpqYmpycnJyamJqSlo6MioSEA
12AxVGAEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2022-06-18T04:26:42.000Z" objectID="{8AF0CB4B-77CC-0D78-37A4-D8914A8FD516}{3}{B0}"><one:Position x="60.00944900512695" y="190.0063018798828" z="2"/><one:Size width="160.0157470703125" height="0.0283412933349609"/><one:Data>APsBHQOwAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
13Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBRE//8D
14RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQApWFYL+
15Pbv407JRKSiUgv7NC/s0MAAABRRGAIAAAAkEBRRGAIAAABIAESDYrR159N2MQ7BkvoW/LnuoCgAR
16IACtUZzLgtgBAxVGAEAKABEg4dbRP+nao0A=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2022-06-18T04:26:45.000Z" objectID="{8AF0CB4B-77CC-0D78-37A4-D8914A8FD516}{14}{B0}"><one:Position x="252.0" y="230.4000091552734" z="5"/><one:Size width="108.0283584594727" height="37.55904388427734"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="252.0" y="230.3999786376953"/><one:AnchorPoint x="360.0" y="266.3999938964844"/></one:ShapeInfo><one:Data>AFodA6QCaAEQKgAaH4tCcEWEdYC+0EXZBAMLSBBEn7TqA0UyRjIFAgtkZAMVRgBAChACD0V0xnAP
17P4CS2AMVRgBAChgDh/J815Nf+OPAh/Gud4zs+AvAAxVGAEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2022-06-18T04:26:46.000Z" objectID="{8AF0CB4B-77CC-0D78-37A4-D8914A8FD516}{17}{B0}"><one:Position x="378.0" y="230.4000091552734" z="6"/><one:Size width="72.02835845947266" height="0.0283412933349609"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="378.0" y="230.3999786376953"/><one:AnchorPoint x="450.0" y="230.3999786376953"/></one:ShapeInfo><one:Data>AEAdA8QBBAEQKgAaH4tCcEWEdYC+0EXZBAMLSBBEn7TqA0UyRjIFAgtkZAMVRgBAChACD2gu+AwO
18fwH8AAMVRgBA
19</one:Data></one:InkDrawing></one:Page>
corpus/ink-tools/drawn/cold/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/ink-tools/drawn/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/snowbound-ink/gate/drawn-candidate",
3 "expected_pages": 1,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
17 }
18}
corpus/ink-tools/drawn/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/ink-tools/drawn/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/ink-tools/drawn/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1596,
5 "sha256": "d5a80cff53089bfedc57be7a900b345fe413b56cb56d5b88097633fb2612ae7c",
6 "mtime_ns": 1790730300920529312
7 },
8 {
9 "path": "ink.one",
10 "bytes": 11656,
11 "sha256": "d1c8e2b2159d4635c017ba4782e53a0f14fabd75889c484c8b10b75107b5e5cf",
12 "mtime_ns": 1790730300920314814
13 }
14]
corpus/ink-tools/drawn/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/ink-tools/edited/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/ink-tools/edited/candidate/Open Notebook.onetoc2 differ
corpus/ink-tools/edited/candidate/ink.one created
Binary files /dev/null and b/corpus/ink-tools/edited/candidate/ink.one differ
corpus/ink-tools/edited/cold/commands.jsonl created+3
...@@ -0,0 +1,3 @@
1{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-4F2D85E9 -ExpectedPages 5 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 5 pages.\r\n", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
corpus/ink-tools/edited/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-4f2d85e9", "hostname": "ONE-M6-4F2D85E9"}
corpus/ink-tools/edited/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/ink-tools/edited/cold/notebook/Open Notebook.onetoc2 differ
corpus/ink-tools/edited/cold/notebook/ink.one created
Binary files /dev/null and b/corpus/ink-tools/edited/cold/notebook/ink.one differ
corpus/ink-tools/edited/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-4F2D85E9",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/ink-tools/edited/cold/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{F96D64FB-64F3-47C2-8710-5BB103E59097}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T01:08:42.000Z" color="#B49EDE"><one:Section name="ink" ID="{A77A1597-6DDA-0C9B-05DD-2CD63B5CFE24}{1}{B0}" path="C:\one-tests\runs\capture\notebook\ink.one" lastModifiedTime="2026-09-30T01:08:42.000Z" color="#8AA8E4"><one:Page ID="{4D0D633D-2B24-0838-0CE3-33E1DB10DA2C}{1}{B0}" name="Beside the drawing" dateTime="2026-09-13T00:25:49.000Z" lastModifiedTime="2026-09-30T01:08:42.000Z" pageLevel="1"/><one:Page ID="{DB8D8C2E-3789-4958-BF3F-202608CD8306}{1}{B0}" name="Pens" dateTime="2026-09-30T00:18:53.000Z" lastModifiedTime="2026-09-30T00:19:49.000Z" pageLevel="1"/><one:Page ID="{AEC739A2-6F42-4515-84C1-D418854C78FF}{1}{B0}" name="Gallery" dateTime="2026-09-30T00:22:50.000Z" lastModifiedTime="2026-09-30T00:23:40.000Z" pageLevel="1"/><one:Page ID="{5282ABA7-9026-4149-9998-6AF690A91A8A}{1}{B0}" name="Edits" dateTime="2026-09-30T00:24:17.000Z" lastModifiedTime="2026-09-30T00:25:14.000Z" pageLevel="1"/><one:Page ID="{165E0C0A-C75B-4477-A2A9-8F9F05942B86}{1}{B0}" name="Shapes2" dateTime="2026-09-30T00:29:10.000Z" lastModifiedTime="2026-09-30T00:32:48.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/ink-tools/edited/cold/read/page-000.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Shapes2 - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-tools/edited/cold/read/page-000.png created
Binary files /dev/null and b/corpus/ink-tools/edited/cold/read/page-000.png differ
corpus/ink-tools/edited/cold/read/page-000.xml created+24
...@@ -0,0 +1,24 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{165E0C0A-C75B-4477-A2A9-8F9F05942B86}{1}{B0}" name="Shapes2" dateTime="2026-09-30T00:29:10.000Z" lastModifiedTime="2026-09-30T00:32:48.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T00:29:11.000Z" lastModifiedTime="2026-09-30T00:29:11.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Shapes2]]></one:T></one:OE></one:Title><one:InkDrawing lastModifiedTime="2026-09-30T00:29:28.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{40}{B0}"><one:Position x="270.0" y="50.39999771118164" z="1"/><one:Size width="21.60001373291016" height="72.02834320068359"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="270.0" y="50.39999771118164"/><one:AnchorPoint x="288.0" y="122.400001525879"/></one:ShapeInfo><one:Data>AFodA0TMAQEQKgAaH4tCcEWEdYC+0EXZBAMLSBBE/wFFlgFGlgEFAgtkZAMVRgBAChACD0pqnsAO
3G8kN4AMVRgBAChgDh/H214/1+B3Ah/BeZ4HX+ZfAAxVGAEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:29:25.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{31}{B0}"><one:Position x="36.0" y="74.74960327148437" z="0"/><one:Size width="190.5165405273437" height="14.740159034729"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="36.0" y="86.4000015258789"/><one:AnchorPoint x="226.5000152587891" y="81.75000762939453"/></one:ShapeInfo><one:Data>AFkdA4gEMgEQKgAaH4tCcEWEdYC+0EXZBAMLSBBE/wFFlgFGlgEFAgtkZAMVRgBAChACDhPZ82AN
4X0LRAAMVRgBAChcDh/FGd4ks+adAh/AYx4BK5gADFUYAQA==</one:Data></one:InkDrawing><one:InkDrawing isUnclassified="true" lastModifiedTime="2026-09-30T00:32:48.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{123}{B0}"><one:Position x="54.0" y="160.4976348876953" z="12"/><one:Size width="60.03779220581055" height="7.540157318115234"/><one:Data>ALYCHQOiARYBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
5Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
6BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKngE4h+1g7FmATebTWAzGAoBA
7ZnNJvACZzKazSawCAwCAwCAwCazKATWZTOZTOaTVMpnNJqmUzmk1TKZppNSH8NXXhq7mk1mUzmUz
8mk1TKZppNUymZNJqmUzTSagAAAAFFEYAQAAACQQFFEYAAAAAEgARIH7bL5LspZlKvDhZGnSznPUK
9P0Ai5AG7fQcwO6ADFUYAQAo/QCMlgf3zx5gRwA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:29:31.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{48}{B0}"><one:Position x="81.0" y="162.9000244140625" z="2"/><one:Size width="72.02835845947266" height="54.02833938598632"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="36.0" y="140.4005889892578"/><one:AnchorPoint x="71.99999237060547" y="140.4005889892578"/><one:AnchorPoint x="107.9999847412109" y="140.4005889892578"/><one:AnchorPoint x="107.9999847412109" y="167.4005889892578"/><one:AnchorPoint x="107.9999847412109" y="194.4005889892578"/><one:AnchorPoint x="71.99999237060547" y="194.4005889892578"/><one:AnchorPoint x="36.0" y="194.4005889892578"/><one:AnchorPoint x="36.0" y="167.4005889892578"/></one:ShapeInfo><one:Data>AEwdBMIBkgEBECoAGh+LQnBFhHWAvtBF2QQDBkgQRRlGGQUCC2RkAxVGAEAKIAWH6GroavASv4CV
10/ASsh/Cph4VMe1g7WHtYQAMVRgBA
11</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:29:49.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{56}{B0}"><one:Position x="180.0" y="209.2535400390625" z="3"/><one:Size width="234.0283508300781" height="3.174808502197266"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="212.4000091552734"/><one:AnchorPoint x="414.0" y="209.25"/></one:ShapeInfo><one:Data>ADsdA/IEDAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFI0YjBQILZGQDFUYAQAoQAg8xnOQ0DnUVzWAD
12FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:29:54.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{64}{B0}"><one:Position x="180.0" y="212.4000091552734" z="4"/><one:Size width="234.0283508300781" height="4.365348815917969"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="212.4000091552734"/><one:AnchorPoint x="414.0" y="216.75"/></one:ShapeInfo><one:Data>ADsdA/QEEAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFMkYyBQILZGQDFUYAQAoQAg8xnOQ0DnUV3eAD
13FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:29:59.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{72}{B0}"><one:Position x="180.0" y="224.2488098144531" z="5"/><one:Size width="234.0283508300781" height="6.179534912109375"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="230.4000091552734"/><one:AnchorPoint x="414.0" y="224.2500152587891"/></one:ShapeInfo><one:Data>ADsdA/YEFgEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFRkZGBQILZGQDFUYAQAoQAg8xnOQ0Dn8B7nAD
14FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:10.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{88}{B0}"><one:Position x="180.0" y="230.4000091552734" z="7"/><one:Size width="234.0283508300781" height="8.87243843078613"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="230.4000091552734"/><one:AnchorPoint x="414.0" y="239.25"/></one:ShapeInfo><one:Data>AD0dA/wEIgEQKgAaH4tCcEWEdYC+0EXZBAMISBBFlgFGlgEFAgtkZAMVRgBAChACDzGc5DQPP4CD
154AMVRgBA
16</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:05.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{80}{B0}"><one:Position x="180.0" y="230.4000091552734" z="6"/><one:Size width="234.0283508300781" height="1.38896369934082"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="230.4000091552734"/><one:AnchorPoint x="414.0" y="231.75"/></one:ShapeInfo><one:Data>ADsdA/gEDAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFZEZkBQILZGQDFUYAQAoQAg8xnOQ0Dn8B/wAD
17FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:20.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{104}{B0}"><one:Position x="180.0" y="248.4000091552734" z="9"/><one:Size width="234.0283508300781" height="5.867712020874023"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="248.4000091552734"/><one:AnchorPoint x="414.0" y="254.25"/></one:ShapeInfo><one:Data>AD0dA4oFKgEQKgAaH4tCcEWEdYC+0EXZBAMISBBF3gJG3gIFAgtkZAMVRgBAChACDzGc5DQPRHaM
18JAMVRgBA
19</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:15.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{96}{B0}"><one:Position x="180.0" y="246.7559051513672" z="8"/><one:Size width="234.0283508300781" height="1.672445297241211"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="248.4000091552734"/><one:AnchorPoint x="414.0" y="246.7500152587891"/></one:ShapeInfo><one:Data>AD0dA4AFFAEQKgAaH4tCcEWEdYC+0EXZBAMISBBFyAFGyAEFAgtkZAMVRgBAChACDzGc5DQPRHaI
20BAMVRgBA
21</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:25.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{112}{B0}"><one:Position x="180.0" y="254.2393798828125" z="10"/><one:Size width="234.0283508300781" height="12.1889705657959"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="266.3999938964844"/><one:AnchorPoint x="414.0" y="254.2393798828125"/></one:ShapeInfo><one:Data>AD0dA5YFRgEQKgAaH4tCcEWEdYC+0EXZBAMISBBF9ANG9AMFAgtkZAMVRgBAChACDzGc5DQPSWyM
22JAMVRgBA
23</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:31.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{120}{B0}"><one:Position x="180.0" y="266.3999938964844" z="11"/><one:Size width="234.0283508300781" height="72.02834320068359"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="266.3999938964844"/><one:AnchorPoint x="414.0" y="338.3999938964844"/></one:ShapeInfo><one:Data>AD4dBJYF5gEBECoAGh+LQnBFhHWAvtBF2QQDCEgQRfQDRvQDBQILZGQDFUYAQAoQAg8xnOQ0D0ls
24uogDFUYAQA==</one:Data></one:InkDrawing></one:Page>
corpus/ink-tools/edited/cold/read/page-001.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Beside the drawing - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-tools/edited/cold/read/page-001.png created
Binary files /dev/null and b/corpus/ink-tools/edited/cold/read/page-001.png differ
corpus/ink-tools/edited/cold/read/page-001.xml created+7
...@@ -0,0 +1,7 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{4D0D633D-2B24-0838-0CE3-33E1DB10DA2C}{1}{B0}" name="Beside the drawing" dateTime="2026-09-13T00:25:49.000Z" lastModifiedTime="2026-09-30T01:08:42.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-13T00:25:49.000Z" objectID="{A77A1597-6DDA-0C9B-05DD-2CD63B5CFE24}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="152.4244079589843" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-13T00:25:49.000Z" lastModifiedTime="2026-09-13T00:25:49.000Z" objectID="{A77A1597-6DDA-0C9B-05DD-2CD63B5CFE24}{24}{B0}" alignment="left"><one:T><![CDATA[Beside the drawing]]></one:T></one:OE></one:OEChildren></one:Outline><one:InkDrawing lastModifiedTime="2026-09-13T00:25:49.000Z" objectID="{51506EB3-217E-0F51-2E4F-2B1ADBD0DD24}{0}{B0}"><one:Position x="299.9905395507812" y="119.9905471801758" z="1"/><one:Size width="60.03781127929687" height="60.03779220581055"/><one:Data>AM8CHQSiAaIBAWAqABofi0JwRYR1gL7QRdkE+oeVMvLsF0G4wBa/8yv0et67E4vlXrNInJuhJVq3
3URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBACEQZIEEUj
4RiMJSBBE/wFFI0YjBQgLZU9mZ2hkaRkUMggAgB4CIeLiQTMIAPAQAovF4kFkAxVGAEAKXAWH8gln
5jCj7wnvCe8IAh/B8h4Pke8I7wnvCQAUURgBAAAAJBAUURgAAAAASABEg7Ks5v90DzE+YKNcBjx6b
6FwoAESCA5B1tFkPdAQMVRgBACj9AIm9GCC70WAqACQEKTAIPUq7GcA5CJYzgBRRGAIAAAAkEBRRG
7AAAAABIAESBSpMe3m/jlRpcE5ouTMwiuCgARIIDkHW0WQ90BAxVGAEAKP0Aib0YILvRYCoA=</one:Data></one:InkDrawing></one:Page>
corpus/ink-tools/edited/cold/read/page-002.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Edits - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-tools/edited/cold/read/page-002.png created
Binary files /dev/null and b/corpus/ink-tools/edited/cold/read/page-002.png differ
corpus/ink-tools/edited/cold/read/page-002.xml created+15
...@@ -0,0 +1,15 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{5282ABA7-9026-4149-9998-6AF690A91A8A}{1}{B0}" name="Edits" dateTime="2026-09-30T00:24:17.000Z" lastModifiedTime="2026-09-30T00:25:14.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T00:24:18.000Z" lastModifiedTime="2026-09-30T00:24:18.000Z" objectID="{616ED56F-25A4-4C1F-B04A-01347A13D7CC}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Edits]]></one:T></one:OE></one:Title><one:InkDrawing lastModifiedTime="2026-09-30T00:24:32.000Z" objectID="{616ED56F-25A4-4C1F-B04A-01347A13D7CC}{56}{B0}"><one:Position x="252.0" y="50.39999771118164" z="2"/><one:Size width="72.02835845947266" height="54.02834701538086"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="251.9998931884766" y="50.4000587463379"/><one:AnchorPoint x="287.9998474121094" y="50.4000587463379"/><one:AnchorPoint x="323.9997863769531" y="50.4000587463379"/><one:AnchorPoint x="323.9997863769531" y="77.40003967285156"/><one:AnchorPoint x="323.9997863769531" y="104.400016784668"/><one:AnchorPoint x="287.9998474121094" y="104.400016784668"/><one:AnchorPoint x="251.9998931884766" y="104.400016784668"/><one:AnchorPoint x="251.9998931884766" y="77.40003967285156"/></one:ShapeInfo><one:Data>AEwdBMQBlAEBECoAGh+LQnBFhHWAvtBF2QQDBkgQRTJGMgUCC2RkAxVGAEAKIAWH8Z+XiAb8BK/g
3JX8BKwCH7GLsY+1g7WHtYQMVRgBA
4</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:24:22.000Z" objectID="{616ED56F-25A4-4C1F-B04A-01347A13D7CC}{32}{B0}"><one:Position x="39.00472640991211" y="59.24409484863281" z="0"/><one:Size width="112.5354309082031" height="0.0283455848693848"/><one:Data>AKgCHQOuAgIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
5Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
6BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKkAFBh+k+5y+YwGazGApnAZjA
7UzE1QCAzOZTMmcygMxmU1gEBgEBgEBgE1mSZzSazKZzSaplM5pNUymaaTWZTNNJqgv484/jzkAAA
8AAAAAAAABRRGAEAAAAkEBRRGAAAAABIAESBoVDskhZV+SLo2P5B9KRgDCgARIAD8ywlyUN0BAxVG
9AEAKABEgV/iIP6ru0D8=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:24:35.000Z" objectID="{616ED56F-25A4-4C1F-B04A-01347A13D7CC}{64}{B0}"><one:Position x="252.0" y="140.4000091552734" z="3"/><one:Size width="72.02835845947266" height="36.02833938598632"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="252.0" y="140.4000091552734"/><one:AnchorPoint x="324.0" y="176.4000091552734"/></one:ShapeInfo><one:Data>ADsdA8QBZAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFMkYyBQILZGQDFUYAQAoQAg9FdLKYDk1lhPAD
10FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:24:26.000Z" objectID="{616ED56F-25A4-4C1F-B04A-01347A13D7CC}{42}{B0}"><one:Position x="84.00472259521484" y="149.2488250732422" z="1"/><one:Size width="112.5354309082031" height="0.0283584594726562"/><one:Data>AKgCHQOuAgIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
11Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
12BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKkAFBh+k+5y+YwGazGApnAZjA
13UzE1QCAzOZTMmcygMxmU1gEBgEBgEBgE1mSZzSazKZzSaplM5pNUymaaTWZTNNJqgv6Pk/o+UAAA
14AAAAAAAABRRGAMAAAAkEBRRGAAAAABIAESCTBvW9+tcASYS183z19nlfCgARIKCijAxyUN0BAxVG
15AEAKABEgV/iIP9HObUA=</one:Data></one:InkDrawing></one:Page>
corpus/ink-tools/edited/cold/read/page-003.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Gallery - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-tools/edited/cold/read/page-003.png created
Binary files /dev/null and b/corpus/ink-tools/edited/cold/read/page-003.png differ
corpus/ink-tools/edited/cold/read/page-003.xml created+75
...@@ -0,0 +1,75 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{AEC739A2-6F42-4515-84C1-D418854C78FF}{1}{B0}" name="Gallery" dateTime="2026-09-30T00:22:50.000Z" lastModifiedTime="2026-09-30T00:23:40.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T00:22:51.000Z" lastModifiedTime="2026-09-30T00:22:51.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Gallery]]></one:T></one:OE></one:Title><one:InkDrawing lastModifiedTime="2026-09-30T00:22:55.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{32}{B0}"><one:Position x="39.00472640991211" y="81.75118255615234" z="0"/><one:Size width="150.0094451904297" height="0.0283498764038086"/><one:Data>AJoCHQOSAwIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
3Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
4BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKggEyh+k+5R202e1zOazOzwGa
5zGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv5Vs/lW0AAAAAAAAAUURgBAAAAJ
6BAUURgAAAAASABEg5wN6/ciK60u6WsVDR2cJgAoAESAwfQjWcVDdAQMVRgBACgARIFf4iD+PehBA
7
8</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:22:58.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{38}{B0}"><one:Position x="39.00472640991211" y="95.2440948486328" z="1"/><one:Size width="150.0094451904297" height="0.0283498764038086"/><one:Data>AJ8CHQOSAwIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
9Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBRE7bmQ
10AUUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
11rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/mST+ZJQAAAAAAAABRRG
12AIAAAAkEBRRGAAAAABIAESBGQZxts4QQTbV43z4KBKVpCgARIHD5K9hxUN0BAxVGAEAKABEgV/iI
13P1V3KEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:02.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{44}{B0}"><one:Position x="39.00472640991211" y="108.7370071411133" z="2"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AJ8CHQOSAwIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
14Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREscrt
15BUUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
16rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/nNz+c3QAAAAAAAABRRG
17AMAAAAkEBRRGAAAAABIAESAY7KQuf+viTJV1jzfsq3ctCgARIPA2NdpxUN0BAxVGAEAKABEgV/iI
18Pxt0QEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:05.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{50}{B0}"><one:Position x="39.00472640991211" y="122.2582702636719" z="3"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AJ8CHQOSAwIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
19Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREl/rZ
20AUUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
21rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/oJb+glwAAAAAAAABRRG
22AQAAAAkEBRRGAAAAABIAESAm+zoEY2rUTJ5/d6gaBVuMCgARIICbPtxxUN0BAxVGAEAKABEgV/iI
23P8h9WEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:09.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{56}{B0}"><one:Position x="39.00472640991211" y="135.7511749267578" z="4"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AJ8CHQOSAwIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
24Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREgIGC
25BEUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
26rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/pE7+kTwAAAAAAAABRRG
27AUAAAAkEBRRGAAAAABIAESCnZ/4+38wKT4pCwgl9y8fMCgARIABoRd5xUN0BAxVGAEAKABEgV/iI
28P496cEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:12.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{62}{B0}"><one:Position x="39.00472640991211" y="149.2440948486328" z="5"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AKgCHQOWAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
29Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBRE//8D
30RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH
316T7lHbTZ7XM5rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/qAb+oBw
32AAAAAAAABRRGAYAAAAkEBRRGAIAAABIAESDuqWuD5PkcQbZoQPucPr1dCgARIJBbTOBxUN0BAxVG
33AEAKP0Ain6X8O+fYD+A=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:15.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{68}{B0}"><one:Position x="39.00472640991211" y="162.7370147705078" z="6"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AKkCHQOWAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
34Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMVSBREgP7/
35B0VGRwFGkANXCQAAAFB/BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKggEy
36h+k+5R202e1zOazOzwGazGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv6u+/q7
378AAAAAAAAAUURgHAAAAJBAUURgCAAAASABEgULb3v276vU+yZJBxbMv26goAESAgT1PicVDdAQMV
38RgBACgARIOknhz+inYtA
39</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:19.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{74}{B0}"><one:Position x="39.00472640991211" y="176.2582550048828" z="7"/><one:Size width="150.0094451904297" height="0.0283584594726562"/><one:Data>AJoCHQOUAwQBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
40Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFMkYy
41BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKggEyh+k+5R202e1zOazOzwGa
42zGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv694/r3kAAAAAAAAAUURgIAAAAJ
43BAUURgAAAAASABEg598ld/kfskqe+7EWJ5S2+QoAESCgbmHkcVDdAQMVRgBACj9AIqe1/EC0qBOA
44
45</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:22.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{80}{B0}"><one:Position x="39.00472640991211" y="189.7511749267578" z="8"/><one:Size width="150.0094451904297" height="0.0283584594726562"/><one:Data>AJ8CHQOUAwQBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
46Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBRE7bmQ
47AUUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
48rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/szD+zMQAAAAAAAABRRG
49AkAAAAkEBRRGAAAAABIAESA7WYhg62teTJWERTyI4+4BCgARIAAidOZxUN0BAxVGAEAKP0Aip7X8
50QJrYFQA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:26.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{86}{B0}"><one:Position x="39.00472640991211" y="203.2440948486328" z="9"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AJ8CHQOUAwQBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
51Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREscrt
52BUUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
53rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/tuj+26QAAAAAAAABRRG
54AoAAAAkEBRRGAAAAABIAESCzAtv9BGjgRKJG+TDcSpijCgARIICyhOhxUN0BAxVGAEAKP0Aip7X8
55QIEIFoA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:29.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{92}{B0}"><one:Position x="39.00472640991211" y="216.7370147705078" z="10"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AJ8CHQOUAwQBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
56Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREl/rZ
57AUUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
58rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/uqD+6oQAAAAAAAABRRG
59AsAAAAkEBRRGAAAAABIAESAU7/fpyXd0SJkgxjH5MyTjCgARIOBll+pxUN0BAxVGAEAKP0Aip7X8
60QGc4GAA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:33.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{98}{B0}"><one:Position x="39.00472640991211" y="230.2582550048828" z="11"/><one:Size width="150.0094451904297" height="0.0283584594726562"/><one:Data>AJ8CHQOUAwQBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
61Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREgIGC
62BEUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
63rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/vlr++WwAAAAAAAABRRG
64AwAAAAkEBRRGAAAAABIAESAMVgQMFYRlS7E8QjeztIbhCgARIFCxrOxxUN0BAxVGAEAKP0Aip7X8
65QLSoGYA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:36.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{104}{B0}"><one:Position x="39.00472640991211" y="243.7511749267578" z="12"/><one:Size width="150.0094451904297" height="0.0283584594726562"/><one:Data>AKoCHQOWAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
66Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBREgP4D
67RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqEATKH
686T7lHbTZ7XM5rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/wAEJf4A
69CEwAAAAAAAAFFEYDQAAACQQFFEYAgAAAEgARIJkREIJaMrZPpp8CdhQXBbYKABEgwBq97nFQ3QED
70FUYAQAoAESDpJ4c/26DTQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:40.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{110}{B0}"><one:Position x="39.00472640991211" y="257.2441101074219" z="13"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AKsCHQOWAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
71Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMVSBRE/4H8
72B0VGRwFGkANXCQAAAFB/BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKhAEy
73h+k+5R202e1zOazOzwGazGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv8AC5X+
74ABcsAAAAAAAABRRGA4AAAAkEBRRGAIAAABIAESCoZ3Z0oKXFSKIRRxukb8jmCgARIEA6y/BxUN0B
75AxVGAEAKABEg6SeHPz6f30A=</one:Data></one:InkDrawing></one:Page>
corpus/ink-tools/edited/cold/read/page-004.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Pens - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-tools/edited/cold/read/page-004.png created
Binary files /dev/null and b/corpus/ink-tools/edited/cold/read/page-004.png differ
corpus/ink-tools/edited/cold/read/page-004.xml created+46
...@@ -0,0 +1,46 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{DB8D8C2E-3789-4958-BF3F-202608CD8306}{1}{B0}" name="Pens" dateTime="2026-09-30T00:18:53.000Z" lastModifiedTime="2026-09-30T00:19:49.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T00:18:54.000Z" lastModifiedTime="2026-09-30T00:18:54.000Z" objectID="{350AB5BE-E3CC-4E57-832C-F13E71EC14AC}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Pens]]></one:T></one:OE></one:Title><one:InkDrawing lastModifiedTime="2026-09-30T00:19:39.000Z" objectID="{350AB5BE-E3CC-4E57-832C-F13E71EC14AC}{63}{B0}"><one:Position x="252.0" y="86.4000015258789" z="4"/><one:Size width="72.02835845947266" height="36.02833938598632"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="251.999755859375" y="86.39993286132812"/><one:AnchorPoint x="287.999755859375" y="86.39993286132812"/><one:AnchorPoint x="323.9997863769531" y="86.39993286132812"/><one:AnchorPoint x="323.9997863769531" y="104.3999328613281"/><one:AnchorPoint x="323.9997863769531" y="122.3999404907227"/><one:AnchorPoint x="287.999755859375" y="122.3999404907227"/><one:AnchorPoint x="251.999755859375" y="122.3999404907227"/><one:AnchorPoint x="251.999755859375" y="104.3999328613281"/></one:ShapeInfo><one:Data>AE0dA8QBZAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFMkYyBQILZGQDFUYAQAoiBYfxn5eIBvwEr+Al
3fwErAIfwMneBk/oauhr6GsADFUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:42.000Z" objectID="{350AB5BE-E3CC-4E57-832C-F13E71EC14AC}{71}{B0}"><one:Position x="342.0" y="86.4000015258789" z="5"/><one:Size width="72.02835845947266" height="36.02833938598632"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="342.0" y="86.4000015258789"/><one:AnchorPoint x="414.0" y="122.400001525879"/></one:ShapeInfo><one:Data>ADsdA8QBZAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFMkYyBQILZGQDFUYAQAoQAg9eQuQ0Di+hDeAD
4FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:18:59.000Z" objectID="{350AB5BE-E3CC-4E57-832C-F13E71EC14AC}{32}{B0}"><one:Position x="39.00472640991211" y="87.76062774658203" z="0"/><one:Size width="112.5354309082031" height="0.0283498764038086"/><one:Data>ALUCHQOuAgIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
5Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
6BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKnQFQh+k+580mZAZjAUzEBmMy
7mqATWZTOZTMJrNJnMprNJnAUAgMAgMAgMzmkBgE1mUzmkBmaZTOaTWZTNNJqmUzmk1TKZzSaplM5
8pNSC/lxT+XFQAAAAAAAAAAAAAAUURgBAAAAJBAUURgAAAAASABEg43IZXxePZEqSIvXVtQWmUwo/
9QCMjAkbdBxA7oAMVRgBACgARIFf4iD+GKRtA
10</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T01:05:20.000Z" objectID="{350AB5BE-E3CC-4E57-832C-F13E71EC14AC}{37}{B0}"><one:Position x="194.9952697753906" y="90.73700714111328" z="1"/><one:Size width="0.0283412933349609" height="60.03779220581055"/><one:Data>AK4CHQMCogEBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
11Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
12BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKlgFKgv6q2/qrcAAAAAAAAAAA
13AIfwFFeAeuZJrNJqmc0gMzAgMxmU1mkzgMAgMAgKAQGAQGZzSAzOaQGZzSazKZplM5pNUymZNJqm
14UzJpNUymaaTUmUzABRRGAIAAAAkEBRRGAAAAABIAESCdJ4KnPMDWT4AyoS4EsfmDCj9AIkfCUi0H
15EDugAxVGAEAKABEgmuaMQBt0AEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:45.000Z" objectID="{350AB5BE-E3CC-4E57-832C-F13E71EC14AC}{78}{B0}"><one:Position x="252.0" y="158.4000091552734" z="6"/><one:Size width="72.02835845947266" height="36.02833938598632"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="252.0" y="158.4000091552734"/><one:AnchorPoint x="324.0" y="194.4000091552734"/></one:ShapeInfo><one:Data>AFUdA8QBZAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFMkYyBQILZGQDFUYAQAoQAg9FdLKYDldRrKAD
16FUYAQAoYA4fyMAeRDfjRQIfxDYeH0/g4QAMVRgBA
17</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:49.000Z" objectID="{350AB5BE-E3CC-4E57-832C-F13E71EC14AC}{86}{B0}"><one:Position x="342.0" y="158.4283447265625" z="7"/><one:Size width="72.02835845947266" height="35.97165679931641"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="378.0002746582031" y="158.4002532958984"/><one:AnchorPoint x="414.0002746582031" y="176.4002532958984"/><one:AnchorPoint x="378.0002746582031" y="194.4002532958984"/><one:AnchorPoint x="342.0002746582031" y="176.4002532958984"/></one:ShapeInfo><one:Data>AKABHQPEAWQBECoAGh+LQnBFhHWAvtBF2QQDBkgQRTJGMgUCC2RkAxVGAEAKdTOH8wTHmCtk8nkc
18fkcbjcTh8Lg8DgUGhUOiUajUij0ik0mk0mk0ij0ijUaiUOhUGgUDg8Lh8Tjcbkcfkcnk4IX4fTPh
196tjjnppvtvweBweDweDwODvtvppnjjRRTUUXWXYLAYLBYLBYDBXWXUUTRRADFUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:26.000Z" objectID="{350AB5BE-E3CC-4E57-832C-F13E71EC14AC}{49}{B0}"><one:Position x="39.00472640991211" y="171.7511749267578" z="2"/><one:Size width="150.0094451904297" height="0.0283584594726562"/><one:Data>ANECHQOWAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
20Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBRE//8D
21RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqrAVaH
226T7nL5jAU1mMwmM3mkzmcwmMBmcymaAzGZTVAIDM5lM00mqazRNZpM5lNUAgM0mc1mUAgMAgMzmk
23Bmcymc0msymc0mqZTNNJrMpmmk1mUzTSazKZplMwgv646/rjsAAAAAAAAAAAAAAFFEYBAAAACQQF
24FEYAgAAAEgARIIxv3sLIAtJLsNVUUmVe0nIKABEgwIKVWXFQ3QEDFUYAQAoAESDpJ4c/26CTQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:30.000Z" objectID="{350AB5BE-E3CC-4E57-832C-F13E71EC14AC}{54}{B0}"><one:Position x="188.9858245849609" y="239.2440948486328" z="3"/><one:Size width="30.0472469329834" height="22.53543090820312"/><one:Data>AMkCHQJSPgFgKgAaH4tCcEWEdYC+0EXZBPqHlTLy7BdBuMAWv/Mr9HreuxOL5V6zSJyboSVat1ES
25v+xxzMwisUGJDrsTLO56oVjPVIrml8VPjwb4utLhmyLEZm2nDbJYSZodTkA0BmwQAwZIFEUjRiMF
26CAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqyATeH8RSniIrmcyJrNJrAIDNJ
27nAYBAYBAUzmkBmc0gMzmk1mUzmk1mUzTSazKZppNZlM0ymc0mqZTNNJqh/GDd4wOYDNJrAIDNJnA
28ZnNIDAIDAJrMkzmk1mUzmk1mUzTSaplM00mpMpmmk1JlM00mpMpmBRRGAUAAAAkEBRRGAAAAABIA
29ESAy8nFzf6jGRYlYDFxmSkMdCj9AI42C380HEDugAxVGAEAKABEgHo+nQKo71EA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T01:05:21.000Z" objectID="{6729AFE0-D1B1-0CF4-3BA6-E9CDC9337A20}{4}{B0}"><one:Position x="60.00944900512695" y="338.031494140625" z="9"/><one:Size width="160.0157470703125" height="23.98109436035156"/><one:Data>AKACHQOuA0IBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
30Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREn7Tq
31A0UjRiMFCAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQAqDASmC/j27+OVQJQEo
32CUBKAIfycqeTwmIRaPSKUSaUSKRRqIQyAQeGxmNyWSyuSymPx2KwuBwSGRaNSKTSaUSaQR6JAAUU
33RgHAAAAJBAUURgAAAAASABEgqcqAxLBes0uG2h8ws7R+rwoAESCAHvbDd1DdAQMVRgBACgARIGia
340z8IBBZB
35</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T01:05:21.000Z" objectID="{6729AFE0-D1B1-0CF4-3BA6-E9CDC9337A20}{7}{B0}"><one:Position x="252.0" y="340.41259765625" z="10"/><one:Size width="72.02835845947266" height="54.02832412719727"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="252.0" y="340.3999633789062"/><one:AnchorPoint x="288.0" y="340.3999633789062"/><one:AnchorPoint x="324.0" y="340.3999633789062"/><one:AnchorPoint x="324.0" y="367.3999633789062"/><one:AnchorPoint x="324.0" y="394.3999633789062"/><one:AnchorPoint x="288.0" y="394.3999633789062"/><one:AnchorPoint x="252.0" y="394.3999633789062"/><one:AnchorPoint x="252.0" y="367.3999633789062"/></one:ShapeInfo><one:Data>AFMdBMQBlAEBECoAGh+LQnBFhHWAvtBF2QQDC0gQRJ+06gNFMkYyBQILZGQDFUYAQAoiBYfxn5eI
36BvwEr+AlfwErAIfyYoeTFHtYO1h7WEADFUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T01:05:21.000Z" objectID="{6729AFE0-D1B1-0CF4-3BA6-E9CDC9337A20}{9}{B0}"><one:Position x="342.0" y="340.4692993164062" z="11"/><one:Size width="90.02835845947266" height="53.91495895385742"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="387.0" y="340.3999633789062"/><one:AnchorPoint x="432.0" y="367.3999633789062"/><one:AnchorPoint x="387.0" y="394.3999633789062"/><one:AnchorPoint x="342.0" y="367.3999633789062"/></one:ShapeInfo><one:Data>AK0BHQT0AZQBARAqABofi0JwRYR1gL7QRdkEAwtIEESftOoDRTJGMgUCC2RkAxVGAEAKfDOH8yx3
37mWpl8vl8rk8ljsbicPhMBgEIh0SjUckkmlUul0umUul0ulUmkkcjUSh0IgEBhMPicbjslk8rl8vl
384IbyoBeUxWDhYuNj5eTm5mbnZ2dnZuZm5OXj42LhYOBhISKjI6WkpqYmpycnJyamJqSlo6MioSEA
39AxVGAEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T01:05:20.000Z" objectID="{6729AFE0-D1B1-0CF4-3BA6-E9CDC9337A20}{2}{B0}"><one:Position x="60.00944900512695" y="390.0188903808594" z="8"/><one:Size width="160.0157470703125" height="0.0283241271972656"/><one:Data>APEBHQOwAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
40Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBRE//8D
41RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQApMAg4h
42FeUQD2t+1vwFFEYBgAAACQQFFEYAgAAAEgARIKX0Fhytz+FHincjDAF4OcwKABEgAIhdw3dQ3QED
43FUYAQAoAESDh1tE/ctIqQQ==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T01:05:21.000Z" objectID="{6729AFE0-D1B1-0CF4-3BA6-E9CDC9337A20}{12}{B0}"><one:Position x="252.0" y="430.41259765625" z="12"/><one:Size width="108.0283584594727" height="37.55902862548828"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="252.0" y="430.3999633789062"/><one:AnchorPoint x="360.0" y="466.3999633789062"/></one:ShapeInfo><one:Data>AFodA6QCaAEQKgAaH4tCcEWEdYC+0EXZBAMLSBBEn7TqA0UyRjIFAgtkZAMVRgBAChACD0V0xnAQ
44O1BARgMVRgBAChgDh/J815Nf+OPAh/Nnd5q0+AvAAxVGAEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T01:05:21.000Z" objectID="{6729AFE0-D1B1-0CF4-3BA6-E9CDC9337A20}{14}{B0}"><one:Position x="378.0" y="430.41259765625" z="13"/><one:Size width="72.02835845947266" height="0.0283241271972656"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="378.0" y="430.3999633789062"/><one:AnchorPoint x="450.0" y="430.3999633789062"/></one:ShapeInfo><one:Data>AEAdA8QBBAEQKgAaH4tCcEWEdYC+0EXZBAMLSBBEn7TqA0UyRjIFAgtkZAMVRgBAChACD2gu+AwP
45dqDtQAMVRgBA
46</one:Data></one:InkDrawing></one:Page>
corpus/ink-tools/edited/cold/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/ink-tools/edited/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/snowbound-ink/gate/edited-candidate",
3 "expected_pages": 5,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
17 }
18}
corpus/ink-tools/edited/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/ink-tools/edited/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/ink-tools/edited/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1596,
5 "sha256": "92301b5ba63ac2b7ae8721a2846a3827d75cc944ed2277569f690fbd4d0c687b",
6 "mtime_ns": 1790730321004978035
7 },
8 {
9 "path": "ink.one",
10 "bytes": 86952,
11 "sha256": "3c6809d655b86b6b2afdaa8fe49d80da211f562dac987eb52cd7545601ec1d1a",
12 "mtime_ns": 1790730321004761537
13 }
14]
corpus/ink-tools/edited/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/ink-tools/native-ui/commands.jsonl created+3
...@@ -0,0 +1,3 @@
1{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-A8B36F2B -ExpectedPages 5 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 5 pages.\r\n", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\read -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
corpus/ink-tools/native-ui/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-a8b36f2b", "hostname": "ONE-M6-A8B36F2B"}
corpus/ink-tools/native-ui/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-A8B36F2B",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/ink-tools/native-ui/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{9CAD55E2-13A6-462A-847F-FFEDB6D54293}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T01:13:37.000Z" color="#EE9597"><one:Section name="ink" ID="{9C8EFC66-2CF4-0F8E-25EA-9196AA36B971}{1}{B0}" path="C:\one-tests\runs\capture\notebook\ink.one" lastModifiedTime="2026-09-30T01:13:37.000Z" color="#8AA8E4"><one:Page ID="{76F98ACC-6A0A-0B2D-2CD4-8EA14A7A9D79}{1}{B0}" name="Beside the drawing" dateTime="2026-09-13T00:25:49.000Z" lastModifiedTime="2026-09-30T01:13:37.000Z" pageLevel="1"/><one:Page ID="{E07965DF-76A7-4A4D-9F08-9D6699A7C453}{1}{B0}" name="Pens" dateTime="2026-09-30T00:18:53.000Z" lastModifiedTime="2026-09-30T00:19:49.000Z" pageLevel="1"/><one:Page ID="{9533D053-2E6C-4600-A4F6-695814263FAA}{1}{B0}" name="Gallery" dateTime="2026-09-30T00:22:50.000Z" lastModifiedTime="2026-09-30T00:23:40.000Z" pageLevel="1"/><one:Page ID="{69764256-D108-425C-B9AF-D7B601C35DDF}{1}{B0}" name="Edits" dateTime="2026-09-30T00:24:17.000Z" lastModifiedTime="2026-09-30T00:25:14.000Z" pageLevel="1"/><one:Page ID="{2DAAE5FB-8675-4762-829E-32DF94FE6CD3}{1}{B0}" name="Shapes2" dateTime="2026-09-30T00:29:10.000Z" lastModifiedTime="2026-09-30T00:32:48.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/ink-tools/native-ui/read/page-000.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Shapes2 - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-tools/native-ui/read/page-000.png created
Binary files /dev/null and b/corpus/ink-tools/native-ui/read/page-000.png differ
corpus/ink-tools/native-ui/read/page-000.xml created+24
...@@ -0,0 +1,24 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{2DAAE5FB-8675-4762-829E-32DF94FE6CD3}{1}{B0}" name="Shapes2" dateTime="2026-09-30T00:29:10.000Z" lastModifiedTime="2026-09-30T00:32:48.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T00:29:11.000Z" lastModifiedTime="2026-09-30T00:29:11.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Shapes2]]></one:T></one:OE></one:Title><one:InkDrawing lastModifiedTime="2026-09-30T00:29:28.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{40}{B0}"><one:Position x="270.0" y="50.39999771118164" z="1"/><one:Size width="21.60001373291016" height="72.02834320068359"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="270.0" y="50.39999771118164"/><one:AnchorPoint x="288.0" y="122.400001525879"/></one:ShapeInfo><one:Data>AFodA0TMAQEQKgAaH4tCcEWEdYC+0EXZBAMLSBBE/wFFlgFGlgEFAgtkZAMVRgBAChACD0pqnsAO
3G8kN4AMVRgBAChgDh/H214/1+B3Ah/BeZ4HX+ZfAAxVGAEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:29:25.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{31}{B0}"><one:Position x="36.0" y="74.74960327148437" z="0"/><one:Size width="190.5165405273437" height="14.740159034729"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="36.0" y="86.4000015258789"/><one:AnchorPoint x="226.5000152587891" y="81.75000762939453"/></one:ShapeInfo><one:Data>AFkdA4gEMgEQKgAaH4tCcEWEdYC+0EXZBAMLSBBE/wFFlgFGlgEFAgtkZAMVRgBAChACDhPZ82AN
4X0LRAAMVRgBAChcDh/FGd4ks+adAh/AYx4BK5gADFUYAQA==</one:Data></one:InkDrawing><one:InkDrawing isUnclassified="true" lastModifiedTime="2026-09-30T00:32:48.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{123}{B0}"><one:Position x="54.0" y="160.4976348876953" z="12"/><one:Size width="60.03779220581055" height="7.540157318115234"/><one:Data>ALYCHQOiARYBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
5Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
6BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKngE4h+1g7FmATebTWAzGAoBA
7ZnNJvACZzKazSawCAwCAwCAwCazKATWZTOZTOaTVMpnNJqmUzmk1TKZppNSH8NXXhq7mk1mUzmUz
8mk1TKZppNUymZNJqmUzTSagAAAAFFEYAQAAACQQFFEYAAAAAEgARIH7bL5LspZlKvDhZGnSznPUK
9P0Ai5AG7fQcwO6ADFUYAQAo/QCMlgf3zx5gRwA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:29:31.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{48}{B0}"><one:Position x="81.0" y="162.9000244140625" z="2"/><one:Size width="72.02835845947266" height="54.02833938598632"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="36.0" y="140.4005889892578"/><one:AnchorPoint x="71.99999237060547" y="140.4005889892578"/><one:AnchorPoint x="107.9999847412109" y="140.4005889892578"/><one:AnchorPoint x="107.9999847412109" y="167.4005889892578"/><one:AnchorPoint x="107.9999847412109" y="194.4005889892578"/><one:AnchorPoint x="71.99999237060547" y="194.4005889892578"/><one:AnchorPoint x="36.0" y="194.4005889892578"/><one:AnchorPoint x="36.0" y="167.4005889892578"/></one:ShapeInfo><one:Data>AEwdBMIBkgEBECoAGh+LQnBFhHWAvtBF2QQDBkgQRRlGGQUCC2RkAxVGAEAKIAWH6GroavASv4CV
10/ASsh/Cph4VMe1g7WHtYQAMVRgBA
11</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:29:49.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{56}{B0}"><one:Position x="180.0" y="209.2535400390625" z="3"/><one:Size width="234.0283508300781" height="3.174808502197266"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="212.4000091552734"/><one:AnchorPoint x="414.0" y="209.25"/></one:ShapeInfo><one:Data>ADsdA/IEDAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFI0YjBQILZGQDFUYAQAoQAg8xnOQ0DnUVzWAD
12FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:29:54.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{64}{B0}"><one:Position x="180.0" y="212.4000091552734" z="4"/><one:Size width="234.0283508300781" height="4.365348815917969"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="212.4000091552734"/><one:AnchorPoint x="414.0" y="216.75"/></one:ShapeInfo><one:Data>ADsdA/QEEAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFMkYyBQILZGQDFUYAQAoQAg8xnOQ0DnUV3eAD
13FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:29:59.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{72}{B0}"><one:Position x="180.0" y="224.2488098144531" z="5"/><one:Size width="234.0283508300781" height="6.179534912109375"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="230.4000091552734"/><one:AnchorPoint x="414.0" y="224.2500152587891"/></one:ShapeInfo><one:Data>ADsdA/YEFgEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFRkZGBQILZGQDFUYAQAoQAg8xnOQ0Dn8B7nAD
14FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:10.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{88}{B0}"><one:Position x="180.0" y="230.4000091552734" z="7"/><one:Size width="234.0283508300781" height="8.87243843078613"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="230.4000091552734"/><one:AnchorPoint x="414.0" y="239.25"/></one:ShapeInfo><one:Data>AD0dA/wEIgEQKgAaH4tCcEWEdYC+0EXZBAMISBBFlgFGlgEFAgtkZAMVRgBAChACDzGc5DQPP4CD
154AMVRgBA
16</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:05.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{80}{B0}"><one:Position x="180.0" y="230.4000091552734" z="6"/><one:Size width="234.0283508300781" height="1.38896369934082"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="230.4000091552734"/><one:AnchorPoint x="414.0" y="231.75"/></one:ShapeInfo><one:Data>ADsdA/gEDAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFZEZkBQILZGQDFUYAQAoQAg8xnOQ0Dn8B/wAD
17FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:20.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{104}{B0}"><one:Position x="180.0" y="248.4000091552734" z="9"/><one:Size width="234.0283508300781" height="5.867712020874023"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="248.4000091552734"/><one:AnchorPoint x="414.0" y="254.25"/></one:ShapeInfo><one:Data>AD0dA4oFKgEQKgAaH4tCcEWEdYC+0EXZBAMISBBF3gJG3gIFAgtkZAMVRgBAChACDzGc5DQPRHaM
18JAMVRgBA
19</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:15.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{96}{B0}"><one:Position x="180.0" y="246.7559051513672" z="8"/><one:Size width="234.0283508300781" height="1.672445297241211"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="248.4000091552734"/><one:AnchorPoint x="414.0" y="246.7500152587891"/></one:ShapeInfo><one:Data>AD0dA4AFFAEQKgAaH4tCcEWEdYC+0EXZBAMISBBFyAFGyAEFAgtkZAMVRgBAChACDzGc5DQPRHaI
20BAMVRgBA
21</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:25.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{112}{B0}"><one:Position x="180.0" y="254.2393798828125" z="10"/><one:Size width="234.0283508300781" height="12.1889705657959"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="266.3999938964844"/><one:AnchorPoint x="414.0" y="254.2393798828125"/></one:ShapeInfo><one:Data>AD0dA5YFRgEQKgAaH4tCcEWEdYC+0EXZBAMISBBF9ANG9AMFAgtkZAMVRgBAChACDzGc5DQPSWyM
22JAMVRgBA
23</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:31.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{120}{B0}"><one:Position x="180.0" y="266.3999938964844" z="11"/><one:Size width="234.0283508300781" height="72.02834320068359"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="266.3999938964844"/><one:AnchorPoint x="414.0" y="338.3999938964844"/></one:ShapeInfo><one:Data>AD4dBJYF5gEBECoAGh+LQnBFhHWAvtBF2QQDCEgQRfQDRvQDBQILZGQDFUYAQAoQAg8xnOQ0D0ls
24uogDFUYAQA==</one:Data></one:InkDrawing></one:Page>
corpus/ink-tools/native-ui/read/page-001.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Edits - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-tools/native-ui/read/page-001.png created
Binary files /dev/null and b/corpus/ink-tools/native-ui/read/page-001.png differ
corpus/ink-tools/native-ui/read/page-001.xml created+15
...@@ -0,0 +1,15 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{69764256-D108-425C-B9AF-D7B601C35DDF}{1}{B0}" name="Edits" dateTime="2026-09-30T00:24:17.000Z" lastModifiedTime="2026-09-30T00:25:14.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T00:24:18.000Z" lastModifiedTime="2026-09-30T00:24:18.000Z" objectID="{5A9A3C9E-648A-4F0A-907D-BC74EB799099}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Edits]]></one:T></one:OE></one:Title><one:InkDrawing lastModifiedTime="2026-09-30T00:24:32.000Z" objectID="{5A9A3C9E-648A-4F0A-907D-BC74EB799099}{56}{B0}"><one:Position x="252.0" y="50.39999771118164" z="2"/><one:Size width="72.02835845947266" height="54.02834701538086"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="251.9998931884766" y="50.4000587463379"/><one:AnchorPoint x="287.9998474121094" y="50.4000587463379"/><one:AnchorPoint x="323.9997863769531" y="50.4000587463379"/><one:AnchorPoint x="323.9997863769531" y="77.40003967285156"/><one:AnchorPoint x="323.9997863769531" y="104.400016784668"/><one:AnchorPoint x="287.9998474121094" y="104.400016784668"/><one:AnchorPoint x="251.9998931884766" y="104.400016784668"/><one:AnchorPoint x="251.9998931884766" y="77.40003967285156"/></one:ShapeInfo><one:Data>AEwdBMQBlAEBECoAGh+LQnBFhHWAvtBF2QQDBkgQRTJGMgUCC2RkAxVGAEAKIAWH8Z+XiAb8BK/g
3JX8BKwCH7GLsY+1g7WHtYQMVRgBA
4</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:24:22.000Z" objectID="{5A9A3C9E-648A-4F0A-907D-BC74EB799099}{32}{B0}"><one:Position x="39.00472640991211" y="59.24409484863281" z="0"/><one:Size width="112.5354309082031" height="0.0283455848693848"/><one:Data>AKgCHQOuAgIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
5Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
6BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKkAFBh+k+5y+YwGazGApnAZjA
7UzE1QCAzOZTMmcygMxmU1gEBgEBgEBgE1mSZzSazKZzSaplM5pNUymaaTWZTNNJqgv484/jzkAAA
8AAAAAAAABRRGAEAAAAkEBRRGAAAAABIAESBoVDskhZV+SLo2P5B9KRgDCgARIAD8ywlyUN0BAxVG
9AEAKABEgV/iIP6ru0D8=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:24:35.000Z" objectID="{5A9A3C9E-648A-4F0A-907D-BC74EB799099}{64}{B0}"><one:Position x="252.0" y="140.4000091552734" z="3"/><one:Size width="72.02835845947266" height="36.02833938598632"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="252.0" y="140.4000091552734"/><one:AnchorPoint x="324.0" y="176.4000091552734"/></one:ShapeInfo><one:Data>ADsdA8QBZAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFMkYyBQILZGQDFUYAQAoQAg9FdLKYDk1lhPAD
10FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:24:26.000Z" objectID="{5A9A3C9E-648A-4F0A-907D-BC74EB799099}{42}{B0}"><one:Position x="84.00472259521484" y="149.2488250732422" z="1"/><one:Size width="112.5354309082031" height="0.0283584594726562"/><one:Data>AKgCHQOuAgIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
11Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
12BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKkAFBh+k+5y+YwGazGApnAZjA
13UzE1QCAzOZTMmcygMxmU1gEBgEBgEBgE1mSZzSazKZzSaplM5pNUymaaTWZTNNJqgv6Pk/o+UAAA
14AAAAAAAABRRGAMAAAAkEBRRGAAAAABIAESCTBvW9+tcASYS183z19nlfCgARIKCijAxyUN0BAxVG
15AEAKABEgV/iIP9HObUA=</one:Data></one:InkDrawing></one:Page>
corpus/ink-tools/native-ui/read/page-002.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Beside the drawing - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-tools/native-ui/read/page-002.png created
Binary files /dev/null and b/corpus/ink-tools/native-ui/read/page-002.png differ
corpus/ink-tools/native-ui/read/page-002.xml created+7
...@@ -0,0 +1,7 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{76F98ACC-6A0A-0B2D-2CD4-8EA14A7A9D79}{1}{B0}" name="Beside the drawing" dateTime="2026-09-13T00:25:49.000Z" lastModifiedTime="2026-09-30T01:13:37.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-13T00:25:49.000Z" objectID="{9C8EFC66-2CF4-0F8E-25EA-9196AA36B971}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="152.4244079589843" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-13T00:25:49.000Z" lastModifiedTime="2026-09-13T00:25:49.000Z" objectID="{9C8EFC66-2CF4-0F8E-25EA-9196AA36B971}{24}{B0}" alignment="left"><one:T><![CDATA[Beside the drawing]]></one:T></one:OE></one:OEChildren></one:Outline><one:InkDrawing lastModifiedTime="2026-09-13T00:25:49.000Z" objectID="{6AA48742-6050-0C44-0E78-965A4ABA9A71}{0}{B0}"><one:Position x="299.9905395507812" y="119.9905471801758" z="1"/><one:Size width="60.03781127929687" height="60.03779220581055"/><one:Data>AM8CHQSiAaIBAWAqABofi0JwRYR1gL7QRdkE+oeVMvLsF0G4wBa/8yv0et67E4vlXrNInJuhJVq3
3URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBACEQZIEEUj
4RiMJSBBE/wFFI0YjBQgLZU9mZ2hkaRkUMggAgB4CIeLiQTMIAPAQAovF4kFkAxVGAEAKXAWH8gln
5jCj7wnvCe8IAh/B8h4Pke8I7wnvCQAUURgBAAAAJBAUURgAAAAASABEg7Ks5v90DzE+YKNcBjx6b
6FwoAESCA5B1tFkPdAQMVRgBACj9AIm9GCC70WAqACQEKTAIPUq7GcA5CJYzgBRRGAIAAAAkEBRRG
7AAAAABIAESBSpMe3m/jlRpcE5ouTMwiuCgARIIDkHW0WQ90BAxVGAEAKP0Aib0YILvRYCoA=</one:Data></one:InkDrawing></one:Page>
corpus/ink-tools/native-ui/read/page-003.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Gallery - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-tools/native-ui/read/page-003.png created
Binary files /dev/null and b/corpus/ink-tools/native-ui/read/page-003.png differ
corpus/ink-tools/native-ui/read/page-003.xml created+75
...@@ -0,0 +1,75 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{9533D053-2E6C-4600-A4F6-695814263FAA}{1}{B0}" name="Gallery" dateTime="2026-09-30T00:22:50.000Z" lastModifiedTime="2026-09-30T00:23:40.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T00:22:51.000Z" lastModifiedTime="2026-09-30T00:22:51.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Gallery]]></one:T></one:OE></one:Title><one:InkDrawing lastModifiedTime="2026-09-30T00:22:55.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{32}{B0}"><one:Position x="39.00472640991211" y="81.75118255615234" z="0"/><one:Size width="150.0094451904297" height="0.0283498764038086"/><one:Data>AJoCHQOSAwIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
3Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
4BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKggEyh+k+5R202e1zOazOzwGa
5zGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv5Vs/lW0AAAAAAAAAUURgBAAAAJ
6BAUURgAAAAASABEg5wN6/ciK60u6WsVDR2cJgAoAESAwfQjWcVDdAQMVRgBACgARIFf4iD+PehBA
7
8</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:22:58.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{38}{B0}"><one:Position x="39.00472640991211" y="95.2440948486328" z="1"/><one:Size width="150.0094451904297" height="0.0283498764038086"/><one:Data>AJ8CHQOSAwIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
9Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBRE7bmQ
10AUUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
11rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/mST+ZJQAAAAAAAABRRG
12AIAAAAkEBRRGAAAAABIAESBGQZxts4QQTbV43z4KBKVpCgARIHD5K9hxUN0BAxVGAEAKABEgV/iI
13P1V3KEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:02.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{44}{B0}"><one:Position x="39.00472640991211" y="108.7370071411133" z="2"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AJ8CHQOSAwIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
14Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREscrt
15BUUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
16rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/nNz+c3QAAAAAAAABRRG
17AMAAAAkEBRRGAAAAABIAESAY7KQuf+viTJV1jzfsq3ctCgARIPA2NdpxUN0BAxVGAEAKABEgV/iI
18Pxt0QEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:05.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{50}{B0}"><one:Position x="39.00472640991211" y="122.2582702636719" z="3"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AJ8CHQOSAwIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
19Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREl/rZ
20AUUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
21rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/oJb+glwAAAAAAAABRRG
22AQAAAAkEBRRGAAAAABIAESAm+zoEY2rUTJ5/d6gaBVuMCgARIICbPtxxUN0BAxVGAEAKABEgV/iI
23P8h9WEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:09.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{56}{B0}"><one:Position x="39.00472640991211" y="135.7511749267578" z="4"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AJ8CHQOSAwIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
24Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREgIGC
25BEUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
26rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/pE7+kTwAAAAAAAABRRG
27AUAAAAkEBRRGAAAAABIAESCnZ/4+38wKT4pCwgl9y8fMCgARIABoRd5xUN0BAxVGAEAKABEgV/iI
28P496cEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:12.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{62}{B0}"><one:Position x="39.00472640991211" y="149.2440948486328" z="5"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AKgCHQOWAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
29Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBRE//8D
30RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH
316T7lHbTZ7XM5rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/qAb+oBw
32AAAAAAAABRRGAYAAAAkEBRRGAIAAABIAESDuqWuD5PkcQbZoQPucPr1dCgARIJBbTOBxUN0BAxVG
33AEAKP0Ain6X8O+fYD+A=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:15.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{68}{B0}"><one:Position x="39.00472640991211" y="162.7370147705078" z="6"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AKkCHQOWAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
34Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMVSBREgP7/
35B0VGRwFGkANXCQAAAFB/BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKggEy
36h+k+5R202e1zOazOzwGazGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv6u+/q7
378AAAAAAAAAUURgHAAAAJBAUURgCAAAASABEgULb3v276vU+yZJBxbMv26goAESAgT1PicVDdAQMV
38RgBACgARIOknhz+inYtA
39</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:19.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{74}{B0}"><one:Position x="39.00472640991211" y="176.2582550048828" z="7"/><one:Size width="150.0094451904297" height="0.0283584594726562"/><one:Data>AJoCHQOUAwQBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
40Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFMkYy
41BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKggEyh+k+5R202e1zOazOzwGa
42zGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv694/r3kAAAAAAAAAUURgIAAAAJ
43BAUURgAAAAASABEg598ld/kfskqe+7EWJ5S2+QoAESCgbmHkcVDdAQMVRgBACj9AIqe1/EC0qBOA
44
45</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:22.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{80}{B0}"><one:Position x="39.00472640991211" y="189.7511749267578" z="8"/><one:Size width="150.0094451904297" height="0.0283584594726562"/><one:Data>AJ8CHQOUAwQBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
46Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBRE7bmQ
47AUUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
48rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/szD+zMQAAAAAAAABRRG
49AkAAAAkEBRRGAAAAABIAESA7WYhg62teTJWERTyI4+4BCgARIAAidOZxUN0BAxVGAEAKP0Aip7X8
50QJrYFQA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:26.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{86}{B0}"><one:Position x="39.00472640991211" y="203.2440948486328" z="9"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AJ8CHQOUAwQBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
51Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREscrt
52BUUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
53rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/tuj+26QAAAAAAAABRRG
54AoAAAAkEBRRGAAAAABIAESCzAtv9BGjgRKJG+TDcSpijCgARIICyhOhxUN0BAxVGAEAKP0Aip7X8
55QIEIFoA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:29.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{92}{B0}"><one:Position x="39.00472640991211" y="216.7370147705078" z="10"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AJ8CHQOUAwQBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
56Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREl/rZ
57AUUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
58rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/uqD+6oQAAAAAAAABRRG
59AsAAAAkEBRRGAAAAABIAESAU7/fpyXd0SJkgxjH5MyTjCgARIOBll+pxUN0BAxVGAEAKP0Aip7X8
60QGc4GAA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:33.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{98}{B0}"><one:Position x="39.00472640991211" y="230.2582550048828" z="11"/><one:Size width="150.0094451904297" height="0.0283584594726562"/><one:Data>AJ8CHQOUAwQBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
61Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREgIGC
62BEUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
63rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/vlr++WwAAAAAAAABRRG
64AwAAAAkEBRRGAAAAABIAESAMVgQMFYRlS7E8QjeztIbhCgARIFCxrOxxUN0BAxVGAEAKP0Aip7X8
65QLSoGYA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:36.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{104}{B0}"><one:Position x="39.00472640991211" y="243.7511749267578" z="12"/><one:Size width="150.0094451904297" height="0.0283584594726562"/><one:Data>AKoCHQOWAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
66Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBREgP4D
67RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqEATKH
686T7lHbTZ7XM5rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/wAEJf4A
69CEwAAAAAAAAFFEYDQAAACQQFFEYAgAAAEgARIJkREIJaMrZPpp8CdhQXBbYKABEgwBq97nFQ3QED
70FUYAQAoAESDpJ4c/26DTQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:40.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{110}{B0}"><one:Position x="39.00472640991211" y="257.2441101074219" z="13"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AKsCHQOWAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
71Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMVSBRE/4H8
72B0VGRwFGkANXCQAAAFB/BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKhAEy
73h+k+5R202e1zOazOzwGazGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv8AC5X+
74ABcsAAAAAAAABRRGA4AAAAkEBRRGAIAAABIAESCoZ3Z0oKXFSKIRRxukb8jmCgARIEA6y/BxUN0B
75AxVGAEAKABEg6SeHPz6f30A=</one:Data></one:InkDrawing></one:Page>
corpus/ink-tools/native-ui/read/page-004.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Pens - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-tools/native-ui/read/page-004.png created
Binary files /dev/null and b/corpus/ink-tools/native-ui/read/page-004.png differ
corpus/ink-tools/native-ui/read/page-004.xml created+39
...@@ -0,0 +1,39 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{E07965DF-76A7-4A4D-9F08-9D6699A7C453}{1}{B0}" name="Pens" dateTime="2026-09-30T00:18:53.000Z" lastModifiedTime="2026-09-30T00:19:49.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T00:18:54.000Z" lastModifiedTime="2026-09-30T00:18:54.000Z" objectID="{0EFE5C4F-A2E2-4D42-A31B-4C7EE08653F9}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Pens]]></one:T></one:OE></one:Title><one:InkDrawing lastModifiedTime="2026-09-30T00:19:01.000Z" objectID="{0EFE5C4F-A2E2-4D42-A31B-4C7EE08653F9}{37}{B0}"><one:Position x="158.9952697753906" y="72.73700714111328" z="1"/><one:Size width="0.0283412933349609" height="60.03779220581055"/><one:Data>AK4CHQMCogEBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
3Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
4BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKlgFKgv6q2/qrcAAAAAAAAAAA
5AIfwFFeAeuZJrNJqmc0gMzAgMxmU1mkzgMAgMAgKAQGAQGZzSAzOaQGZzSazKZplM5pNUymZNJqm
6UzJpNUymaaTUmUzABRRGAIAAAAkEBRRGAAAAABIAESCdJ4KnPMDWT4AyoS4EsfmDCj9AIkfCUi0H
7EDugAxVGAEAKABEgmuaMQBt0AEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:39.000Z" objectID="{0EFE5C4F-A2E2-4D42-A31B-4C7EE08653F9}{63}{B0}"><one:Position x="252.0" y="86.4000015258789" z="5"/><one:Size width="72.02835845947266" height="36.02833938598632"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="251.999755859375" y="86.39993286132812"/><one:AnchorPoint x="287.999755859375" y="86.39993286132812"/><one:AnchorPoint x="323.9997863769531" y="86.39993286132812"/><one:AnchorPoint x="323.9997863769531" y="104.3999328613281"/><one:AnchorPoint x="323.9997863769531" y="122.3999404907227"/><one:AnchorPoint x="287.999755859375" y="122.3999404907227"/><one:AnchorPoint x="251.999755859375" y="122.3999404907227"/><one:AnchorPoint x="251.999755859375" y="104.3999328613281"/></one:ShapeInfo><one:Data>AE0dA8QBZAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFMkYyBQILZGQDFUYAQAoiBYfxn5eIBvwEr+Al
8fwErAIfwMneBk/oauhr6GsADFUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:42.000Z" objectID="{0EFE5C4F-A2E2-4D42-A31B-4C7EE08653F9}{71}{B0}"><one:Position x="342.0" y="86.4000015258789" z="6"/><one:Size width="72.02835845947266" height="36.02833938598632"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="342.0" y="86.4000015258789"/><one:AnchorPoint x="414.0" y="122.400001525879"/></one:ShapeInfo><one:Data>ADsdA8QBZAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFMkYyBQILZGQDFUYAQAoQAg9eQuQ0Di+hDeAD
9FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:18:59.000Z" objectID="{0EFE5C4F-A2E2-4D42-A31B-4C7EE08653F9}{32}{B0}"><one:Position x="39.00472640991211" y="87.76062774658203" z="0"/><one:Size width="112.5354309082031" height="0.0283498764038086"/><one:Data>ALUCHQOuAgIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
10Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
11BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKnQFQh+k+580mZAZjAUzEBmMy
12mqATWZTOZTMJrNJnMprNJnAUAgMAgMAgMzmkBgE1mUzmkBmaZTOaTWZTNNJqmUzmk1TKZzSaplM5
13pNSC/lxT+XFQAAAAAAAAAAAAAAUURgBAAAAJBAUURgAAAAASABEg43IZXxePZEqSIvXVtQWmUwo/
14QCMjAkbdBxA7oAMVRgBACgARIFf4iD+GKRtA
15</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:23.000Z" objectID="{0EFE5C4F-A2E2-4D42-A31B-4C7EE08653F9}{43}{B0}"><one:Position x="39.00472640991211" y="89.26298522949218" z="2"/><one:Size width="90.02834320068359" height="22.50708961486816"/><one:Data>AOgEHQP0AUABYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
16Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBRE7bmQ
17AUUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQArLA9UBh+k+6QuATKaw
18CAzSawCazKAQGAQGZzSAzOaTWZTOZTOaTVMpmmk1mUzTSazKZppNZlM00moJkmkzgMAmU1gE1mUA
19gKATWZQCazKZzSazKZzSaplM00msymaZTOaTVMpnNJqmUzBNIDNJnAYBMJjAZnNIDAIDAJrMoBNZ
20lM5lM5pNZlM00mqZTOaTVMpnNJqmUzmk1TKZgmkBmk1gEBmkzgMzmkBQCAzOaQGZzSazKZzSazKZ
21ppNUymc0mqZTOZTNNJrMpmmk1ACH8GpXg21mk1mkBgE1mUAmU1gEBgEBmkzTKazSZzKazSZzJNZo
22mcyJrNEzmSazQmcyTOZCZJpM4DAJlNYBNZkgEBgE1mUAmsymc0msymc0mqZTNNJqTKZplM00mpMp
23mmk1Jlh2aTWaQGATWZQCZTWAQGAQGaTNMprNJnMprNJnMk1miZzIms0TOZJrNCZzJM5kJkmkzgMA
24mU1gE1mSAQGATWZQCazKZzSazKZzSaplM00mpMpmmUzTSakymaaTUmQFFEYAwAAACQQFFEYAAAAA
25EgARICQJs8oEpxxMtZFlXTzuPzUKABEgQNoRVnFQ3QEDFUYAQAo/QCKntfxG4PgDoA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:45.000Z" objectID="{0EFE5C4F-A2E2-4D42-A31B-4C7EE08653F9}{78}{B0}"><one:Position x="252.0" y="158.4000091552734" z="7"/><one:Size width="72.02835845947266" height="36.02833938598632"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="252.0" y="158.4000091552734"/><one:AnchorPoint x="324.0" y="194.4000091552734"/></one:ShapeInfo><one:Data>AFUdA8QBZAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFMkYyBQILZGQDFUYAQAoQAg9FdLKYDldRrKAD
26FUYAQAoYA4fyMAeRDfjRQIfxDYeH0/g4QAMVRgBA
27</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:49.000Z" objectID="{0EFE5C4F-A2E2-4D42-A31B-4C7EE08653F9}{86}{B0}"><one:Position x="342.0" y="158.4283447265625" z="8"/><one:Size width="72.02835845947266" height="35.97165679931641"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="378.0002746582031" y="158.4002532958984"/><one:AnchorPoint x="414.0002746582031" y="176.4002532958984"/><one:AnchorPoint x="378.0002746582031" y="194.4002532958984"/><one:AnchorPoint x="342.0002746582031" y="176.4002532958984"/></one:ShapeInfo><one:Data>AKABHQPEAWQBECoAGh+LQnBFhHWAvtBF2QQDBkgQRTJGMgUCC2RkAxVGAEAKdTOH8wTHmCtk8nkc
28fkcbjcTh8Lg8DgUGhUOiUajUij0ik0mk0mk0ij0ijUaiUOhUGgUDg8Lh8Tjcbkcfkcnk4IX4fTPh
296tjjnppvtvweBweDweDwODvtvppnjjRRTUUXWXYLAYLBYLBYDBXWXUUTRRADFUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:26.000Z" objectID="{0EFE5C4F-A2E2-4D42-A31B-4C7EE08653F9}{49}{B0}"><one:Position x="39.00472640991211" y="171.7511749267578" z="3"/><one:Size width="150.0094451904297" height="0.0283584594726562"/><one:Data>ANECHQOWAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
30Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBRE//8D
31RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqrAVaH
326T7nL5jAU1mMwmM3mkzmcwmMBmcymaAzGZTVAIDM5lM00mqazRNZpM5lNUAgM0mc1mUAgMAgMzmk
33Bmcymc0msymc0mqZTNNJrMpmmk1mUzTSazKZplMwgv646/rjsAAAAAAAAAAAAAAFFEYBAAAACQQF
34FEYAgAAAEgARIIxv3sLIAtJLsNVUUmVe0nIKABEgwIKVWXFQ3QEDFUYAQAoAESDpJ4c/26CTQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:30.000Z" objectID="{0EFE5C4F-A2E2-4D42-A31B-4C7EE08653F9}{54}{B0}"><one:Position x="188.9858245849609" y="239.2440948486328" z="4"/><one:Size width="30.0472469329834" height="22.53543090820312"/><one:Data>AMkCHQJSPgFgKgAaH4tCcEWEdYC+0EXZBPqHlTLy7BdBuMAWv/Mr9HreuxOL5V6zSJyboSVat1ES
35v+xxzMwisUGJDrsTLO56oVjPVIrml8VPjwb4utLhmyLEZm2nDbJYSZodTkA0BmwQAwZIFEUjRiMF
36CAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqyATeH8RSniIrmcyJrNJrAIDNJ
37nAYBAYBAUzmkBmc0gMzmk1mUzmk1mUzTSazKZppNZlM0ymc0mqZTNNJqh/GDd4wOYDNJrAIDNJnA
38ZnNIDAIDAJrMkzmk1mUzmk1mUzTSaplM00mpMpmmk1JlM00mpMpmBRRGAUAAAAkEBRRGAAAAABIA
39ESAy8nFzf6jGRYlYDFxmSkMdCj9AI42C380HEDugAxVGAEAKABEgHo+nQKo71EA=</one:Data></one:InkDrawing></one:Page>
corpus/ink-tools/native-ui/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/ink-tools/native-ui/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/snowbound-ink/gate/native-src",
3 "expected_pages": 5,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": false,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
17 }
18}
corpus/ink-tools/native-ui/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/ink-tools/native-ui/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/ink-tools/native-ui/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 3748,
5 "sha256": "c8d045166bedd6a34bc320687164a6028da67a22e740f56866a7850148241997",
6 "mtime_ns": 1790730645582703153
7 },
8 {
9 "path": "ink.one",
10 "bytes": 75888,
11 "sha256": "6971b45218ee86de5ceece252ffa7fae5d6bff58182604e46f2d9e48818f3cde",
12 "mtime_ns": 1790730645582324906
13 }
14]
corpus/ink-tools/native-ui/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/media-edit/README.md+2-1
...@@ -8,7 +8,8 @@...@@ -8,7 +8,8 @@
8annotated a paragraph with the moment 500 ms. The recording attachment8annotated a paragraph with the moment 500 ms. The recording attachment
9(`page::Attachment::recording`) and the annotation9(`page::Attachment::recording`) and the annotation
10(`PageParagraph::media`) are read from the page and kept through the edit;10(`PageParagraph::media`) are read from the page and kept through the edit;
11a model that adds an annotation or drops the recording refuses to write.11a model that drops the recording refuses to write. `corpus/recording` covers
12writing recordings and links.
1213
13`cold/` is a fresh OneNote 2010 read: `MediaFile`, `MediaReference` and14`cold/` is a fresh OneNote 2010 read: `MediaFile`, `MediaReference` and
14`MediaIndex` name the same recording and moment as before the edit, next to15`MediaIndex` name the same recording and moment as before the edit, next to
corpus/recording/README.md created+65
...@@ -0,0 +1,65 @@
1# Recording audio
2
3## What OneNote 2010 does
4
5Observed in a lab clone on 2026-09-29, with a QEMU HD Audio device on a null
6audio backend (it records silence; the clone has no camera, so Record Video
7was not observed):
8
9- **Record Audio** (Insert, Recording) with the caret at the end of "Before
10 recording": after that paragraph OneNote adds the recording's file, an empty
11 paragraph, "Audio recording started: 5:18 PM Tuesday, September 29, 2026" in
12 the `cite` quick style (Calibri 9, #595959) and an empty paragraph. The
13 contextual Audio & Video tab shows Recording: Record Audio, Record Video,
14 Play, Pause, Stop, Rewind 10 Minutes, Rewind 10 Seconds, the position
15 ("00:08/00:08"), Fast Forward 10 Seconds, Fast Forward 10 Minutes, See
16 Playback and Audio & Video Settings (`native/read/onenote-recording-tab.png`).
17- **The file** is named after the page ("Recorded.wma", shown without its
18 extension) and is Windows Media Audio 9 Voice, 8 kHz mono: 24,827 bytes for
19 34 seconds. It is stored in the section's own file data store beside a
20 32-pixel icon, never in `_onefiles`. The attachment carries
21 AudioRecordingGuid (`0x1c001c97`), IRecordMedia 1 (`0x14001d24`) and
22 AudioRecordingDuration in milliseconds (`0x14001cfd`, 33,903); the page lists
23 the recording in AudioRecordingGuids (`0x1c001ca3`, COM's `MediaPlaylist`).
24- **Linked notes.** Every paragraph whose text changes while recording is
25 linked to that moment: the recording's GUID (`0x1c001c98`) and milliseconds
26 since the start (`0x14001c99`, COM's `MediaIndex`) on its text object. A
27 paragraph edited during recording is linked too ("Before recording edited",
28 20,131 ms); empty paragraphs are not. The line saying when and the file are
29 linked at 0 and 151 ms.
30- **Playback.** Hovering a linked paragraph, or selecting the recording, shows
31 a blue play button in the margin (`native/read/onenote-linked-notes.png`); a
32 click plays from the linked moment.
33- **Deleting** the recording removes AudioRecordingGuids from the page; the
34 notes keep their links (`native/deleted`).
35- **Attach File** of a .wav makes it a recording as well: identity,
36 IRecordMedia 1 and its length (1,000 ms), listed on the page
37 (`native/attached`). MS-ONE requires this of .wma, .mp3, .wav, .wmv, .avi and
38 .mpg files.
39
40## Rows
41
42`native/` is OneNote's recording (`notebook/`), its COM export
43(`read/page-recorded.xml`), and the section after OneNote deleted the
44recording (`deleted/`) or attached a .wav (`attached/`).
45
46`candidate/` is `a_recording_stores_its_file_line_and_linked_notes_for_onenote`
47in `crates/snowbound/src/recording.rs`: the editor records at the end of
48"Before recording", two notes are typed while it records, and Stop puts in
49two seconds of a tone as Snowbound stores recordings, 16 kHz IMA ADPCM WAV.
50`cold/` is a fresh OneNote 2010 read: the playlist, the file with its bytes,
51the line and both notes linked to their moments. Opened in a lab clone, the
52note's play button played the recording from there (`played-from-note.png`);
53`native/read/onenote-plays-ima-adpcm.png` is OneNote playing a 20-second one.
54Regenerate with `SNOWBOUND_RECORDING_EXPORT`, then `tools/native_runner.py DIR
55COLD --expected-pages 1 --collect-notebook --screenshots`.
56
57`edit/` is `onenote_s_recording_round_trips_through_deleting_restoring_and_linking`
58in `crates/onestore/tests/page_media.rs`: OneNote's recording deleted, put back
59as undo does, and a paragraph linked at 25 s, each as its own edit. `edit/cold`
60is a fresh OneNote 2010 read: the same playlist, links, WMA bytes and the new
61link. Regenerate with `ONESTORE_RECORDING_EDIT_EXPORT`, then
62`tools/native_runner.py DIR COLD --expected-pages 2 --collect-notebook
63--screenshots`.
64
65`tools/test_recording.py` checks the rows without a VM.
corpus/recording/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/recording/candidate/Open Notebook.onetoc2 differ
corpus/recording/candidate/Recorded.one created
Binary files /dev/null and b/corpus/recording/candidate/Recorded.one differ
corpus/recording/cold/commands.jsonl created+3
...@@ -0,0 +1,3 @@
1{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-AB2185D7 -ExpectedPages 1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
corpus/recording/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-ab2185d7", "hostname": "ONE-M6-AB2185D7"}
corpus/recording/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/recording/cold/notebook/Open Notebook.onetoc2 differ
corpus/recording/cold/notebook/Recorded.one created
Binary files /dev/null and b/corpus/recording/cold/notebook/Recorded.one differ
corpus/recording/cold/read/36b0ffb210410ba1e1afffb66c6817f76261c422c4a083ab7f721cb94b186087.attachment created
Binary files /dev/null and b/corpus/recording/cold/read/36b0ffb210410ba1e1afffb66c6817f76261c422c4a083ab7f721cb94b186087.attachment differ
corpus/recording/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-AB2185D7",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/recording/cold/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{AB730F2F-036C-4D72-A711-326F7C10168F}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T01:22:24.000Z" color="#B49EDE"><one:Section name="Recorded" ID="{EED087BE-07B2-05B4-3848-B86669CDFDC1}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Recorded.one" lastModifiedTime="2026-09-30T01:22:24.000Z" color="#8AA8E4"><one:Page ID="{8456898C-BAE3-030C-1BB1-61E30651A3D9}{1}{B0}" name="Before recording" dateTime="2026-09-30T01:19:00.000Z" lastModifiedTime="2026-09-30T01:22:24.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/recording/cold/read/page-000.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Before recording - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/recording/cold/read/page-000.png created
Binary files /dev/null and b/corpus/recording/cold/read/page-000.png differ
corpus/recording/cold/read/page-000.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{8456898C-BAE3-030C-1BB1-61E30651A3D9}{1}{B0}" name="Before recording" dateTime="2026-09-30T01:19:00.000Z" lastModifiedTime="2026-09-30T01:22:24.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="cite" fontColor="#595959" highlightColor="automatic" font="Calibri" fontSize="9.0" spaceBefore="0.0" spaceAfter="0.0"/><one:MediaPlaylist><one:MediaReference mediaID="{15A71FFE-6FF6-4E3E-95A8-88B32D892606}"/></one:MediaPlaylist><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" lastModifiedTime="2026-09-30T01:19:00.000Z" objectID="{EED087BE-07B2-05B4-3848-B86669CDFDC1}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="272.0706176757812" height="127.6971435546875"/><one:OEChildren><one:OE creationTime="2026-09-30T01:19:00.000Z" lastModifiedTime="2026-09-30T01:19:00.000Z" objectID="{EED087BE-07B2-05B4-3848-B86669CDFDC1}{24}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[Before recording]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:24.000Z" lastModifiedTime="2025-08-18T14:13:24.000Z" objectID="{9ABDFC70-D187-0E34-1234-8F9E8F02FD0F}{11}{B0}" alignment="left"><one:MediaIndex timeIndex="0"><one:MediaReference mediaID="{15A71FFE-6FF6-4E3E-95A8-88B32D892606}"/></one:MediaIndex><one:MediaFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\5154cbf2-8ca1-45d6-a9ab-df456b942fcb.wav" preferredName="Recorded.wav"><one:MediaReference mediaID="{15A71FFE-6FF6-4E3E-95A8-88B32D892606}"/></one:MediaFile></one:OE><one:OE creationTime="2025-08-18T14:13:21.000Z" lastModifiedTime="2025-08-18T14:13:21.000Z" objectID="{9ABDFC70-D187-0E34-1234-8F9E8F02FD0F}{1}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:21.000Z" lastModifiedTime="2025-08-18T14:13:21.000Z" objectID="{9ABDFC70-D187-0E34-1234-8F9E8F02FD0F}{3}{B0}" alignment="left" quickStyleIndex="0"><one:MediaIndex timeIndex="0"><one:MediaReference mediaID="{15A71FFE-6FF6-4E3E-95A8-88B32D892606}"/></one:MediaIndex><one:T><![CDATA[Audio recording started: 5:18 PM Tuesday, September 29, 2026]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:21.000Z" lastModifiedTime="2025-08-18T14:13:22.000Z" objectID="{9ABDFC70-D187-0E34-1234-8F9E8F02FD0F}{5}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:MediaIndex timeIndex="50"><one:MediaReference mediaID="{15A71FFE-6FF6-4E3E-95A8-88B32D892606}"/></one:MediaIndex><one:T><![CDATA[First linked note]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:23.000Z" lastModifiedTime="2025-08-18T14:13:23.000Z" objectID="{9ABDFC70-D187-0E34-1234-8F9E8F02FD0F}{8}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:MediaIndex timeIndex="91"><one:MediaReference mediaID="{15A71FFE-6FF6-4E3E-95A8-88B32D892606}"/></one:MediaIndex><one:T><![CDATA[Second linked note]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/recording/cold/read/payloads.json created+10
...@@ -0,0 +1,10 @@
1[
2 {
3 "sha256": "36b0ffb210410ba1e1afffb66c6817f76261c422c4a083ab7f721cb94b186087",
4 "name": "Recorded.wav",
5 "object": "{9ABDFC70-D187-0E34-1234-8F9E8F02FD0F}{11}{B0}",
6 "kind": "MediaFile",
7 "page": "{8456898C-BAE3-030C-1BB1-61E30651A3D9}{1}{B0}",
8 "bytes": 16444
9 }
10]
\ No newline at end of file
corpus/recording/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/snowbound-recording/candidate",
3 "expected_pages": 1,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
17 }
18}
corpus/recording/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/recording/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/recording/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "367002b3fa8652d57b33524b212bdd1c9e2db92c05a3dcd0b1b81c42a9f5b571",
6 "mtime_ns": 1790731147609636401
7 },
8 {
9 "path": "Recorded.one",
10 "bytes": 26848,
11 "sha256": "34339ca682fb0c5686ff03e8e181664d3d776f42ba6cb6c7e43a651bf7dbbca2",
12 "mtime_ns": 1790731147609438776
13 }
14]
corpus/recording/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/recording/edit/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/recording/edit/candidate/Open Notebook.onetoc2 differ
corpus/recording/edit/candidate/files.one created
Binary files /dev/null and b/corpus/recording/edit/candidate/files.one differ
corpus/recording/edit/cold/commands.jsonl created+3
...@@ -0,0 +1,3 @@
1{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-C1DF0A20 -ExpectedPages 2 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 2 pages.\r\n", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
corpus/recording/edit/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-c1df0a20", "hostname": "ONE-M6-C1DF0A20"}
corpus/recording/edit/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/recording/edit/cold/notebook/Open Notebook.onetoc2 differ
corpus/recording/edit/cold/notebook/files.one created
Binary files /dev/null and b/corpus/recording/edit/cold/notebook/files.one differ
corpus/recording/edit/cold/read/b20a14e044e33eac39b4665e7d79813b8a3672dbbd27ed9d2e4ac866c13ec4b6.attachment created+1
...@@ -0,0 +1 @@
1Rust wrote this attachment.
corpus/recording/edit/cold/read/c41bd6bd61277bdc4bf3557b5572be5c08451ba5b4665aba662ac1280d907ca3.attachment created
Binary files /dev/null and b/corpus/recording/edit/cold/read/c41bd6bd61277bdc4bf3557b5572be5c08451ba5b4665aba662ac1280d907ca3.attachment differ
corpus/recording/edit/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-C1DF0A20",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/recording/edit/cold/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{70C86429-74AC-49DA-89F8-36EEEBE00B05}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T01:03:32.000Z" color="#B49EDE"><one:Section name="files" ID="{2F599349-FBBE-05E3-0652-268C3758DCC9}{1}{B0}" path="C:\one-tests\runs\capture\notebook\files.one" lastModifiedTime="2026-09-30T01:03:32.000Z" color="#8AA8E4"><one:Page ID="{D383DAE3-517B-02EB-0821-2990092E3DE9}{1}{B0}" name="Before the file" dateTime="2026-09-12T20:54:55.000Z" lastModifiedTime="2026-09-30T01:03:32.000Z" pageLevel="1"/><one:Page ID="{0D60127D-BED1-425E-8EA7-E0DE4C091A0E}{1}{B0}" name="Recorded" dateTime="2026-09-30T00:18:10.000Z" lastModifiedTime="2026-09-30T00:18:50.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/recording/edit/cold/read/page-000.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Recorded - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/recording/edit/cold/read/page-000.png created
Binary files /dev/null and b/corpus/recording/edit/cold/read/page-000.png differ
corpus/recording/edit/cold/read/page-000.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{0D60127D-BED1-425E-8EA7-E0DE4C091A0E}{1}{B0}" name="Recorded" dateTime="2026-09-30T00:18:10.000Z" lastModifiedTime="2026-09-30T00:18:50.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="1" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="2" name="cite" fontColor="#595959" highlightColor="automatic" font="Calibri" fontSize="9.0" spaceBefore="0.0" spaceAfter="0.0"/><one:MediaPlaylist><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaPlaylist><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T00:18:12.000Z" lastModifiedTime="2026-09-30T00:18:12.000Z" objectID="{831B7C66-D79E-4364-BC87-04D01F7A5187}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Recorded]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T01:01:01.000Z" objectID="{831B7C66-D79E-4364-BC87-04D01F7A5187}{28}{B0}"><one:Position x="36.0" y="86.4000015258789" z="0"/><one:Size width="272.0706176757812" height="154.5525970458984"/><one:OEChildren><one:OE creationTime="2026-09-30T00:18:40.000Z" lastModifiedTime="2026-09-30T00:18:40.000Z" objectID="{831B7C66-D79E-4364-BC87-04D01F7A5187}{29}{B0}" alignment="left" quickStyleIndex="1"><one:MediaIndex timeIndex="20131"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaIndex><one:T><![CDATA[Before recording edited]]></one:T></one:OE><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-09-30T01:01:01.000Z" lastModifiedTime="2026-09-30T01:01:01.000Z" objectID="{831B7C66-D79E-4364-BC87-04D01F7A5187}{46}{B0}" alignment="left"><one:MediaIndex timeIndex="151"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaIndex><one:MediaFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\7350cade-df91-45d9-8aed-577a41ffef0b.wma" pathSource="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\Audio Cache\Recorded.wma" preferredName="Recorded.wma"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaFile></one:OE><one:OE creationTime="2026-09-30T00:18:20.000Z" lastModifiedTime="2026-09-30T00:18:20.000Z" objectID="{831B7C66-D79E-4364-BC87-04D01F7A5187}{34}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[]]></one:T></one:OE><one:OE creationTime="2026-09-30T00:18:20.000Z" lastModifiedTime="2026-09-30T00:18:20.000Z" objectID="{831B7C66-D79E-4364-BC87-04D01F7A5187}{36}{B0}" alignment="left" quickStyleIndex="2"><one:MediaIndex timeIndex="0"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaIndex><one:T><![CDATA[Audio recording started: 5:18 PM Tuesday, September 29, 2026]]></one:T></one:OE><one:OE creationTime="2026-09-30T00:18:20.000Z" lastModifiedTime="2026-09-30T00:18:20.000Z" objectID="{831B7C66-D79E-4364-BC87-04D01F7A5187}{39}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[]]></one:T></one:OE><one:OE creationTime="2026-09-30T00:18:35.000Z" lastModifiedTime="2026-09-30T00:18:35.000Z" objectID="{831B7C66-D79E-4364-BC87-04D01F7A5187}{41}{B0}" alignment="left" quickStyleIndex="1"><one:MediaIndex timeIndex="15123"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaIndex><one:T><![CDATA[First linked note]]></one:T></one:OE><one:OE creationTime="2026-09-30T00:18:38.000Z" lastModifiedTime="2026-09-30T00:18:38.000Z" objectID="{831B7C66-D79E-4364-BC87-04D01F7A5187}{47}{B0}" alignment="left" quickStyleIndex="1"><one:MediaIndex timeIndex="18134"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaIndex><one:T><![CDATA[Second linked note]]></one:T></one:OE><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-09-30T01:01:01.000Z" lastModifiedTime="2026-09-30T01:01:01.000Z" objectID="{9427AC27-0484-0E62-2B01-1A318C39298F}{1}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:MediaIndex timeIndex="25000"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaIndex><one:T><![CDATA[Linked by Snowbound]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/recording/edit/cold/read/page-001.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Before the file - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/recording/edit/cold/read/page-001.png created
Binary files /dev/null and b/corpus/recording/edit/cold/read/page-001.png differ
corpus/recording/edit/cold/read/page-001.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{D383DAE3-517B-02EB-0821-2990092E3DE9}{1}{B0}" name="Before the file" dateTime="2026-09-12T20:54:55.000Z" lastModifiedTime="2026-09-30T01:03:32.000Z" pageLevel="1" lang="en-US"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-12T20:54:55.000Z" objectID="{2F599349-FBBE-05E3-0652-268C3758DCC9}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="130.4619598388672" height="89.85542297363281"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T20:54:55.000Z" lastModifiedTime="2026-09-12T20:54:55.000Z" objectID="{2F599349-FBBE-05E3-0652-268C3758DCC9}{24}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[Before the file]]></one:T></one:OE><one:OE author="Attachment author" lastModifiedBy="Attachment author" creationTime="2026-09-12T20:54:55.000Z" lastModifiedTime="2026-09-12T20:54:55.000Z" objectID="{DC10E2AC-49AB-0DF4-26AE-2E8215DF4D70}{0}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\6b5d60fe-566b-4a4b-aef1-fd37d628779c.txt" preferredName="notes 🦀.txt"/></one:OE><one:OE author="Attachment author" lastModifiedBy="Attachment author" creationTime="2026-09-12T20:54:55.000Z" lastModifiedTime="2026-09-12T20:54:55.000Z" objectID="{6F57B715-DDC6-02FB-3925-4B11BC745096}{0}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[After the file]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/recording/edit/cold/read/payloads.json created+18
...@@ -0,0 +1,18 @@
1[
2 {
3 "sha256": "c41bd6bd61277bdc4bf3557b5572be5c08451ba5b4665aba662ac1280d907ca3",
4 "name": "Recorded.wma",
5 "object": "{831B7C66-D79E-4364-BC87-04D01F7A5187}{46}{B0}",
6 "kind": "MediaFile",
7 "page": "{0D60127D-BED1-425E-8EA7-E0DE4C091A0E}{1}{B0}",
8 "bytes": 24827
9 },
10 {
11 "sha256": "b20a14e044e33eac39b4665e7d79813b8a3672dbbd27ed9d2e4ac866c13ec4b6",
12 "name": "notes 🦀.txt",
13 "object": "{DC10E2AC-49AB-0DF4-26AE-2E8215DF4D70}{0}{B0}",
14 "kind": "InsertedFile",
15 "page": "{D383DAE3-517B-02EB-0821-2990092E3DE9}{1}{B0}",
16 "bytes": 28
17 }
18]
\ No newline at end of file
corpus/recording/edit/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/snowbound-recording/edit-candidate",
3 "expected_pages": 2,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
17 }
18}
corpus/recording/edit/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/recording/edit/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/recording/edit/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 3764,
5 "sha256": "b47a0549b7401a416419d94f174f159ed257a8248ca2a60b9b7859f19637212d",
6 "mtime_ns": 1790730061936055248
7 },
8 {
9 "path": "files.one",
10 "bytes": 82304,
11 "sha256": "fcf457cbb8c5bba3ee565908b52a2abb8b1da20ce9f34dffb42d83f558104b10",
12 "mtime_ns": 1790730061935940415
13 }
14]
corpus/recording/edit/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/recording/native/attached/files.one created
Binary files /dev/null and b/corpus/recording/native/attached/files.one differ
corpus/recording/native/deleted/files.one created
Binary files /dev/null and b/corpus/recording/native/deleted/files.one differ
corpus/recording/native/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/recording/native/notebook/Open Notebook.onetoc2 differ
corpus/recording/native/notebook/files.one created
Binary files /dev/null and b/corpus/recording/native/notebook/files.one differ
corpus/recording/native/read/onenote-linked-notes.png created
Binary files /dev/null and b/corpus/recording/native/read/onenote-linked-notes.png differ
corpus/recording/native/read/onenote-plays-ima-adpcm.png created
Binary files /dev/null and b/corpus/recording/native/read/onenote-plays-ima-adpcm.png differ
corpus/recording/native/read/onenote-recording-tab.png created
Binary files /dev/null and b/corpus/recording/native/read/onenote-recording-tab.png differ
corpus/recording/native/read/page-recorded.xml created+3
...@@ -0,0 +1,3 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{D2B5565B-CE76-4195-A48D-FD09668AD090}{1}{B0}" name="Recorded" dateTime="2026-09-30T00:18:10.000Z" lastModifiedTime="2026-09-30T00:18:50.000Z" pageLevel="1" isCurrentlyViewed="true" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="1" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="2" name="cite" fontColor="#595959" highlightColor="automatic" font="Calibri" fontSize="9.0" spaceBefore="0.0" spaceAfter="0.0"/><one:MediaPlaylist><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaPlaylist><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T00:18:12.000Z" lastModifiedTime="2026-09-30T00:18:12.000Z" objectID="{5CCE3840-A739-40AF-96AD-190735F99B19}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Recorded]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T00:18:40.000Z" objectID="{5CCE3840-A739-40AF-96AD-190735F99B19}{28}{B0}"><one:Position x="36.0" y="86.4000015258789" z="0"/><one:Size width="272.0706176757812" height="141.1248626708984"/><one:OEChildren><one:OE creationTime="2026-09-30T00:18:40.000Z" lastModifiedTime="2026-09-30T00:18:40.000Z" objectID="{5CCE3840-A739-40AF-96AD-190735F99B19}{29}{B0}" alignment="left" quickStyleIndex="1"><one:MediaIndex timeIndex="20131"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaIndex><one:T><![CDATA[Before recording edited]]></one:T></one:OE><one:OE creationTime="2026-09-30T00:18:20.000Z" lastModifiedTime="2026-09-30T00:18:20.000Z" objectID="{5CCE3840-A739-40AF-96AD-190735F99B19}{46}{B0}" alignment="left"><one:MediaIndex timeIndex="151"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaIndex><one:MediaFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\16475b97-e54f-4f30-a381-c4fb8f2e074c.wma" pathSource="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\Audio Cache\Recorded.wma" preferredName="Recorded.wma"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaFile></one:OE><one:OE creationTime="2026-09-30T00:18:20.000Z" lastModifiedTime="2026-09-30T00:18:20.000Z" objectID="{5CCE3840-A739-40AF-96AD-190735F99B19}{34}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[]]></one:T></one:OE><one:OE creationTime="2026-09-30T00:18:20.000Z" lastModifiedTime="2026-09-30T00:18:20.000Z" objectID="{5CCE3840-A739-40AF-96AD-190735F99B19}{36}{B0}" alignment="left" quickStyleIndex="2"><one:MediaIndex timeIndex="0"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaIndex><one:T><![CDATA[Audio recording started: 5:18 PM Tuesday, September 29, 2026]]></one:T></one:OE><one:OE creationTime="2026-09-30T00:18:20.000Z" lastModifiedTime="2026-09-30T00:18:20.000Z" objectID="{5CCE3840-A739-40AF-96AD-190735F99B19}{39}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[]]></one:T></one:OE><one:OE creationTime="2026-09-30T00:18:35.000Z" lastModifiedTime="2026-09-30T00:18:35.000Z" objectID="{5CCE3840-A739-40AF-96AD-190735F99B19}{41}{B0}" alignment="left" quickStyleIndex="1"><one:MediaIndex timeIndex="15123"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaIndex><one:T><![CDATA[First linked note]]></one:T></one:OE><one:OE creationTime="2026-09-30T00:18:38.000Z" lastModifiedTime="2026-09-30T00:18:38.000Z" objectID="{5CCE3840-A739-40AF-96AD-190735F99B19}{47}{B0}" alignment="left" quickStyleIndex="1"><one:MediaIndex timeIndex="18134"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaIndex><one:T><![CDATA[Second linked note]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
3
corpus/recording/played-from-note.png created
Binary files /dev/null and b/corpus/recording/played-from-note.png differ
crates/canvas/src/editor.rs+47-3
...@@ -28,14 +28,17 @@ mod equation;...@@ -28,14 +28,17 @@ mod equation;
28#[cfg(test)]28#[cfg(test)]
29mod evidence;29mod evidence;
30mod format;30mod format;
31mod ink;
31mod link;32mod link;
32mod ops;33mod ops;
33pub(crate) mod page;34pub(crate) mod page;
35mod recording;
34mod space;36mod space;
35mod table;37mod table;
36pub use format::{38pub use format::{
37 Alignment, BULLET_LIBRARY, FormatState, Formatting, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle,39 Alignment, BULLET_LIBRARY, FormatState, Formatting, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle,
38};40};
41pub use ink::{FAVORITES, Pen};
39pub use link::{Link, shown_urls};42pub use link::{Link, shown_urls};
40pub use page::ReadOnlyObject;43pub use page::ReadOnlyObject;
4144
...@@ -120,6 +123,9 @@ pub struct CanvasEditor {...@@ -120,6 +123,9 @@ pub struct CanvasEditor {
120 /// The outline, caret and undo depth a run of typing left, which typing there joins as123 /// The outline, caret and undo depth a run of typing left, which typing there joins as
121 /// OneNote 2010 does until the caret moves or another kind of edit comes between.124 /// OneNote 2010 does until the caret moves or another kind of edit comes between.
122 typing: Option<(ExGuid, TextPosition, usize)>,125 typing: Option<(ExGuid, TextPosition, usize)>,
126 recording: Option<recording::Live>,
127 /// Drawings being dragged and how far, shown there until dropped.
128 ink_drag: Option<(Vec<ExGuid>, [f32; 2])>,
123}129}
124130
125/// Imported page state the editable content does not carry.131/// Imported page state the editable content does not carry.
...@@ -813,6 +819,17 @@ enum History {...@@ -813,6 +819,17 @@ enum History {
813 index: usize,819 index: usize,
814 file: Option<Box<page::Content>>,820 file: Option<Box<page::Content>>,
815 },821 },
822 /// Restores the drawing on the page at `index` in paint order, or removes the one there
823 /// when `None`.
824 Ink {
825 index: usize,
826 ink: Option<Box<onestore::page::Ink>>,
827 },
828 /// Gives a drawing back the strokes it had.
829 Strokes {
830 ink: ExGuid,
831 strokes: Vec<onestore::page::InkStroke>,
832 },
816 Remove {833 Remove {
817 outline: onestore::ExGuid,834 outline: onestore::ExGuid,
818 focus: RestoreFocus,835 focus: RestoreFocus,
...@@ -870,6 +887,8 @@ impl CanvasEditor {...@@ -870,6 +887,8 @@ impl CanvasEditor {
870 stored: None,887 stored: None,
871 whole: None,888 whole: None,
872 typing: None,889 typing: None,
890 recording: None,
891 ink_drag: None,
873 })892 })
874 }893 }
875894
...@@ -1058,9 +1077,11 @@ impl CanvasEditor {...@@ -1058,9 +1077,11 @@ impl CanvasEditor {
1058 let reaches_one = |history: &History| match history {1077 let reaches_one = |history: &History| match history {
1059 History::Date(_) => date_changed,1078 History::Date(_) => date_changed,
1060 History::Paper { .. } => false,1079 History::Paper { .. } => false,
1061 History::Image { .. } | History::Picture { .. } | History::File { .. } => {1080 History::Image { .. }
1062 objects_changed1081 | History::Picture { .. }
1063 }1082 | History::File { .. }
1083 | History::Ink { .. }
1084 | History::Strokes { .. } => objects_changed,
1064 History::Draft { outlines, .. } => changed.contains(&outlines[0].id),1085 History::Draft { outlines, .. } => changed.contains(&outlines[0].id),
1065 History::Text { outline, change } => {1086 History::Text { outline, change } => {
1066 changed.contains(outline)1087 changed.contains(outline)
...@@ -1446,6 +1467,8 @@ impl CanvasEditor {...@@ -1446,6 +1467,8 @@ impl CanvasEditor {
1446 stored: None,1467 stored: None,
1447 whole: None,1468 whole: None,
1448 typing: None,1469 typing: None,
1470 recording: None,
1471 ink_drag: None,
1449 })1472 })
1450 }1473 }
14511474
...@@ -3683,6 +3706,14 @@ impl CanvasEditor {...@@ -3683,6 +3706,14 @@ impl CanvasEditor {
3683 .get(*index)3706 .get(*index)
3684 .and_then(page::Content::file)3707 .and_then(page::Content::file)
3685 .is_some(),3708 .is_some(),
3709 History::Ink {
3710 index,
3711 ink: Some(_),
3712 } => *index <= self.objects.len(),
3713 History::Ink { index, ink: None } => {
3714 matches!(self.objects.get(*index), Some(page::Content::Ink(_)))
3715 }
3716 History::Strokes { ink, .. } => self.has_ink(*ink),
3686 History::Remove { outline, focus } => {3717 History::Remove { outline, focus } => {
3687 self.outlines.iter().any(|item| item.id == *outline)3718 self.outlines.iter().any(|item| item.id == *outline)
3688 && match focus {3719 && match focus {
...@@ -3917,6 +3948,17 @@ impl CanvasEditor {...@@ -3917,6 +3948,17 @@ impl CanvasEditor {
3917 None => Some(Box::new(self.remove_file(index))),3948 None => Some(Box::new(self.remove_file(index))),
3918 },3949 },
3919 },3950 },
3951 History::Ink { index, ink } => History::Ink {
3952 index,
3953 ink: match ink {
3954 Some(ink) => {
3955 self.add_ink(index, *ink);
3956 None
3957 }
3958 None => Some(Box::new(self.remove_ink(index))),
3959 },
3960 },
3961 History::Strokes { ink, strokes } => self.set_strokes(ink, strokes),
3920 History::Remove { outline, focus } => {3962 History::Remove { outline, focus } => {
3921 let index = self3963 let index = self
3922 .outlines3964 .outlines
...@@ -4065,6 +4107,7 @@ impl CanvasEditor {...@@ -4065,6 +4107,7 @@ impl CanvasEditor {
4065 .document4107 .document
4066 .replace(range.clone(), replacement)?;4108 .replace(range.clone(), replacement)?;
4067 self.own_lists(&mut edit)?;4109 self.own_lists(&mut edit)?;
4110 self.link_notes(&mut edit)?;
4068 let inverse = self.apply(4111 let inverse = self.apply(
4069 engine,4112 engine,
4070 edit,4113 edit,
...@@ -4163,6 +4206,7 @@ impl CanvasEditor {...@@ -4163,6 +4206,7 @@ impl CanvasEditor {
4163 selection: Selection,4206 selection: Selection,
4164 ) -> Result<(), EditorError> {4207 ) -> Result<(), EditorError> {
4165 self.own_lists(&mut edit)?;4208 self.own_lists(&mut edit)?;
4209 self.link_notes(&mut edit)?;
4166 let inverse = self.apply(engine, edit, selection, true, None)?;4210 let inverse = self.apply(engine, edit, selection, true, None)?;
4167 self.record_change(inverse);4211 self.record_change(inverse);
4168 Ok(())4212 Ok(())
crates/canvas/src/editor/ink.rs created+450
...@@ -0,0 +1,450 @@
1//! Drawing on the page as OneNote 2010's Draw tab does (`corpus/ink-tools`): each stroke and
2//! shape is a drawing of its own, the stroke eraser takes whole strokes, and the lasso picks
3//! drawings to move or delete.
4
5use super::{CanvasEditor, EditError, EditorError, History, Placement, page};
6use onestore::{
7 ExGuid,
8 op::PageOp,
9 page::{Ink, InkStroke, PageObject, ink::snap, text::new_id},
10};
11
12/// A pen or highlighter, as OneNote's pen gallery and Color & Thickness choose one.
13#[derive(Clone, Copy, Debug, PartialEq)]
14pub struct Pen {
15 /// Tip width in HIMETRIC (hundredths of a millimetre); a highlighter's tip is as tall as `HIGHLIGHTER_HEIGHT`.
16 pub width: f32,
17 /// COLORREF; `None` draws in the paper's ink, as OneNote's black pens store it.
18 pub color: Option<u32>,
19 pub highlighter: bool,
20}
21
22/// A highlighter tip's height in HIMETRIC.
23const HIGHLIGHTER_HEIGHT: f32 = 400.0;
24
25impl Pen {
26 pub const fn new(width: f32, color: Option<u32>) -> Self {
27 Self {
28 width,
29 color,
30 highlighter: false,
31 }
32 }
33
34 pub const fn highlighter(color: u32) -> Self {
35 Self {
36 width: 70.0,
37 color: Some(color),
38 highlighter: true,
39 }
40 }
41
42 /// A stroke of this pen through `points`, snapped to the HIMETRIC grid stored.
43 pub fn stroke(&self, points: &[[f32; 2]]) -> Result<InkStroke, EditError> {
44 let points_of = |himetric: f32| snap(himetric * 72.0 / 2540.0);
45 Ok(InkStroke {
46 id: new_id()?,
47 points: points.iter().map(|point| point.map(snap)).collect(),
48 width: points_of(self.width),
49 height: points_of(if self.highlighter {
50 HIGHLIGHTER_HEIGHT
51 } else {
52 self.width
53 }),
54 color: self.color,
55 transparency: self.highlighter.then_some(127),
56 pen_tip: self.highlighter.then_some(1),
57 raster_operation: self.highlighter.then_some(9),
58 })
59 }
60}
61
62/// OneNote 2010's favourite pens, in its gallery's order.
63pub const FAVORITES: [Pen; 14] = {
64 const RED: u32 = 0x241ced;
65 const BLUE: u32 = 0xbb6531;
66 const GREEN: u32 = 0x367d17;
67 const GREY: u32 = 0x808080;
68 [
69 Pen::new(35.0, None),
70 Pen::new(35.0, Some(RED)),
71 Pen::new(35.0, Some(BLUE)),
72 Pen::new(35.0, Some(GREEN)),
73 Pen::new(35.0, Some(GREY)),
74 Pen::highlighter(0x00ffff),
75 Pen::highlighter(0xffff00),
76 Pen::new(50.0, None),
77 Pen::new(50.0, Some(RED)),
78 Pen::new(50.0, Some(BLUE)),
79 Pen::new(50.0, Some(GREEN)),
80 Pen::new(50.0, Some(GREY)),
81 Pen::highlighter(0x00ff00),
82 Pen::highlighter(0xff00ff),
83 ]
84};
85
86impl CanvasEditor {
87 /// Puts `ink`, a stroke or shape just drawn, on top of the page as one undo step.
88 pub fn draw(&mut self, ink: Ink) -> Result<(), EditorError> {
89 if ink.strokes.is_empty() {
90 return Err(EditError::InvalidRange.into());
91 }
92 self.finish_composition();
93 let index = self.objects.len();
94 self.add_ink(index, ink);
95 self.undo.push(History::Ink { index, ink: None });
96 self.redo.clear();
97 Ok(())
98 }
99
100 /// Erases what the eraser touches moving from `from` to `to`, `reach` page points either
101 /// side of its path: a whole drawing where it is a shape, a single stroke or a group, as
102 /// OneNote erases a shape at a touch, and otherwise the strokes touched. With `join` the
103 /// erasing joins the last undo step, which the same sweep of the eraser made. Returns
104 /// whether anything was erased.
105 pub fn erase(
106 &mut self,
107 from: [f32; 2],
108 to: [f32; 2],
109 reach: f32,
110 join: bool,
111 ) -> Result<bool, EditorError> {
112 let mut entries = Vec::new();
113 let mut index = self.objects.len();
114 while index > 0 {
115 index -= 1;
116 let page::Content::Ink(ink) = &self.objects[index] else {
117 continue;
118 };
119 let offset = page::ink_offset(ink);
120 let touched = |stroke: &InkStroke| {
121 let reach = reach + stroke.width.max(stroke.height) / 2.0;
122 let mut points = stroke
123 .points
124 .iter()
125 .map(|p| [p[0] + offset[0], p[1] + offset[1]]);
126 let Some(mut previous) = points.next() else {
127 return false;
128 };
129 if segments_distance([previous, previous], [from, to]) <= reach {
130 return true;
131 }
132 points.any(|point| {
133 let near = segments_distance([previous, point], [from, to]) <= reach;
134 previous = point;
135 near
136 })
137 };
138 let (kept, erased): (Vec<InkStroke>, Vec<InkStroke>) = ink
139 .strokes
140 .iter()
141 .cloned()
142 .partition(|stroke| !touched(stroke));
143 let grouped = ink
144 .groups
145 .iter()
146 .any(|group| group_touched(group, &touched));
147 if erased.is_empty() && !grouped {
148 continue;
149 }
150 if ink.shape.is_some() || !ink.groups.is_empty() || kept.is_empty() {
151 let removed = self.remove_ink(index);
152 entries.push(History::Ink {
153 index,
154 ink: Some(Box::new(removed)),
155 });
156 } else {
157 let id = ink.id;
158 entries.push(self.set_strokes(id, kept));
159 }
160 }
161 if entries.is_empty() {
162 return Ok(false);
163 }
164 self.finish_composition();
165 if join
166 && let Some(History::Group {
167 entries: earlier, ..
168 }) = self.undo.last_mut()
169 {
170 earlier.extend(entries);
171 } else {
172 self.undo.push(History::Group {
173 entries,
174 page: false,
175 });
176 }
177 self.redo.clear();
178 Ok(true)
179 }
180
181 /// The page's drawings, on top first, with most of their points inside `lasso`, a
182 /// closed path in page points.
183 pub fn ink_within(&self, lasso: &[[f32; 2]]) -> Vec<ExGuid> {
184 self.objects
185 .iter()
186 .rev()
187 .filter_map(|object| match object {
188 page::Content::Ink(ink) => {
189 let offset = page::ink_offset(ink);
190 let points = points(ink);
191 let inside = points
192 .iter()
193 .filter(|p| contains(lasso, [p[0] + offset[0], p[1] + offset[1]]))
194 .count();
195 (!points.is_empty() && inside * 2 > points.len()).then_some(ink.id)
196 }
197 _ => None,
198 })
199 .collect()
200 }
201
202 /// The drawing on top whose strokes pass within `reach` of `point`.
203 pub fn ink_at(&self, point: [f32; 2], reach: f32) -> Option<ExGuid> {
204 self.objects.iter().rev().find_map(|object| match object {
205 page::Content::Ink(ink) => {
206 let offset = page::ink_offset(ink);
207 let near = |stroke: &InkStroke| {
208 let reach = reach + stroke.width.max(stroke.height) / 2.0;
209 let mut points = stroke
210 .points
211 .iter()
212 .map(|p| [p[0] + offset[0], p[1] + offset[1]]);
213 let first = points.next();
214 first.is_some_and(|first| {
215 let mut previous = first;
216 segments_distance([first, first], [point, point]) <= reach
217 || points.any(|p| {
218 let near =
219 segments_distance([previous, p], [point, point]) <= reach;
220 previous = p;
221 near
222 })
223 })
224 };
225 (ink.strokes.iter().any(near) || ink.groups.iter().any(|g| group_touched(g, &near)))
226 .then_some(ink.id)
227 }
228 _ => None,
229 })
230 }
231
232 /// The painted extent of drawings `ids`, as `[x0, y0, x1, y1]` page points, while dragged
233 /// where they show.
234 pub fn ink_extent(&self, ids: &[ExGuid]) -> Option<[f32; 4]> {
235 self.objects
236 .iter()
237 .filter_map(|object| match object {
238 page::Content::Ink(ink) if ids.contains(&ink.id) => {
239 let [dx, dy] = self.ink_drag_offset(ink.id);
240 page::ink_bounds(ink)
241 .map(|[x0, y0, x1, y1]| [x0 + dx, y0 + dy, x1 + dx, y1 + dy])
242 }
243 _ => None,
244 })
245 .reduce(|a, b| {
246 [
247 a[0].min(b[0]),
248 a[1].min(b[1]),
249 a[2].max(b[2]),
250 a[3].max(b[3]),
251 ]
252 })
253 }
254
255 /// Shows drawings dragged `delta` from where they lie, without storing it, until the
256 /// drag ends with `move_ink` or `None`.
257 pub fn drag_ink(&mut self, drag: Option<(Vec<ExGuid>, [f32; 2])>) {
258 self.ink_drag = drag;
259 }
260
261 /// How far a drawing shows from where it lies, while dragged.
262 pub(crate) fn ink_drag_offset(&self, id: ExGuid) -> [f32; 2] {
263 match &self.ink_drag {
264 Some((ids, delta)) if ids.contains(&id) => *delta,
265 _ => [0.0; 2],
266 }
267 }
268
269 /// Moves drawings `ids` by `delta` as one undo step, as OneNote moves ink: by the
270 /// drawing's offset, its strokes as they were.
271 pub fn move_ink(&mut self, ids: &[ExGuid], delta: [f32; 2]) -> Result<(), EditorError> {
272 self.ink_drag = None;
273 if delta == [0.0; 2] {
274 return Ok(());
275 }
276 let mut entries = Vec::new();
277 for &id in ids {
278 let Some(layout) = self.ink_layout_mut(id) else {
279 continue;
280 };
281 let stored = [layout.x, layout.y];
282 // Undoing restores a position: a drawing never moved lies at no offset.
283 let position = stored.map(|v| Some(v.unwrap_or(0.0)));
284 [layout.x, layout.y] = [0, 1].map(|axis| Some(position[axis].unwrap() + delta[axis]));
285 self.record(self.placement_ops(&Placement {
286 id,
287 position: stored,
288 }));
289 entries.push(History::Position {
290 object: id,
291 position,
292 });
293 }
294 self.push_ink_step(entries);
295 Ok(())
296 }
297
298 /// Deletes drawings `ids` as one undo step.
299 pub fn delete_ink(&mut self, ids: &[ExGuid]) -> Result<(), EditorError> {
300 let mut entries = Vec::new();
301 let mut index = self.objects.len();
302 while index > 0 {
303 index -= 1;
304 if matches!(&self.objects[index], page::Content::Ink(ink) if ids.contains(&ink.id)) {
305 let ink = self.remove_ink(index);
306 entries.push(History::Ink {
307 index,
308 ink: Some(Box::new(ink)),
309 });
310 }
311 }
312 self.push_ink_step(entries);
313 Ok(())
314 }
315
316 fn push_ink_step(&mut self, entries: Vec<History>) {
317 if entries.is_empty() {
318 return;
319 }
320 self.finish_composition();
321 self.undo.push(History::Group {
322 entries,
323 page: false,
324 });
325 self.redo.clear();
326 }
327
328 fn ink_layout_mut(&mut self, id: ExGuid) -> Option<&mut onestore::document::Layout> {
329 self.objects.iter_mut().find_map(|object| match object {
330 page::Content::Ink(ink) if ink.id == id => Some(&mut ink.layout),
331 _ => None,
332 })
333 }
334
335 /// Puts a drawing on the page at `index` in paint order, recording its op.
336 pub(super) fn add_ink(&mut self, index: usize, ink: Ink) {
337 let object = PageObject::Ink(ink.clone());
338 self.objects.insert(index, page::Content::Ink(ink));
339 let before = self.successor(object.id());
340 self.record(Ok(vec![PageOp::Add { object, before }]));
341 }
342
343 /// Takes the drawing at `index` in paint order off the page, recording its op.
344 pub(super) fn remove_ink(&mut self, index: usize) -> Ink {
345 let page::Content::Ink(ink) = self.objects.remove(index) else {
346 unreachable!("the index holds a drawing")
347 };
348 self.record(Ok(vec![PageOp::Delete { object: ink.id }]));
349 ink
350 }
351
352 /// Gives drawing `id` `strokes`, recording the op that erases the strokes it loses and
353 /// adds those it gains; the entry restoring what it had.
354 pub(super) fn set_strokes(&mut self, id: ExGuid, strokes: Vec<InkStroke>) -> History {
355 let ink = self
356 .objects
357 .iter_mut()
358 .find_map(|object| match object {
359 page::Content::Ink(ink) if ink.id == id => Some(ink),
360 _ => None,
361 })
362 .expect("the drawing is on the page");
363 let known = |stroke: &InkStroke, list: &[InkStroke]| list.iter().any(|s| s.id == stroke.id);
364 let remove = ink
365 .strokes
366 .iter()
367 .filter(|stroke| !known(stroke, &strokes))
368 .map(|stroke| stroke.id)
369 .collect();
370 let (mut kept, add): (Vec<InkStroke>, Vec<InkStroke>) = strokes
371 .into_iter()
372 .partition(|stroke| known(stroke, &ink.strokes));
373 // Strokes a drawing gains follow those it keeps, as the stored list orders them.
374 kept.extend(add.iter().cloned());
375 let previous = std::mem::replace(&mut ink.strokes, kept);
376 self.record(Ok(vec![PageOp::Strokes {
377 ink: id,
378 add,
379 remove,
380 }]));
381 History::Strokes {
382 ink: id,
383 strokes: previous,
384 }
385 }
386
387 pub(super) fn has_ink(&self, id: ExGuid) -> bool {
388 self.objects
389 .iter()
390 .any(|object| matches!(object, page::Content::Ink(ink) if ink.id == id))
391 }
392}
393
394fn group_touched(ink: &Ink, touched: &impl Fn(&InkStroke) -> bool) -> bool {
395 ink.strokes.iter().any(touched) || ink.groups.iter().any(|group| group_touched(group, touched))
396}
397
398fn points(ink: &Ink) -> Vec<[f32; 2]> {
399 ink.strokes
400 .iter()
401 .flat_map(|stroke| stroke.points.iter().copied())
402 .chain(ink.groups.iter().flat_map(points))
403 .collect()
404}
405
406/// Whether `point` lies inside the closed path `polygon` (even-odd).
407fn contains(polygon: &[[f32; 2]], point: [f32; 2]) -> bool {
408 let mut inside = false;
409 let mut previous = match polygon.last() {
410 Some(last) => *last,
411 None => return false,
412 };
413 for &current in polygon {
414 if (current[1] > point[1]) != (previous[1] > point[1])
415 && point[0]
416 < (previous[0] - current[0]) * (point[1] - current[1]) / (previous[1] - current[1])
417 + current[0]
418 {
419 inside = !inside;
420 }
421 previous = current;
422 }
423 inside
424}
425
426/// The least distance between segments `a` and `b`.
427fn segments_distance(a: [[f32; 2]; 2], b: [[f32; 2]; 2]) -> f32 {
428 let cross = |o: [f32; 2], p: [f32; 2], q: [f32; 2]| {
429 (p[0] - o[0]) * (q[1] - o[1]) - (p[1] - o[1]) * (q[0] - o[0])
430 };
431 let [d1, d2] = [cross(b[0], b[1], a[0]), cross(b[0], b[1], a[1])];
432 let [d3, d4] = [cross(a[0], a[1], b[0]), cross(a[0], a[1], b[1])];
433 if d1 * d2 < 0.0 && d3 * d4 < 0.0 {
434 return 0.0;
435 }
436 let to_segment = |p: [f32; 2], [s, e]: [[f32; 2]; 2]| {
437 let [dx, dy] = [e[0] - s[0], e[1] - s[1]];
438 let length = dx * dx + dy * dy;
439 let t = if length > 0.0 {
440 (((p[0] - s[0]) * dx + (p[1] - s[1]) * dy) / length).clamp(0.0, 1.0)
441 } else {
442 0.0
443 };
444 (p[0] - s[0] - t * dx).hypot(p[1] - s[1] - t * dy)
445 };
446 to_segment(a[0], b)
447 .min(to_segment(a[1], b))
448 .min(to_segment(b[0], a))
449 .min(to_segment(b[1], a))
450}
crates/canvas/src/editor/recording.rs created+284
...@@ -0,0 +1,284 @@
1//! Record Audio as OneNote 2010 records (`corpus/recording`): starting splits the caret's
2//! paragraph around a line saying when, in the page's `cite` style and linked to the start;
3//! text written while recording links to the moment it was written; the finished file takes
4//! its place after the caret's paragraph.
5
6use super::*;
7use onestore::page::{Attachment, MediaIndex};
8use std::time::Instant;
9
10/// A recording in progress.
11pub(super) struct Live {
12 id: [u8; 16],
13 started: Instant,
14 /// The blank paragraph before the line saying when, which the file goes before.
15 place: ExGuid,
16}
17
18/// OneNote 2010's `cite` quick style: Calibri 9 in dark grey.
19fn cite() -> Definition {
20 Definition {
21 kind: Kind::Style {
22 name: Some("cite".into()),
23 },
24 format: Format {
25 bold: Some(false),
26 italic: Some(false),
27 underline: Some(false),
28 strike: Some(false),
29 superscript: Some(false),
30 subscript: Some(false),
31 font: Some("Calibri".into()),
32 font_size: Some(9.0),
33 color: Some(0x595959),
34 highlight: Some(0xff00_0000),
35 space_before: Some(0.0),
36 space_after: Some(0.0),
37 line_spacing: Some(0.0),
38 ..Format::default()
39 },
40 }
41}
42
43impl CanvasEditor {
44 /// The recording that text written now links to.
45 pub fn recording(&self) -> Option<[u8; 16]> {
46 self.recording.as_ref().map(|live| live.id)
47 }
48
49 /// Starts a recording at the caret, returning its identity: the caret's paragraph splits
50 /// there around a blank line, `label` (saying when, in the `cite` style, linked to the
51 /// start) and the blank paragraph the caret starts. An empty first half is the blank
52 /// line; a caret in the title records at the body's start.
53 pub fn start_recording(
54 &mut self,
55 engine: &mut TextEngine,
56 label: &str,
57 ) -> Result<[u8; 16], EditorError> {
58 if self.recording.is_some() {
59 return Err(EditError::InvalidRange.into());
60 }
61 let id = onestore::page::text::new_guid()?;
62 self.leave_title(engine)?;
63 let [anchor, focus] = self.selection().positions;
64 let (start, end) = (anchor.min(focus), anchor.max(focus));
65 let mut format = self.typing_format(start)?;
66 link::unlinked(&mut format);
67 let language = format.language;
68 let head_empty = start.offset == 0;
69 let parts = if head_empty { 3 } else { 4 };
70 let mut edit = self.active_outline().document.replace(
71 start..end,
72 vec![Paragraph::new(String::new(), format); parts],
73 )?;
74 let style = match self.definitions.iter().find(|(_, definition)| {
75 matches!(&definition.kind, Kind::Style { name: Some(name) } if name == "cite")
76 }) {
77 Some((style, _)) => *style,
78 None => {
79 let style = onestore::page::text::new_id()?;
80 self.definitions.insert(style, cite());
81 style
82 }
83 };
84 if head_empty {
85 // The caret's text, now the last part, keeps its tags and link, as Attach File's does.
86 let [head, .., tail] = &mut edit.replacement[..parts] else {
87 unreachable!("a split holds both halves")
88 };
89 tail.tags = std::mem::take(&mut head.tags);
90 tail.text_mut().unwrap().tags = std::mem::take(&mut head.text_mut().unwrap().tags);
91 tail.media = std::mem::take(&mut head.media);
92 }
93 let place = edit.replacement[parts - 3].id;
94 let line = &mut edit.replacement[parts - 2];
95 line.style = Some(style);
96 line.lists.clear();
97 line.media = MediaIndex {
98 recordings: vec![id],
99 time_ms: Some(0),
100 };
101 // The line's text reads back in its style's look.
102 let look = Format {
103 language,
104 ..self.definitions[&style].format.clone()
105 };
106 line.text_mut().unwrap().text = Paragraph::new(label.to_owned(), look);
107 let caret = TextPosition {
108 paragraph: start.paragraph + parts - 1,
109 offset: 0,
110 };
111 self.commit(
112 engine,
113 edit,
114 Selection {
115 positions: [caret; 2],
116 affinities: [Affinity::Upstream; 2],
117 },
118 )?;
119 self.recording = Some(Live {
120 id,
121 started: Instant::now(),
122 place,
123 });
124 Ok(id)
125 }
126
127 /// Stops recording: `file`, the recording made, goes where it started, linked to its
128 /// start, or at the caret when that place is gone.
129 pub fn finish_recording(
130 &mut self,
131 engine: &mut TextEngine,
132 mut file: Attachment,
133 ) -> Result<(), EditorError> {
134 let Some(live) = self.recording.take() else {
135 return Err(EditError::InvalidRange.into());
136 };
137 if file
138 .recording
139 .is_none_or(|recording| recording.id != live.id)
140 {
141 return Err(EditError::InvalidStructure.into());
142 }
143 let place = self.outlines.iter().find_map(|outline| {
144 descendants(outline.document.nodes(), None).find_map(|(container, index, node)| {
145 (node.id == live.place).then(|| (outline.id, container, index, node.clone()))
146 })
147 });
148 let Some((outline, container, index, place)) = place else {
149 return self.insert_attachment(engine, file);
150 };
151 file.layout = Default::default();
152 let node = PageParagraph {
153 id: onestore::page::text::new_id()?,
154 content: ParagraphContent::Attachment(file),
155 style: None,
156 lists: Vec::new(),
157 tags: Vec::new(),
158 collapsed: false,
159 media: MediaIndex {
160 recordings: vec![live.id],
161 time_ms: Some(0),
162 },
163 ..place
164 };
165 self.focus_outline(outline)?;
166 let selection = self.selection();
167 self.commit(
168 engine,
169 DocumentEdit {
170 columns: BTreeMap::new(),
171 container,
172 range: index..index,
173 replacement: vec![node],
174 },
175 selection,
176 )
177 }
178
179 /// Links text `edit` writes while recording to the moment it is written, as OneNote
180 /// links notes taken then; blank paragraphs and those already linked stay as they are.
181 pub(super) fn link_notes(&self, edit: &mut DocumentEdit) -> Result<(), EditError> {
182 let Some(live) = &self.recording else {
183 return Ok(());
184 };
185 let nodes = &self.active_outline().document.container(edit.container)?[edit.range.clone()];
186 let before: BTreeMap<ExGuid, &str> = descendants(nodes, None)
187 .filter_map(|(_, _, node)| Some((node.id, node.text()?.text.text())))
188 .collect();
189 let time = u32::try_from(live.started.elapsed().as_millis()).unwrap_or(u32::MAX);
190 let mut pending: Vec<&mut PageParagraph> = edit.replacement.iter_mut().collect();
191 while let Some(node) = pending.pop() {
192 let written = node.text().is_some_and(|text| {
193 !text.text.text().is_empty() && before.get(&node.id) != Some(&text.text.text())
194 });
195 if written && node.media == MediaIndex::default() {
196 node.media = MediaIndex {
197 recordings: vec![live.id],
198 time_ms: Some(time),
199 };
200 }
201 if let ParagraphContent::Table(table) = &mut node.content {
202 pending.extend(
203 table
204 .rows
205 .iter_mut()
206 .flat_map(|row| &mut row.cells)
207 .flat_map(|cell| &mut cell.paragraphs),
208 );
209 }
210 }
211 Ok(())
212 }
213}
214
215/// A play button's side, and how far its right edge stands before the text it plays from,
216/// in points, as OneNote 2010 draws it at 100%.
217const BUTTON: [f32; 2] = [10.5, 24.0];
218
219impl CanvasEditor {
220 /// The recording `id` names, in an outline or on the page.
221 pub fn recording_file(&self, id: [u8; 16]) -> Option<&Attachment> {
222 let named = |file: &&Attachment| file.recording.is_some_and(|r| r.id == id);
223 self.objects
224 .iter()
225 .filter_map(|object| object.file().map(|(file, _)| file))
226 .find(named)
227 .or_else(|| {
228 self.visible_outlines()
229 .flat_map(|outline| descendants(outline.document.nodes(), None))
230 .filter_map(|(_, _, node)| match &node.content {
231 ParagraphContent::Attachment(file) => Some(file),
232 _ => None,
233 })
234 .find(named)
235 })
236 }
237
238 /// The play button OneNote shows for document point `point`: beside the linked note or
239 /// the recording level with it, over its outline or in the margin before it. Gives the
240 /// button's square, the recording and the moment it plays from.
241 pub fn play_button(&self, point: [f32; 2]) -> Option<([f32; 4], [u8; 16], u32)> {
242 let [x, y] = point;
243 let outline = self.visible_outlines().find(|outline| {
244 let bounds = outline.bounds();
245 (bounds.x0 as f32 - BUTTON[1] - BUTTON[0]..=bounds.x1 as f32).contains(&x)
246 && (bounds.y0 as f32..=bounds.y1 as f32).contains(&y)
247 })?;
248 let [left, top] = outline.origin();
249 let square = |text_left: f32, line_top: f32, height: f32| {
250 let x1 = left + text_left - BUTTON[1];
251 let y0 = top + line_top + (height - BUTTON[0]) / 2.0;
252 [x1 - BUTTON[0], y0, x1, y0 + BUTTON[0]]
253 };
254 let shaped = outline.shaped();
255 let (rect, recording, time) = if let Some(paragraph) = shaped
256 .paragraphs
257 .iter()
258 .find(|p| (top + p.origin[1]..=top + p.origin[1] + p.text.height()).contains(&y))
259 {
260 let (_, _, node) = descendants(outline.document.nodes(), None)
261 .find(|(_, _, node)| node.id == paragraph.id)?;
262 let line = paragraph
263 .text
264 .lines()
265 .next()
266 .map_or(paragraph.text.height(), |(_, line)| line.height);
267 (
268 square(paragraph.origin[0], paragraph.origin[1], line),
269 *node.media.recordings.first()?,
270 node.media.time_ms?,
271 )
272 } else {
273 let object = shaped.objects.iter().find(|object| {
274 matches!(object.kind, crate::outline::ObjectKind::File(_))
275 && (top + object.rect[1]..=top + object.bottom).contains(&y)
276 })?;
277 let [x0, y0, _, y1] = object.rect;
278 let recording = self.attachment(object.id)?.recording?.id;
279 (square(x0, y0, y1 - y0), recording, 0)
280 };
281 self.recording_file(recording)?;
282 Some((rect, recording, time))
283 }
284}
crates/canvas/src/gpu/page.rs+68-29
...@@ -96,12 +96,46 @@ fn outline_origin(...@@ -96,12 +96,46 @@ fn outline_origin(
96 Ok(origin)96 Ok(origin)
97}97}
9898
99fn append_ink(ink: &onestore::page::Ink, offset: [f32; 2], primitives: &mut Vec<Primitive<'_>>) {99/// Draws `ink` moved by `offset`, strokes without a colour in the paper's `automatic` ink. A
100/// highlighter's rectangular tip sweeps a band as wide as the tip reaches across each
101/// segment, its colour multiplying what lies beneath as OneNote's MaskPen does.
102pub(crate) fn append_ink(
103 ink: &onestore::page::Ink,
104 offset: [f32; 2],
105 automatic: [f32; 4],
106 primitives: &mut Vec<Primitive<'_>>,
107) {
100 for stroke in &ink.strokes {108 for stroke in &ink.strokes {
101 let mut color = colorref(stroke.color.unwrap_or(0));109 let highlighter = stroke.raster_operation == Some(9);
102 color[3] = 1.0 - f32::from(stroke.transparency.unwrap_or(0)) / 255.0;110 let mut color = stroke.color.map_or(automatic, colorref);
103 let width = stroke.width.max(stroke.height);111 if !highlighter {
112 color[3] = 1.0 - f32::from(stroke.transparency.unwrap_or(0)) / 255.0;
113 }
104 let round = stroke.pen_tip != Some(1);114 let round = stroke.pen_tip != Some(1);
115 let segment = |from: [f32; 2], to: [f32; 2]| {
116 if !highlighter {
117 return Primitive::Segment {
118 from,
119 to,
120 width: stroke.width.max(stroke.height),
121 round,
122 color,
123 };
124 }
125 let [dx, dy] = [to[0] - from[0], to[1] - from[1]];
126 let length = dx.hypot(dy);
127 let [along, across] = if length > 0.0 {
128 [dx.abs() / length, dy.abs() / length]
129 } else {
130 [1.0, 0.0]
131 };
132 Primitive::Highlight {
133 from,
134 to,
135 width: stroke.width * across + stroke.height * along,
136 color,
137 }
138 };
105 let mut points = stroke139 let mut points = stroke
106 .points140 .points
107 .iter()141 .iter()
...@@ -109,37 +143,35 @@ fn append_ink(ink: &onestore::page::Ink, offset: [f32; 2], primitives: &mut Vec<...@@ -109,37 +143,35 @@ fn append_ink(ink: &onestore::page::Ink, offset: [f32; 2], primitives: &mut Vec<
109 let Some(mut from) = points.next() else {143 let Some(mut from) = points.next() else {
110 continue;144 continue;
111 };145 };
112 let mut drawn = false;146 // Samples along a straight run make one segment, since each joint paints its overlap
147 // twice.
148 let mut end = from;
113 for to in points {149 for to in points {
114 // Pen samples far closer than a pixel add vertices without changing the stroke.150 if !between(from, end, to) {
115 if (to[0] - from[0]).hypot(to[1] - from[1]) < 0.2 {151 primitives.push(segment(from, end));
116 continue;152 from = end;
117 }153 }
118 primitives.push(Primitive::Segment {154 end = to;
119 from,
120 to,
121 width,
122 round,
123 color,
124 });
125 from = to;
126 drawn = true;
127 }
128 if !drawn {
129 primitives.push(Primitive::Segment {
130 from,
131 to: from,
132 width,
133 round,
134 color,
135 });
136 }155 }
156 primitives.push(segment(from, end));
137 }157 }
138 for group in &ink.groups {158 for group in &ink.groups {
139 append_ink(group, offset, primitives);159 append_ink(group, offset, automatic, primitives);
140 }160 }
141}161}
142162
163/// Whether `middle` lies within a tenth of a point of the segment from `from` to `to`.
164fn between(from: [f32; 2], middle: [f32; 2], to: [f32; 2]) -> bool {
165 let [dx, dy] = [to[0] - from[0], to[1] - from[1]];
166 let [mx, my] = [middle[0] - from[0], middle[1] - from[1]];
167 let length = dx * dx + dy * dy;
168 if length == 0.0 {
169 return mx.hypot(my) <= 0.1;
170 }
171 let along = (mx * dx + my * dy) / length;
172 (0.0..=1.0).contains(&along) && (mx * dy - my * dx).abs() / length.sqrt() <= 0.1
173}
174
143const FILE_ICON: &str = include_str!("../../assets/file.svg");175const FILE_ICON: &str = include_str!("../../assets/file.svg");
144176
145/// The blank page for a file without an icon of its own, as the 32 pixel PNG OneNote 2010177/// The blank page for a file without an icon of its own, as the 32 pixel PNG OneNote 2010
...@@ -501,7 +533,9 @@ impl PageScene {...@@ -501,7 +533,9 @@ impl PageScene {
501 primitives.push(Primitive::Image { image, rect });533 primitives.push(Primitive::Image { image, rect });
502 }534 }
503 match &object.kind {535 match &object.kind {
504 crate::outline::ObjectKind::Ink(ink) => append_ink(ink, [rect[0], rect[1]], primitives),536 crate::outline::ObjectKind::Ink(ink) => {
537 append_ink(ink, [rect[0], rect[1]], paper.ink, primitives)
538 }
505 crate::outline::ObjectKind::Unsupported(_) => {539 crate::outline::ObjectKind::Unsupported(_) => {
506 append_placeholder(rect, paper, primitives)540 append_placeholder(rect, paper, primitives)
507 }541 }
...@@ -801,7 +835,9 @@ impl PageScene {...@@ -801,7 +835,9 @@ impl PageScene {
801 }835 }
802 Content::Ink(ink) => {836 Content::Ink(ink) => {
803 let [x, y] = crate::editor::page::ink_offset(ink);837 let [x, y] = crate::editor::page::ink_offset(ink);
804 append_ink(ink, [offset[0] + x, offset[1] + y], primitives);838 let [dx, dy] = editor.map_or([0.0; 2], |editor| editor.ink_drag_offset(ink.id));
839 let offset = [offset[0] + x + dx, offset[1] + y + dy];
840 append_ink(ink, offset, paper.ink, primitives);
805 continue;841 continue;
806 }842 }
807 Content::File { source, layout } => {843 Content::File { source, layout } => {
...@@ -2217,6 +2253,7 @@ mod tests {...@@ -2217,6 +2253,7 @@ mod tests {
2217 color,2253 color,
2218 transparency: Some(51),2254 transparency: Some(51),
2219 pen_tip: None,2255 pen_tip: None,
2256 raster_operation: None,
2220 };2257 };
2221 let ink = Ink {2258 let ink = Ink {
2222 id: ExGuid::default(),2259 id: ExGuid::default(),
...@@ -2228,7 +2265,9 @@ mod tests {...@@ -2228,7 +2265,9 @@ mod tests {
2228 layout: Layout::default(),2265 layout: Layout::default(),
2229 strokes: vec![stroke(vec![[40.0, 30.0]], Some(0x0000ff))],2266 strokes: vec![stroke(vec![[40.0, 30.0]], Some(0x0000ff))],
2230 groups: Vec::new(),2267 groups: Vec::new(),
2268 shape: None,
2231 }],2269 }],
2270 shape: None,
2232 };2271 };
2233 let page = Page {2272 let page = Page {
2234 identity: None,2273 identity: None,
crates/canvas/src/interaction/ink.rs created+326
...@@ -0,0 +1,326 @@
1//! The Draw tab's tools on the page: pens and highlighters drawing a stroke per press, shapes
2//! dragged out on the grid, the stroke eraser, and the lasso whose drawings move and delete,
3//! as OneNote 2010 has them (`corpus/ink-tools`).
4
5use super::{PageView, Response, Result, snap_to_grid};
6use crate::editor::Pen;
7use crate::gpu::colorref;
8use draw::{
9 Primitive, Stroke,
10 edit::{Key, NamedKey},
11};
12use onestore::{
13 ExGuid,
14 page::{Ink, InkStroke, ink::ShapeKind, text::new_id},
15};
16
17/// What a press on the page does.
18#[derive(Clone, Copy, Debug, Default, PartialEq)]
19pub enum Tool {
20 /// Select & Type: text, and drawings picked by a click.
21 #[default]
22 Select,
23 /// A stroke per press.
24 Pen(Pen),
25 /// A shape dragged out, its corners on the grid unless Option is held.
26 Shape(ShapeKind, Pen),
27 /// The stroke eraser.
28 Eraser,
29 Lasso,
30}
31
32#[derive(Default)]
33pub(super) struct State {
34 tool: Tool,
35 gesture: Option<Gesture>,
36 /// Drawings picked by the lasso or a click.
37 selection: Vec<ExGuid>,
38}
39
40enum Gesture {
41 Stroke(Vec<[f32; 2]>),
42 Shape([f32; 2]),
43 Erase { last: [f32; 2], erased: bool },
44 Lasso(Vec<[f32; 2]>),
45 Move { press: [f32; 2] },
46}
47
48/// The eraser's reach either side of its path, in logical pixels.
49const ERASER_REACH: f32 = 4.0;
50
51impl PageView {
52 pub fn tool(&self) -> Tool {
53 self.ink.tool
54 }
55
56 /// Picks what presses on the page do, as the Draw tab's tools do; the lasso's selection
57 /// goes.
58 pub fn set_tool(&mut self, tool: Tool) -> Response {
59 self.ink.tool = tool;
60 self.leave_ink();
61 Response::redraw()
62 }
63
64 /// Drawings the lasso or a click picked.
65 pub fn ink_selection(&self) -> &[ExGuid] {
66 &self.ink.selection
67 }
68
69 /// Ends a gesture unfinished, as a lost focus does.
70 pub(super) fn end_ink(&mut self) {
71 self.ink.gesture = None;
72 self.editor.drag_ink(None);
73 }
74
75 /// Another page shows: the gesture ends and nothing stays picked; the tool stays.
76 pub(super) fn leave_ink(&mut self) {
77 self.end_ink();
78 self.ink.selection.clear();
79 }
80
81 /// Forgets picked drawings the page no longer holds, after a change made elsewhere. A
82 /// stroke, shape or lasso under way goes on; a drag of drawings ends, and the eraser's
83 /// next touch starts an undo step of its own.
84 pub(super) fn refresh_ink(&mut self) {
85 match &mut self.ink.gesture {
86 Some(Gesture::Move { .. }) => self.ink.gesture = None,
87 Some(Gesture::Erase { erased, .. }) => *erased = false,
88 _ => {}
89 }
90 let editor = &self.editor;
91 self.ink
92 .selection
93 .retain(|id| editor.ink_extent(std::slice::from_ref(id)).is_some());
94 }
95
96 fn reach(&self) -> f32 {
97 ERASER_REACH * self.pixel()
98 }
99
100 /// A press at page point `point`, when a drawing tool takes it or it picks ink.
101 pub(super) fn ink_pressed(&mut self, point: [f32; 2]) -> Result<Option<Response>> {
102 let selected = !self.ink.selection.is_empty()
103 && self
104 .editor
105 .ink_extent(&self.ink.selection)
106 .is_some_and(|[x0, y0, x1, y1]| {
107 (x0..=x1).contains(&point[0]) && (y0..=y1).contains(&point[1])
108 });
109 let gesture = match self.ink.tool {
110 Tool::Select | Tool::Lasso if selected => Gesture::Move { press: point },
111 Tool::Select => {
112 self.ink.selection.clear();
113 if self.hit_test(point).is_some() {
114 return Ok(None);
115 }
116 let Some(id) = self.editor.ink_at(point, self.reach()) else {
117 return Ok(None);
118 };
119 self.set_object_focus(None);
120 self.ink.selection = vec![id];
121 Gesture::Move { press: point }
122 }
123 Tool::Pen(_) => Gesture::Stroke(vec![point]),
124 Tool::Shape(..) => Gesture::Shape(self.grid(point)),
125 Tool::Eraser => {
126 let erased = self.editor.erase(point, point, self.reach(), false)?;
127 Gesture::Erase {
128 last: point,
129 erased,
130 }
131 }
132 Tool::Lasso => {
133 self.ink.selection.clear();
134 Gesture::Lasso(vec![point])
135 }
136 };
137 self.drag = None;
138 self.ink.gesture = Some(gesture);
139 Ok(Some(self.changed()?))
140 }
141
142 fn grid(&self, point: [f32; 2]) -> [f32; 2] {
143 if self.modifiers.option {
144 point
145 } else {
146 snap_to_grid(point, self.editor.margin_origin())
147 }
148 }
149
150 /// The pointer moved to page point `point` during a gesture.
151 pub(super) fn ink_moved(&mut self, point: [f32; 2]) -> Result<Response> {
152 match &mut self.ink.gesture {
153 Some(Gesture::Stroke(points) | Gesture::Lasso(points)) => {
154 let last = points[points.len() - 1];
155 // Samples closer than a HIMETRIC unit store as one point.
156 if (point[0] - last[0]).hypot(point[1] - last[1]) >= 72.0 / 2540.0 {
157 points.push(point);
158 }
159 Ok(Response::redraw())
160 }
161 Some(Gesture::Erase { last, erased }) => {
162 let from = std::mem::replace(last, point);
163 let join = *erased;
164 if self.editor.erase(from, point, self.reach(), join)? {
165 if let Some(Gesture::Erase { erased, .. }) = &mut self.ink.gesture {
166 *erased = true;
167 }
168 return self.changed();
169 }
170 Ok(Response::default())
171 }
172 Some(Gesture::Move { press }) => {
173 let delta = [point[0] - press[0], point[1] - press[1]];
174 self.editor
175 .drag_ink(Some((self.ink.selection.clone(), delta)));
176 Ok(Response::redraw())
177 }
178 Some(Gesture::Shape(_)) => Ok(Response::redraw()),
179 None => Ok(Response::default()),
180 }
181 }
182
183 /// The press ended at page point `point`, finishing the gesture.
184 pub(super) fn ink_released(&mut self, point: [f32; 2]) -> Result<Response> {
185 let Some(gesture) = self.ink.gesture.take() else {
186 return Ok(Response::default());
187 };
188 match gesture {
189 Gesture::Stroke(points) => {
190 let Tool::Pen(pen) = self.ink.tool else {
191 return Ok(Response::default());
192 };
193 self.editor.draw(Ink {
194 id: new_id()?,
195 layout: Default::default(),
196 strokes: vec![pen.stroke(&points)?],
197 groups: Vec::new(),
198 shape: None,
199 })?;
200 }
201 Gesture::Shape(from) => {
202 let Tool::Shape(kind, pen) = self.ink.tool else {
203 return Ok(Response::default());
204 };
205 let to = self.grid(point);
206 if from == to {
207 return Ok(Response::redraw());
208 }
209 self.editor
210 .draw(Ink::drawn(kind, from, to, &pen.stroke(&[])?)?)?;
211 }
212 Gesture::Erase { .. } => return Ok(Response::redraw()),
213 Gesture::Lasso(mut points) => {
214 points.push(point);
215 self.ink.selection = self.editor.ink_within(&points);
216 return Ok(Response::redraw());
217 }
218 Gesture::Move { press } => {
219 let delta = [point[0] - press[0], point[1] - press[1]];
220 // As OneNote does, a drawing dropped outside the view stays where it was.
221 let inside = (0..2).all(|axis| {
222 (0.0..self.viewport.size[axis] as f32).contains(&self.pointer[axis])
223 });
224 let selection = self.ink.selection.clone();
225 self.editor
226 .move_ink(&selection, if inside { delta } else { [0.0; 2] })?;
227 }
228 }
229 self.changed()
230 }
231
232 /// Delete and Backspace delete picked drawings; Escape lets them go, then leaves a
233 /// drawing tool for Select & Type, as OneNote's Escape does.
234 pub(super) fn ink_key(&mut self, key: &Key) -> Result<Option<Response>> {
235 match key {
236 Key::Named(NamedKey::Backspace | NamedKey::Delete)
237 if !self.ink.selection.is_empty() =>
238 {
239 let selection = std::mem::take(&mut self.ink.selection);
240 self.editor.delete_ink(&selection)?;
241 Ok(Some(self.changed()?))
242 }
243 Key::Named(NamedKey::Escape) if !self.ink.selection.is_empty() => {
244 self.ink.selection.clear();
245 Ok(Some(Response::redraw()))
246 }
247 Key::Named(NamedKey::Escape) if self.ink.tool != Tool::Select => {
248 Ok(Some(self.set_tool(Tool::Select)))
249 }
250 _ => {
251 self.ink.selection.clear();
252 Ok(None)
253 }
254 }
255 }
256
257 /// Whether a press on the page began a gesture of the drawing tools.
258 pub(super) fn inking(&self) -> bool {
259 self.ink.gesture.is_some()
260 }
261
262 /// Whether a drawing tool takes presses, which then show a crosshair.
263 pub(super) fn drawing(&self) -> bool {
264 self.ink.tool != Tool::Select
265 }
266
267 /// The gesture under way and the picked drawings' frame, over the page.
268 pub(super) fn append_ink_feedback(
269 &self,
270 automatic: [f32; 4],
271 primitives: &mut Vec<Primitive<'_>>,
272 ) {
273 let pixel = self.pixel();
274 let point = self.viewport.document_point(self.pointer);
275 let preview = |ink: Ink, primitives: &mut Vec<Primitive<'_>>| {
276 crate::gpu::page::append_ink(&ink, [0.0; 2], automatic, primitives)
277 };
278 match (&self.ink.gesture, self.ink.tool) {
279 (Some(Gesture::Stroke(points)), Tool::Pen(pen)) => {
280 if let Ok(stroke) = pen.stroke(points) {
281 preview(ink(vec![stroke]), primitives);
282 }
283 }
284 (Some(Gesture::Shape(from)), Tool::Shape(kind, pen)) => {
285 let to = self.grid(point);
286 if let Ok(pen) = pen.stroke(&[])
287 && let Ok(shape) = Ink::drawn(kind, *from, to, &pen)
288 {
289 preview(shape, primitives);
290 }
291 }
292 (Some(Gesture::Lasso(points)), _) => {
293 let lasso = colorref(0x00808080);
294 for pair in points.windows(2) {
295 primitives.push(Primitive::Segment {
296 from: pair[0],
297 to: pair[1],
298 width: pixel,
299 round: true,
300 color: lasso,
301 });
302 }
303 }
304 _ => {}
305 }
306 if let Some([x0, y0, x1, y1]) = self.editor.ink_extent(&self.ink.selection) {
307 let pad = 3.0 * pixel;
308 primitives.push(Primitive::RoundedRect {
309 rect: [x0 - pad, y0 - pad, x1 + pad, y1 + pad],
310 radius: [0.0; 2],
311 stroke: Some(Stroke::Dashed(pixel)),
312 color: colorref(0x00e0a060),
313 });
314 }
315 }
316}
317
318fn ink(strokes: Vec<InkStroke>) -> Ink {
319 Ink {
320 id: ExGuid::default(),
321 layout: Default::default(),
322 strokes,
323 groups: Vec::new(),
324 shape: None,
325 }
326}
crates/canvas/src/interaction/mod.rs+106-1
...@@ -3,6 +3,7 @@...@@ -3,6 +3,7 @@
3//! translate their platform's events into these calls and carry out the returned requests.3//! translate their platform's events into these calls and carry out the returned requests.
44
5pub mod accessibility;5pub mod accessibility;
6pub mod ink;
6#[cfg(test)]7#[cfg(test)]
7mod profile;8mod profile;
8mod scroll;9mod scroll;
...@@ -47,6 +48,8 @@ pub enum Cursor {...@@ -47,6 +48,8 @@ pub enum Cursor {
47 RowResize,48 RowResize,
48 /// Insert Space's line down the page.49 /// Insert Space's line down the page.
49 ColResize,50 ColResize,
51 /// A drawing tool's.
52 Crosshair,
50}53}
5154
52/// Work only the host can do, asked for by an event.55/// Work only the host can do, asked for by an event.
...@@ -62,6 +65,11 @@ pub enum Request {...@@ -62,6 +65,11 @@ pub enum Request {
62 OpenLink(String),65 OpenLink(String),
63 /// Open a copy of a file with the system's application for it, as a double click does.66 /// Open a copy of a file with the system's application for it, as a double click does.
64 OpenAttachment(onestore::page::Attachment),67 OpenAttachment(onestore::page::Attachment),
68 /// Play recording `file` from `at_ms`, as a note's play button does.
69 Play {
70 file: onestore::page::Attachment,
71 at_ms: u32,
72 },
65}73}
6674
67/// What a secondary press lands on, which its context menu offers commands for.75/// What a secondary press lands on, which its context menu offers commands for.
...@@ -235,6 +243,7 @@ pub struct PageView {...@@ -235,6 +243,7 @@ pub struct PageView {
235 caret: f32,243 caret: f32,
236 /// When the caret last moved, which restarts its blink.244 /// When the caret last moved, which restarts its blink.
237 blink_from: Instant,245 blink_from: Instant,
246 ink: ink::State,
238}247}
239248
240/// Room, in OneNote pixels, the view leaves beyond content it scrolls to.249/// Room, in OneNote pixels, the view leaves beyond content it scrolls to.
...@@ -293,6 +302,7 @@ impl PageView {...@@ -293,6 +302,7 @@ impl PageView {
293 found: Vec::new(),302 found: Vec::new(),
294 caret: 1.0,303 caret: 1.0,
295 blink_from: Instant::now(),304 blink_from: Instant::now(),
305 ink: Default::default(),
296 };306 };
297 view.place_opened();307 view.place_opened();
298 view308 view
...@@ -306,6 +316,7 @@ impl PageView {...@@ -306,6 +316,7 @@ impl PageView {
306 self.space = false;316 self.space = false;
307 self.object_focus = None;317 self.object_focus = None;
308 self.found.clear();318 self.found.clear();
319 self.leave_ink();
309 }320 }
310321
311 /// Shows the stored page after a change made elsewhere in place of the one shown,322 /// Shows the stored page after a change made elsewhere in place of the one shown,
...@@ -320,6 +331,7 @@ impl PageView {...@@ -320,6 +331,7 @@ impl PageView {
320 }331 }
321 self.drag = None;332 self.drag = None;
322 self.object_focus = None;333 self.object_focus = None;
334 self.refresh_ink();
323 self.moved()335 self.moved()
324 }336 }
325337
...@@ -858,6 +870,15 @@ impl PageView {...@@ -858,6 +870,15 @@ impl PageView {
858 found: &self.found,870 found: &self.found,
859 },871 },
860 )?);872 )?);
873 if self.drag.is_none()
874 && self.pointer_inside
875 && !self.space
876 && let Some((button, ..)) = self
877 .editor
878 .play_button(self.viewport.document_point(self.pointer))
879 {
880 append_play_button(button, colors.paper, &mut primitives);
881 }
861 if let Some(axis) = self882 if let Some(axis) = self
862 .space_axis()883 .space_axis()
863 .filter(|_| self.pointer_inside || self.drag.is_some())884 .filter(|_| self.pointer_inside || self.drag.is_some())
...@@ -879,6 +900,7 @@ impl PageView {...@@ -879,6 +900,7 @@ impl PageView {
879 &mut primitives,900 &mut primitives,
880 );901 );
881 }902 }
903 self.append_ink_feedback(colors.paper.ink, &mut primitives);
882 Ok(primitives)904 Ok(primitives)
883 }905 }
884906
...@@ -887,7 +909,24 @@ impl PageView {...@@ -887,7 +909,24 @@ impl PageView {
887 if let Some(axis) = self.space_axis() {909 if let Some(axis) = self.space_axis() {
888 return [Cursor::ColResize, Cursor::RowResize][axis];910 return [Cursor::ColResize, Cursor::RowResize][axis];
889 }911 }
890 let hit = self.hit_test(self.viewport.document_point(self.pointer));912 let point = self.viewport.document_point(self.pointer);
913 if self
914 .editor
915 .ink_extent(self.ink_selection())
916 .is_some_and(|[x0, y0, x1, y1]| {
917 (x0..=x1).contains(&point[0]) && (y0..=y1).contains(&point[1])
918 })
919 {
920 return Cursor::Move;
921 }
922 if self.drawing() {
923 return Cursor::Crosshair;
924 }
925 let point = self.viewport.document_point(self.pointer);
926 if self.drag.is_none() && self.play_button(point).is_some() {
927 return Cursor::Pointer;
928 }
929 let hit = self.hit_test(point);
891 match (&self.drag, hit) {930 match (&self.drag, hit) {
892 (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle),931 (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle),
893 (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle),932 (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle),
...@@ -939,6 +978,7 @@ impl PageView {...@@ -939,6 +978,7 @@ impl PageView {
939 if !focused {978 if !focused {
940 self.drag = None;979 self.drag = None;
941 self.space = false;980 self.space = false;
981 self.end_ink();
942 }982 }
943 self.changed()983 self.changed()
944 }984 }
...@@ -960,6 +1000,9 @@ impl PageView {...@@ -960,6 +1000,9 @@ impl PageView {
960 pub fn pointer_moved(&mut self, position: [f32; 2]) -> Result<Response> {1000 pub fn pointer_moved(&mut self, position: [f32; 2]) -> Result<Response> {
961 self.pointer_inside = true;1001 self.pointer_inside = true;
962 self.pointer = position;1002 self.pointer = position;
1003 if self.inking() {
1004 return self.ink_moved(self.viewport.document_point(position));
1005 }
963 match &mut self.drag {1006 match &mut self.drag {
964 Some(Drag::Text { anchor, unit }) => {1007 Some(Drag::Text { anchor, unit }) => {
965 let (anchor, unit) = (*anchor, *unit);1008 let (anchor, unit) = (*anchor, *unit);
...@@ -1019,6 +1062,13 @@ impl PageView {...@@ -1019,6 +1062,13 @@ impl PageView {
1019 });1062 });
1020 return Ok(Response::redraw());1063 return Ok(Response::redraw());
1021 }1064 }
1065 if let Some((file, at_ms)) = self.play_button(point) {
1066 self.drag = None;
1067 return Ok(Response::request(Request::Play { file, at_ms }));
1068 }
1069 if let Some(response) = self.ink_pressed(point)? {
1070 return Ok(response);
1071 }
1022 let unit = self1072 let unit = self
1023 .clicks1073 .clicks
1024 .press(now, self.pointer, 4.0 * self.display_scale);1074 .press(now, self.pointer, 4.0 * self.display_scale);
...@@ -1143,6 +1193,9 @@ impl PageView {...@@ -1143,6 +1193,9 @@ impl PageView {
1143 }1193 }
11441194
1145 pub fn pointer_released(&mut self) -> Result<Response> {1195 pub fn pointer_released(&mut self) -> Result<Response> {
1196 if self.inking() {
1197 return self.ink_released(self.viewport.document_point(self.pointer));
1198 }
1146 if let Some(Drag::Space { axis, line }) = self.drag {1199 if let Some(Drag::Space { axis, line }) = self.drag {
1147 self.drag = None;1200 self.drag = None;
1148 let delta = self.viewport.document_point(self.pointer)[axis] - line;1201 let delta = self.viewport.document_point(self.pointer)[axis] - line;
...@@ -1436,6 +1489,39 @@ impl PageView {...@@ -1436,6 +1489,39 @@ impl PageView {
1436 self.edited()1489 self.edited()
1437 }1490 }
14381491
1492 /// Record Audio: see [`CanvasEditor::start_recording`]; none where text cannot go.
1493 pub fn start_recording(&mut self, label: &str) -> Result<(Option<[u8; 16]>, Response)> {
1494 if !self.accepts_text() || self.editor.marked_range().is_some() {
1495 return Ok((None, Response::default()));
1496 }
1497 let id = self.editor.start_recording(&mut self.engine, label)?;
1498 Ok((Some(id), self.edited()?))
1499 }
1500
1501 /// Stop: see [`CanvasEditor::finish_recording`]. A file without an icon takes the
1502 /// page's blank one.
1503 pub fn finish_recording(&mut self, mut file: onestore::page::Attachment) -> Result<Response> {
1504 file.preview
1505 .get_or_insert_with(|| crate::gpu::page::file_icon().into());
1506 self.editor.finish_composition();
1507 self.editor.finish_recording(&mut self.engine, file)?;
1508 self.follow_pictures()?;
1509 self.edited()
1510 }
1511
1512 /// The recording a press at document point `point` plays and the moment it plays from:
1513 /// that of a play button OneNote shows beside a linked note or a recording.
1514 fn play_button(&self, point: [f32; 2]) -> Option<(onestore::page::Attachment, u32)> {
1515 if !self.pointer_inside || self.space {
1516 return None;
1517 }
1518 let pointer = self.viewport.document_point(self.pointer);
1519 let ([x0, y0, x1, y1], recording, at) = self.editor.play_button(pointer)?;
1520 ((x0..=x1).contains(&point[0]) && (y0..=y1).contains(&point[1]))
1521 .then(|| Some((self.editor.recording_file(recording)?.clone(), at)))
1522 .flatten()
1523 }
1524
1439 /// Insert, Attach File: see [`CanvasEditor::insert_attachment`]. A file without an icon1525 /// Insert, Attach File: see [`CanvasEditor::insert_attachment`]. A file without an icon
1440 /// takes the page's blank one.1526 /// takes the page's blank one.
1441 pub fn insert_attachment(&mut self, mut file: onestore::page::Attachment) -> Result<Response> {1527 pub fn insert_attachment(&mut self, mut file: onestore::page::Attachment) -> Result<Response> {
...@@ -1562,6 +1648,9 @@ impl PageView {...@@ -1562,6 +1648,9 @@ impl PageView {
1562 if key == &Key::Named(NamedKey::Modifier) {1648 if key == &Key::Named(NamedKey::Modifier) {
1563 return Ok(Response::default());1649 return Ok(Response::default());
1564 }1650 }
1651 if let Some(response) = self.ink_key(key)? {
1652 return Ok(response);
1653 }
1565 if let Some(ObjectFocus::Image(id) | ObjectFocus::File(id)) = self.object_focus1654 if let Some(ObjectFocus::Image(id) | ObjectFocus::File(id)) = self.object_focus
1566 && matches!(key, Key::Named(NamedKey::Backspace | NamedKey::Delete))1655 && matches!(key, Key::Named(NamedKey::Backspace | NamedKey::Delete))
1567 {1656 {
...@@ -1808,6 +1897,22 @@ pub fn page_hit_test(...@@ -1808,6 +1897,22 @@ pub fn page_hit_test(
1808 page_hit(editor, scene, point, pixel, None)1897 page_hit(editor, scene, point, pixel, None)
1809}1898}
18101899
1900/// OneNote 2010's play button: a blue disc with a white triangle, in `rect`.
1901fn append_play_button(rect: [f32; 4], paper: Paper, primitives: &mut Vec<Primitive<'_>>) {
1902 let side = rect[2] - rect[0];
1903 primitives.push(Primitive::Gradient {
1904 rect,
1905 radius: [side / 2.0; 2],
1906 colors: [0x00e6a56e, 0x00c8783a].map(|color| paper.shade(crate::gpu::colorref(color))),
1907 });
1908 primitives.push(Primitive::Path {
1909 data: "M4 3L8 5.25L4 7.5Z",
1910 origin: [rect[0], rect[1]],
1911 style: draw::PathStyle::Fill,
1912 colors: [[1.0; 4]; 2],
1913 });
1914}
1915
1811/// Logical pixels a finger's grip targets extend beyond the drawn grips.1916/// Logical pixels a finger's grip targets extend beyond the drawn grips.
1812const TOUCH_REACH: f32 = 16.0;1917const TOUCH_REACH: f32 = 16.0;
18131918
crates/canvas/src/outline.rs+2
...@@ -2613,8 +2613,10 @@ mod tests {...@@ -2613,8 +2613,10 @@ mod tests {
2613 color: None,2613 color: None,
2614 transparency: None,2614 transparency: None,
2615 pen_tip: None,2615 pen_tip: None,
2616 raster_operation: None,
2616 }],2617 }],
2617 groups: Vec::new(),2618 groups: Vec::new(),
2619 shape: None,
2618 });2620 });
2619 let after = paragraph(4, "After", 1, None);2621 let after = paragraph(4, "After", 1, None);
2620 let mut engine = TextEngine::default();2622 let mut engine = TextEngine::default();
crates/canvas/tests/ink_tools.rs created+287
...@@ -0,0 +1,287 @@
1//! The Draw tab's tools through the page view, as a host drives them: every stroke and shape
2//! is one edit adding one drawing, the eraser and the lasso's Delete remove drawings, a lasso
3//! drag moves them, and each is one undo step. The ops seal into a section reading back as the
4//! editor's page. `CANVAS_INK_EXPORT` names a directory receiving that section as a notebook,
5//! for a cold reopen in OneNote 2010 (`corpus/ink-tools`).
6#![cfg(feature = "interaction")]
7
8use canvas::{
9 editor::{FAVORITES, Pen},
10 gpu::page::PageScene,
11 interaction::{PageView, TextColors, ink::Tool},
12 layout::TextEngine,
13};
14use draw::edit::{Key, NamedKey};
15use onestore::{
16 Arena, ExGuid, Section, Store,
17 op::{Edit, Op, PageOp},
18 page::{Page, PageObject, ink::ShapeKind},
19};
20use std::time::{Duration, Instant};
21
22const COLORS: TextColors = TextColors {
23 caret: [0.0, 0.0, 0.0, 1.0],
24 selection: [0.7, 0.8, 1.0, 1.0],
25 paper: canvas::gpu::Paper::WHITE,
26};
27
28struct Lab<'a> {
29 section: Section<'a>,
30 space: ExGuid,
31 view: PageView,
32 at: u64,
33}
34
35impl Lab<'_> {
36 /// Stores what the last gesture recorded, returning its ops.
37 fn store(&mut self) -> Vec<PageOp> {
38 let ops = self.view.editor.take_ops().unwrap();
39 self.at += 10_000_000;
40 let edit = Edit {
41 at: self.at,
42 ops: ops
43 .iter()
44 .cloned()
45 .map(|op| Op::Page {
46 space: self.space,
47 op,
48 })
49 .collect(),
50 };
51 self.section.apply("Author", &edit).unwrap();
52 assert_eq!(
53 self.section.page(self.space).unwrap(),
54 self.view.editor.page().unwrap()
55 );
56 ops
57 }
58
59 /// A press at page point `from` dragged through `through`, in a view at 1 device pixel
60 /// per point.
61 fn drag(&mut self, path: &[[f32; 2]]) {
62 let view = &mut self.view;
63 let _ = view.pointer_moved(path[0]).unwrap();
64 let _ = view.pointer_pressed(Instant::now()).unwrap();
65 for point in &path[1..] {
66 let _ = view.pointer_moved(*point).unwrap();
67 }
68 let _ = view.pointer_released().unwrap();
69 }
70
71 fn drawings(&self) -> Vec<onestore::page::Ink> {
72 self.view
73 .editor
74 .page()
75 .unwrap()
76 .objects
77 .into_iter()
78 .filter_map(|object| match object {
79 PageObject::Ink(ink) => Some(ink),
80 _ => None,
81 })
82 .collect()
83 }
84}
85
86fn open(arena: &Arena, source: Vec<u8>) -> Lab<'_> {
87 let mut section = Section::open(arena, source).unwrap();
88 let (space, ..) = section.pages().unwrap()[0].clone();
89 let page: Page = section.page(space).unwrap();
90 let mut engine = TextEngine::default();
91 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
92 let mut view = PageView::new(
93 editor,
94 engine,
95 Some((scene, [0.0; 2])),
96 [800, 600],
97 1.0,
98 Duration::from_millis(500),
99 );
100 view.viewport.scale = 1.0;
101 view.viewport.origin = [0.0; 2];
102 Lab {
103 section,
104 space,
105 view,
106 at: 133_000_000_000_000_000,
107 }
108}
109
110fn line(from: [f32; 2], to: [f32; 2], steps: usize) -> Vec<[f32; 2]> {
111 (0..=steps)
112 .map(|step| {
113 let t = step as f32 / steps as f32;
114 [
115 from[0] + (to[0] - from[0]) * t,
116 from[1] + (to[1] - from[1]) * t,
117 ]
118 })
119 .collect()
120}
121
122#[test]
123fn drawing_tools_store_each_stroke_and_undo_it_in_one_step() {
124 let source = onestore::create_section("ink.one", "Drawn in Snowbound", "Author").unwrap();
125 let arena = Arena::default();
126 let mut lab = open(&arena, source.clone());
127 let accent = Pen::new(35.0, Some(0x7a9a1f));
128
129 let _ = lab.view.set_tool(Tool::Pen(accent));
130 let wave: Vec<[f32; 2]> = (0..=40)
131 .map(|step| {
132 let x = 60.0 + step as f32 * 4.0;
133 [x, 150.0 + 12.0 * (x / 18.0).sin()]
134 })
135 .collect();
136 lab.drag(&wave);
137 let ops = lab.store();
138 let [
139 PageOp::Add {
140 object: PageObject::Ink(ink),
141 ..
142 },
143 ] = ops.as_slice()
144 else {
145 panic!("{ops:?}")
146 };
147 assert_eq!((ink.strokes.len(), ink.strokes[0].points.len()), (1, 41));
148 assert_eq!(ink.strokes[0].color, Some(0x7a9a1f));
149
150 let _ = lab.view.set_tool(Tool::Pen(FAVORITES[5]));
151 lab.drag(&line([60.0, 190.0], [220.0, 190.0], 20));
152 lab.store();
153 for (kind, from, to) in [
154 (ShapeKind::Rectangle, [250.0, 140.0], [326.0, 196.0]),
155 (ShapeKind::Ellipse, [340.0, 140.0], [430.0, 196.0]),
156 (ShapeKind::Arrow, [250.0, 230.0], [358.0, 266.0]),
157 (ShapeKind::Line, [378.0, 230.0], [450.0, 232.0]),
158 ] {
159 let _ = lab
160 .view
161 .set_tool(Tool::Shape(kind, Pen::new(50.0, Some(0x7a9a1f))));
162 lab.drag(&line(from, to, 8));
163 lab.store();
164 }
165 let drawn = lab.drawings();
166 assert_eq!(drawn.len(), 6);
167 // Shapes take their corners from the grid, anchored at the margin origin.
168 let rectangle = &drawn[2];
169 assert_eq!(
170 (
171 rectangle.strokes[0].points[0],
172 rectangle.strokes[0].points[2]
173 ),
174 (
175 [252.0, 140.4].map(onestore::page::ink::snap),
176 [324.0, 194.4].map(onestore::page::ink::snap)
177 )
178 );
179 assert_eq!(
180 drawn[4].strokes.len(),
181 2,
182 "an arrow's head is a stroke of its own"
183 );
184 let highlighter = drawn[1].strokes[0].clone();
185 assert_eq!(
186 (
187 highlighter.raster_operation,
188 highlighter.pen_tip,
189 highlighter.transparency
190 ),
191 (Some(9), Some(1), Some(127))
192 );
193 // A highlighter multiplies what lies beneath.
194 let primitives = lab.view.primitives(COLORS).unwrap();
195 assert!(
196 primitives
197 .iter()
198 .any(|primitive| matches!(primitive, draw::Primitive::Highlight { .. }))
199 );
200 if let Some(directory) = std::env::var_os("CANVAS_INK_EXPORT") {
201 let mut image = source.clone();
202 let mut sealed = Section::open(&arena, source.clone()).unwrap();
203 std::mem::swap(&mut sealed, &mut lab.section);
204 sealed.seal().unwrap().unwrap().apply(&mut image).unwrap();
205 let directory = std::path::PathBuf::from(directory);
206 std::fs::create_dir_all(&directory).unwrap();
207 std::fs::write(directory.join("ink.one"), &image).unwrap();
208 let file_id = Store::parse(&image).unwrap().header.file_id;
209 std::fs::write(
210 directory.join("Open Notebook.onetoc2"),
211 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("ink.one", file_id)])
212 .unwrap(),
213 )
214 .unwrap();
215 return;
216 }
217
218 // Undo takes the last drawing away, and only that.
219 let arrow = drawn[4].id;
220 let line_id = drawn[5].id;
221 let _ = lab.view.undo(false).unwrap();
222 assert_eq!(lab.store(), [PageOp::Delete { object: line_id }]);
223 let _ = lab.view.undo(true).unwrap();
224 lab.store();
225 assert_eq!(lab.drawings().len(), 6);
226
227 // The eraser takes the wave it crosses, and the arrow's two strokes with it at a touch.
228 let _ = lab.view.set_tool(Tool::Eraser);
229 lab.drag(&[
230 [100.0, 120.0],
231 [100.0, 160.0],
232 [240.0, 160.0],
233 [240.0, 215.0],
234 [320.0, 250.0],
235 ]);
236 let ops = lab.store();
237 assert_eq!(
238 ops,
239 [
240 PageOp::Delete {
241 object: drawn[0].id
242 },
243 PageOp::Delete { object: arrow },
244 ]
245 );
246 let _ = lab.view.undo(false).unwrap();
247 lab.store();
248 assert_eq!(lab.drawings().len(), 6);
249
250 // The lasso picks the highlighter, a drag moves it, Delete removes it.
251 let _ = lab.view.set_tool(Tool::Lasso);
252 lab.drag(&[
253 [50.0, 175.0],
254 [230.0, 175.0],
255 [230.0, 205.0],
256 [50.0, 205.0],
257 [50.0, 176.0],
258 ]);
259 assert_eq!(lab.view.ink_selection(), [drawn[1].id]);
260 lab.drag(&line([140.0, 190.0], [170.0, 220.0], 4));
261 let ops = lab.store();
262 assert!(matches!(
263 ops.as_slice(),
264 [PageOp::Outline { object, .. }] if *object == drawn[1].id
265 ));
266 let moved = &lab.drawings()[1];
267 assert_eq!((moved.layout.x, moved.layout.y), (Some(30.0), Some(30.0)));
268 let _ = lab.view.key(&Key::Named(NamedKey::Delete), None).unwrap();
269 assert_eq!(
270 lab.store(),
271 [PageOp::Delete {
272 object: drawn[1].id
273 }]
274 );
275 let _ = lab.view.undo(false).unwrap();
276 lab.store();
277 let _ = lab.view.undo(false).unwrap();
278 lab.store();
279 assert_eq!(lab.drawings()[1].layout.x, Some(0.0));
280
281 // Escape leaves the drawing tools for Select & Type, where a click picks a drawing.
282 let _ = lab.view.key(&Key::Named(NamedKey::Escape), None).unwrap();
283 assert_eq!(lab.view.tool(), Tool::Select);
284 lab.drag(&[[400.0, 231.0]]);
285 assert!(lab.view.editor.take_ops().unwrap().is_empty());
286 assert_eq!(lab.view.ink_selection(), [drawn[5].id]);
287}
crates/canvas/tests/ops_differential.rs+149-4
...@@ -7,7 +7,7 @@...@@ -7,7 +7,7 @@
77
8use canvas::{8use canvas::{
9 document::TextPosition,9 document::TextPosition,
10 editor::{Alignment, CanvasEditor, Formatting, NoteTag, Toggle, Whole},10 editor::{Alignment, CanvasEditor, FAVORITES, Formatting, NoteTag, Pen, Toggle, Whole},
11 layout::TextEngine,11 layout::TextEngine,
12};12};
13use draw::edit::Movement;13use draw::edit::Movement;
...@@ -31,7 +31,7 @@ const ART: [u8; 67] = [...@@ -31,7 +31,7 @@ const ART: [u8; 67] = [
31#[path = "../../onestore/tests/support/sweep.rs"]31#[path = "../../onestore/tests/support/sweep.rs"]
32mod sweep;32mod sweep;
3333
34const SECTIONS: [&str; 18] = [34const SECTIONS: [&str; 19] = [
35 "corpus/outline-edit/before/notebook/synthetic.one",35 "corpus/outline-edit/before/notebook/synthetic.one",
36 "corpus/paragraph-edit/before/notebook/synthetic.one",36 "corpus/paragraph-edit/before/notebook/synthetic.one",
37 "corpus/outline-edit/tree/before/notebook/synthetic.one",37 "corpus/outline-edit/tree/before/notebook/synthetic.one",
...@@ -43,6 +43,7 @@ const SECTIONS: [&str; 18] = [...@@ -43,6 +43,7 @@ const SECTIONS: [&str; 18] = [
43 "corpus/picture-edit/native-page-level/notebook/pictures.one",43 "corpus/picture-edit/native-page-level/notebook/pictures.one",
44 "corpus/attachment-edit/plain/cold/notebook/files.one",44 "corpus/attachment-edit/plain/cold/notebook/files.one",
45 "corpus/ink-edit/drawing/cold/notebook/ink.one",45 "corpus/ink-edit/drawing/cold/notebook/ink.one",
46 "corpus/ink-tools/native-ui/notebook/ink.one",
46 "corpus/math-edit/native-editor/notebook/links.one",47 "corpus/math-edit/native-editor/notebook/links.one",
47 "corpus/link-edit/native-links/notebook/links.one",48 "corpus/link-edit/native-links/notebook/links.one",
48 "corpus/link-edit/native-typed/notebook/links.one",49 "corpus/link-edit/native-typed/notebook/links.one",
...@@ -110,6 +111,8 @@ enum Kind {...@@ -110,6 +111,8 @@ enum Kind {
110 Paper,111 Paper,
111 Art,112 Art,
112 Attach,113 Attach,
114 /// Record Audio at a random caret, a note taken meanwhile, then Stop.
115 Record,
113 /// A click on blank page, then Attach File: a file on the page.116 /// A click on blank page, then Attach File: a file on the page.
114 AttachPage,117 AttachPage,
115 MoveFile,118 MoveFile,
...@@ -118,6 +121,15 @@ enum Kind {...@@ -118,6 +121,15 @@ enum Kind {
118 TypeRun,121 TypeRun,
119 /// More typing where the caret is, joining the run before it.122 /// More typing where the caret is, joining the run before it.
120 TypeOn,123 TypeOn,
124 /// A pen stroke, a highlighter stroke or a shape: a drawing of its own.
125 Pen,
126 Marker,
127 Shape,
128 /// The stroke eraser touching a drawing's first point.
129 Erase,
130 /// Lassoed drawings moved or deleted.
131 LassoMove,
132 LassoDelete,
121 Undo,133 Undo,
122 Redo,134 Redo,
123}135}
...@@ -202,6 +214,13 @@ const ATTACH_PLANS: [&[Kind]; 2] = [...@@ -202,6 +214,13 @@ const ATTACH_PLANS: [&[Kind]; 2] = [
202 &[Kind::Attach, Kind::Type, Kind::Attach, Kind::Undo],214 &[Kind::Attach, Kind::Type, Kind::Attach, Kind::Undo],
203];215];
204216
217/// Recordings at a random caret, in plans of their own so the random edits' sequences stay
218/// as they were.
219const RECORD_PLANS: [&[Kind]; 2] = [
220 &[Kind::Record, Kind::Undo, Kind::Redo],
221 &[Kind::Record, Kind::PageDelete, Kind::Undo],
222];
223
205/// Files attached on blank page, moved, spaced and removed, in plans of their own so the224/// Files attached on blank page, moved, spaced and removed, in plans of their own so the
206/// random edits' sequences stay as they were.225/// random edits' sequences stay as they were.
207const FLOATING_PLANS: [&[Kind]; 4] = [226const FLOATING_PLANS: [&[Kind]; 4] = [
...@@ -224,6 +243,30 @@ const FLOATING_PLANS: [&[Kind]; 4] = [...@@ -224,6 +243,30 @@ const FLOATING_PLANS: [&[Kind]; 4] = [
224 ],243 ],
225];244];
226245
246/// The Draw tab's tools, in plans of their own so the random edits' sequences stay as they
247/// were.
248const INK_PLANS: [&[Kind]; 6] = [
249 &[Kind::Pen, Kind::Undo, Kind::Redo],
250 &[
251 Kind::Marker,
252 Kind::Shape,
253 Kind::Undo,
254 Kind::Undo,
255 Kind::Redo,
256 ],
257 &[Kind::Pen, Kind::Pen, Kind::Erase, Kind::Undo, Kind::Redo],
258 &[
259 Kind::Shape,
260 Kind::LassoMove,
261 Kind::Undo,
262 Kind::Redo,
263 Kind::LassoDelete,
264 Kind::Undo,
265 ],
266 &[Kind::Erase, Kind::Undo, Kind::Redo],
267 &[Kind::LassoMove, Kind::LassoDelete, Kind::Undo, Kind::Undo],
268];
269
227/// Page colour, rule lines and art undone and redone across each other.270/// Page colour, rule lines and art undone and redone across each other.
228const PAPER_PLANS: [&[Kind]; 3] = [271const PAPER_PLANS: [&[Kind]; 3] = [
229 &[Kind::Art, Kind::Art, Kind::Undo, Kind::Undo, Kind::Redo],272 &[Kind::Art, Kind::Art, Kind::Undo, Kind::Undo, Kind::Redo],
...@@ -290,6 +333,12 @@ fn perform(...@@ -290,6 +333,12 @@ fn perform(
290 | Kind::Date333 | Kind::Date
291 | Kind::Paper334 | Kind::Paper
292 | Kind::Art335 | Kind::Art
336 | Kind::Pen
337 | Kind::Marker
338 | Kind::Shape
339 | Kind::Erase
340 | Kind::LassoMove
341 | Kind::LassoDelete
293 ) {342 ) {
294 if outlines.is_empty() {343 if outlines.is_empty() {
295 return false;344 return false;
...@@ -561,6 +610,29 @@ fn perform(...@@ -561,6 +610,29 @@ fn perform(
561 };610 };
562 editor.set_paper(editor.page_color(), editor.rule_lines(), Some(art))611 editor.set_paper(editor.page_color(), editor.rule_lines(), Some(art))
563 }612 }
613 Kind::Record => {
614 place(editor, middle)
615 && editor
616 .start_recording(engine, "Audio recording started")
617 .is_ok_and(|id| {
618 let file = onestore::page::Attachment {
619 id: onestore::page::text::new_id().unwrap(),
620 filename: "Recorded.wav".into(),
621 source_path: None,
622 size: Some([24.0, 24.0]),
623 layout: Default::default(),
624 bytes: Some(std::sync::Arc::from(b"RIFF".as_slice())),
625 preview: Some(std::sync::Arc::from(ART.as_slice())),
626 recording: Some(onestore::page::Recording {
627 id,
628 kind: 1,
629 duration_ms: Some(1000),
630 }),
631 };
632 editor.insert(engine, "noted").is_ok()
633 && editor.finish_recording(engine, file).is_ok()
634 })
635 }
564 Kind::Attach => {636 Kind::Attach => {
565 let file = onestore::page::Attachment {637 let file = onestore::page::Attachment {
566 id: onestore::page::text::new_id().unwrap(),638 id: onestore::page::text::new_id().unwrap(),
...@@ -624,8 +696,79 @@ fn perform(...@@ -624,8 +696,79 @@ fn perform(
624 && editor.delete_to(engine, Movement::WordLeft).is_ok()696 && editor.delete_to(engine, Movement::WordLeft).is_ok()
625 && editor.commit_text(engine, "ef".into()).is_ok()697 && editor.commit_text(engine, "ef".into()).is_ok()
626 }698 }
627 Kind::TypeOn => {699 Kind::TypeOn => editor.insert(engine, "gh").is_ok() && editor.delete(engine, true).is_ok(),
628 editor.insert(engine, "gh").is_ok() && editor.delete(engine, true).is_ok()700 Kind::Pen | Kind::Marker => {
701 let pen = if kind == Kind::Pen {
702 Pen::new(35.0, Some(0x7a9a1f))
703 } else {
704 FAVORITES[5]
705 };
706 let [x, y] = [
707 60.0 + 18.0 * random.below(20) as f32,
708 400.0 + 18.0 * random.below(10) as f32,
709 ];
710 let points: Vec<[f32; 2]> = (0..12)
711 .map(|step| [x + 6.0 * step as f32, y + 9.0 * (step as f32 / 2.0).sin()])
712 .collect();
713 let ink = onestore::page::Ink {
714 id: onestore::page::text::new_id().unwrap(),
715 layout: Default::default(),
716 strokes: vec![pen.stroke(&points).unwrap()],
717 groups: Vec::new(),
718 shape: None,
719 };
720 editor.draw(ink).is_ok()
721 }
722 Kind::Shape => {
723 use onestore::page::ink::ShapeKind;
724 let kinds = [
725 ShapeKind::Line,
726 ShapeKind::Arrow,
727 ShapeKind::Rectangle,
728 ShapeKind::Ellipse,
729 ];
730 let from = [
731 36.0 + 18.0 * random.below(20) as f32,
732 14.4 + 18.0 * random.below(30) as f32,
733 ];
734 let to = [
735 from[0] + 18.0 * (1 + random.below(6)) as f32,
736 from[1] + 18.0 * (1 + random.below(4)) as f32,
737 ];
738 let pen = Pen::new(50.0, Some(0x7a9a1f)).stroke(&[]).unwrap();
739 let ink = onestore::page::Ink::drawn(kinds[random.below(4)], from, to, &pen).unwrap();
740 editor.draw(ink).is_ok()
741 }
742 Kind::Erase | Kind::LassoMove | Kind::LassoDelete => {
743 let drawings: Vec<onestore::page::Ink> = editor
744 .page()
745 .unwrap()
746 .objects
747 .into_iter()
748 .filter_map(|object| match object {
749 onestore::page::PageObject::Ink(ink) => Some(ink),
750 _ => None,
751 })
752 .collect();
753 if drawings.is_empty() {
754 return false;
755 }
756 let ink = &drawings[random.below(drawings.len())];
757 match kind {
758 Kind::Erase => {
759 let Some([x, y]) = ink.strokes.first().map(|stroke| stroke.points[0]) else {
760 return false;
761 };
762 let [dx, dy] = [ink.layout.x, ink.layout.y].map(|v| v.unwrap_or(0.0));
763 let point = [x + dx, y + dy];
764 editor.erase(point, point, 1.0, false).unwrap_or(false)
765 }
766 Kind::LassoMove => {
767 let delta = [18.0, -9.0].map(|step| step * (1 + random.below(3)) as f32);
768 editor.move_ink(&[ink.id], delta).is_ok()
769 }
770 _ => editor.delete_ink(&[ink.id]).is_ok(),
771 }
629 }772 }
630 Kind::Undo => editor.undo(engine).unwrap_or(false),773 Kind::Undo => editor.undo(engine).unwrap_or(false),
631 Kind::Redo => editor.redo(engine).unwrap_or(false),774 Kind::Redo => editor.redo(engine).unwrap_or(false),
...@@ -915,7 +1058,9 @@ fn sweep(...@@ -915,7 +1058,9 @@ fn sweep(
915 .collect()1058 .collect()
916 }))1059 }))
917 .chain(PAPER_PLANS.map(<[Kind]>::to_vec))1060 .chain(PAPER_PLANS.map(<[Kind]>::to_vec))
1061 .chain(INK_PLANS.map(<[Kind]>::to_vec))
918 .chain(ATTACH_PLANS.map(<[Kind]>::to_vec))1062 .chain(ATTACH_PLANS.map(<[Kind]>::to_vec))
1063 .chain(RECORD_PLANS.map(<[Kind]>::to_vec))
919 .chain(FLOATING_PLANS.map(<[Kind]>::to_vec))1064 .chain(FLOATING_PLANS.map(<[Kind]>::to_vec))
920 .chain(TYPING_PLANS.map(<[Kind]>::to_vec))1065 .chain(TYPING_PLANS.map(<[Kind]>::to_vec))
921 .collect::<Vec<Vec<Kind>>>();1066 .collect::<Vec<Vec<Kind>>>();
crates/canvas/tests/recording.rs created+187
...@@ -0,0 +1,187 @@
1//! Recording through the editor, as Snowbound's Record Audio stores it: the ops the editor
2//! records seal into a section that reads back as the editor's page, with the line saying
3//! when, the notes written meanwhile linked to their moments, and the file where recording
4//! started.
5
6use canvas::{editor::CanvasEditor, layout::TextEngine};
7use draw::edit::Movement;
8use onestore::{
9 Arena, Section,
10 op::{Edit, Op},
11 page::{Attachment, MediaIndex, Page, PageObject, ParagraphContent, Recording},
12};
13
14fn body(page: &Page) -> Vec<(String, MediaIndex)> {
15 page.objects
16 .iter()
17 .find_map(|object| match object {
18 PageObject::Outline(outline) if !outline.title => Some(outline),
19 _ => None,
20 })
21 .unwrap()
22 .paragraphs
23 .iter()
24 .map(|paragraph| {
25 let shown = match &paragraph.content {
26 ParagraphContent::Text(text) => text.text.text().to_owned(),
27 ParagraphContent::Attachment(file) => format!("[{}]", file.filename),
28 _ => panic!(),
29 };
30 (shown, paragraph.media.clone())
31 })
32 .collect()
33}
34
35#[test]
36fn a_recording_stores_its_line_its_linked_notes_and_its_file() {
37 let source = onestore::create_section("files.one", "Recorded", "Author").unwrap();
38 let arena = Arena::default();
39 let mut section = Section::open(&arena, source.clone()).unwrap();
40 let (space, ..) = section.pages().unwrap()[0].clone();
41 let mut engine = TextEngine::default();
42 let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
43 let outline = editor
44 .outlines()
45 .iter()
46 .find(|outline| !outline.title)
47 .unwrap()
48 .id;
49 editor.focus_outline(outline).unwrap();
50 editor
51 .move_selection(&mut engine, Movement::DocumentEnd, false)
52 .unwrap();
53 let mut at = 133_000_000_000_000_000;
54 let mut store = |editor: &mut CanvasEditor| {
55 let ops = editor.take_ops().unwrap();
56 at += 10_000_000;
57 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
58 section.apply("Author", &Edit { at, ops }).unwrap();
59 let stored = section.page(space).unwrap();
60 assert_eq!(
61 Page {
62 title: stored.title.clone(),
63 ..editor.page().unwrap()
64 },
65 stored
66 );
67 stored
68 };
69 let id = editor
70 .start_recording(&mut engine, "Audio recording started: 5:18 PM")
71 .unwrap();
72 assert_eq!(editor.recording(), Some(id));
73 store(&mut editor);
74 std::thread::sleep(std::time::Duration::from_millis(20));
75 editor.insert(&mut engine, "A linked note").unwrap();
76 store(&mut editor);
77 editor.insert(&mut engine, "\n").unwrap();
78 store(&mut editor);
79 let file = Attachment {
80 id: onestore::page::text::new_id().unwrap(),
81 filename: "Recorded.wav".into(),
82 source_path: None,
83 size: Some([24.0, 24.0]),
84 layout: Default::default(),
85 bytes: Some(b"RIFF".as_slice().into()),
86 preview: Some(canvas::gpu::page::file_icon().into()),
87 recording: Some(Recording {
88 id,
89 kind: 1,
90 duration_ms: Some(1000),
91 }),
92 };
93 editor.finish_recording(&mut engine, file).unwrap();
94 assert_eq!(editor.recording(), None);
95 let stored = store(&mut editor);
96 let shown = body(&stored);
97 let texts: Vec<&str> = shown.iter().map(|(text, _)| text.as_str()).collect();
98 assert_eq!(
99 texts,
100 [
101 "Recorded",
102 "[Recorded.wav]",
103 "",
104 "Audio recording started: 5:18 PM",
105 "A linked note",
106 ""
107 ]
108 );
109 let linked = |time| MediaIndex {
110 recordings: vec![id],
111 time_ms: Some(time),
112 };
113 assert_eq!(shown[0].1, MediaIndex::default());
114 assert_eq!(shown[1].1, linked(0));
115 assert_eq!(shown[3].1, linked(0));
116 assert!(shown[4].1.time_ms.is_some_and(|time| time >= 20));
117 assert_eq!(shown[5].1, MediaIndex::default());
118
119 // Undo takes the file and the links away as stored, and redo brings them back.
120 let before = stored;
121 editor.undo(&mut engine).unwrap();
122 let undone = store(&mut editor);
123 assert!(!body(&undone).iter().any(|(text, _)| text.starts_with('[')));
124 editor.redo(&mut engine).unwrap();
125 assert_eq!(store(&mut editor), before);
126}
127
128/// With the caret in the title, OneNote 2010 records at the start of the body, the file first
129/// (`corpus/recording`); an empty first half of the caret's paragraph is the blank line.
130#[test]
131fn a_recording_from_the_title_goes_to_the_body_s_start() {
132 let source = include_bytes!("../../../corpus/recording/native/notebook/files.one");
133 let arena = Arena::default();
134 let mut section = Section::open(&arena, source.to_vec()).unwrap();
135 let (space, ..) = section
136 .pages()
137 .unwrap()
138 .into_iter()
139 .find(|(_, title, _)| title == "Recorded")
140 .unwrap();
141 let mut engine = TextEngine::default();
142 let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
143 let title = editor
144 .outlines()
145 .iter()
146 .find(|outline| outline.title)
147 .unwrap()
148 .id;
149 editor.focus_outline(title).unwrap();
150 let id = editor.start_recording(&mut engine, "Started").unwrap();
151 let file = Attachment {
152 id: onestore::page::text::new_id().unwrap(),
153 filename: "Recorded.wav".into(),
154 source_path: None,
155 size: Some([24.0, 24.0]),
156 layout: Default::default(),
157 bytes: Some(b"RIFF".as_slice().into()),
158 preview: Some(canvas::gpu::page::file_icon().into()),
159 recording: Some(Recording {
160 id,
161 kind: 1,
162 duration_ms: None,
163 }),
164 };
165 editor.finish_recording(&mut engine, file).unwrap();
166 let ops = editor.take_ops().unwrap();
167 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
168 section
169 .apply(
170 "Author",
171 &Edit {
172 at: 133_000_000_000_000_000,
173 ops,
174 },
175 )
176 .unwrap();
177 let stored = section.page(space).unwrap();
178 let shown = body(&stored);
179 let texts: Vec<&str> = shown.iter().map(|(text, _)| text.as_str()).collect();
180 assert_eq!(
181 texts[..4],
182 ["[Recorded.wav]", "", "Started", "Before recording edited"]
183 );
184 // The note keeps its link to OneNote's recording.
185 assert_eq!(shown[3].1.time_ms, Some(20131));
186 assert_eq!(shown[1].1, MediaIndex::default());
187}
crates/draw/src/render.rs+24
...@@ -188,6 +188,8 @@ enum Blend {...@@ -188,6 +188,8 @@ enum Blend {
188 Image(u64),188 Image(u64),
189 /// Clearing it by the batch's coverage.189 /// Clearing it by the batch's coverage.
190 Erase,190 Erase,
191 /// Multiplying it by the batch's colour where covered.
192 Multiply,
191}193}
192194
193#[repr(C)]195#[repr(C)]
...@@ -427,6 +429,14 @@ pub enum Primitive<'a> {...@@ -427,6 +429,14 @@ pub enum Primitive<'a> {
427 round: bool,429 round: bool,
428 color: [f32; 4],430 color: [f32; 4],
429 },431 },
432 /// A highlighter's stroke between two points, `width` units across with square ends:
433 /// its opaque `color` multiplies what lies beneath, so text under it stays dark.
434 Highlight {
435 from: [f32; 2],
436 to: [f32; 2],
437 width: f32,
438 color: [f32; 4],
439 },
430}440}
431441
432#[derive(Debug)]442#[derive(Debug)]
...@@ -851,6 +861,15 @@ impl Renderer {...@@ -851,6 +861,15 @@ impl Renderer {
851 round,861 round,
852 color,862 color,
853 } => self.segment(space, *from, *to, *width, *round, *color)?,863 } => self.segment(space, *from, *to, *width, *round, *color)?,
864 Primitive::Highlight {
865 from,
866 to,
867 width,
868 color,
869 } => {
870 blend = Blend::Multiply;
871 self.segment(space, *from, *to, *width, false, *color)?;
872 }
854 Primitive::Image { image, rect } => {873 Primitive::Image { image, rect } => {
855 if let Some(rect) = space.visible_rect(*rect)? {874 if let Some(rect) = space.visible_rect(*rect)? {
856 self.image(image, active_images)?;875 self.image(image, active_images)?;
...@@ -1600,6 +1619,11 @@ pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] {...@@ -1600,6 +1619,11 @@ pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] {
1600 [red, green, blue, 1.0]1619 [red, green, blue, 1.0]
1601}1620}
16021621
1622/// The sRGB bytes of linear RGBA `color`, as `srgb` takes them.
1623pub fn srgb_bytes([red, green, blue, _]: [f32; 4]) -> [u8; 3] {
1624 [red, green, blue].map(srgb_byte)
1625}
1626
1603#[cfg(all(test, feature = "wgpu"))]1627#[cfg(all(test, feature = "wgpu"))]
1604mod tests {1628mod tests {
1605 use super::*;1629 use super::*;
crates/draw/src/render/gl.rs+4
...@@ -21,6 +21,7 @@ const ZERO: GLenum = 0;...@@ -21,6 +21,7 @@ const ZERO: GLenum = 0;
21const ONE: GLenum = 1;21const ONE: GLenum = 1;
22const SRC_ALPHA: GLenum = 0x0302;22const SRC_ALPHA: GLenum = 0x0302;
23const ONE_MINUS_SRC_ALPHA: GLenum = 0x0303;23const ONE_MINUS_SRC_ALPHA: GLenum = 0x0303;
24const DST_COLOR: GLenum = 0x0306;
24const BLEND: GLenum = 0x0BE2;25const BLEND: GLenum = 0x0BE2;
25const SCISSOR_TEST: GLenum = 0x0C11;26const SCISSOR_TEST: GLenum = 0x0C11;
26const MAX_TEXTURE_SIZE: GLenum = 0x0D33;27const MAX_TEXTURE_SIZE: GLenum = 0x0D33;
...@@ -463,6 +464,9 @@ impl Renderer {...@@ -463,6 +464,9 @@ impl Renderer {
463 gpu.atlas.name,464 gpu.atlas.name,
464 [ZERO, ONE_MINUS_SRC_ALPHA, ZERO, ONE_MINUS_SRC_ALPHA],465 [ZERO, ONE_MINUS_SRC_ALPHA, ZERO, ONE_MINUS_SRC_ALPHA],
465 ),466 ),
467 Blend::Multiply => {
468 (gpu.atlas.name, [DST_COLOR, ONE_MINUS_SRC_ALPHA, ZERO, ONE])
469 }
466 };470 };
467 glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha);471 glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha);
468 glBindTexture(TEXTURE_2D, texture);472 glBindTexture(TEXTURE_2D, texture);
crates/draw/src/render/webgpu.rs+17
...@@ -13,6 +13,7 @@ pub(super) struct Gpu {...@@ -13,6 +13,7 @@ pub(super) struct Gpu {
13 pipeline: wgpu::RenderPipeline,13 pipeline: wgpu::RenderPipeline,
14 image_pipeline: wgpu::RenderPipeline,14 image_pipeline: wgpu::RenderPipeline,
15 erase_pipeline: wgpu::RenderPipeline,15 erase_pipeline: wgpu::RenderPipeline,
16 multiply_pipeline: wgpu::RenderPipeline,
16 atlas: wgpu::Texture,17 atlas: wgpu::Texture,
17 bind_group: wgpu::BindGroup,18 bind_group: wgpu::BindGroup,
18 image_sampler: wgpu::Sampler,19 image_sampler: wgpu::Sampler,
...@@ -98,6 +99,20 @@ impl Renderer {...@@ -98,6 +99,20 @@ impl Renderer {
98 color: erase,99 color: erase,
99 alpha: erase,100 alpha: erase,
100 });101 });
102 // The fragment's colour is scaled by its coverage, so this leaves the target times
103 // the colour where covered and the target elsewhere; alpha stays.
104 let multiply_pipeline = make_pipeline(wgpu::BlendState {
105 color: wgpu::BlendComponent {
106 src_factor: wgpu::BlendFactor::Dst,
107 dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
108 operation: wgpu::BlendOperation::Add,
109 },
110 alpha: wgpu::BlendComponent {
111 src_factor: wgpu::BlendFactor::Zero,
112 dst_factor: wgpu::BlendFactor::One,
113 operation: wgpu::BlendOperation::Add,
114 },
115 });
101 let (atlas, bind_group) = atlas(&device, &pipeline, ATLAS_SIZE);116 let (atlas, bind_group) = atlas(&device, &pipeline, ATLAS_SIZE);
102 let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {117 let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
103 label: Some("Draw vertices"),118 label: Some("Draw vertices"),
...@@ -118,6 +133,7 @@ impl Renderer {...@@ -118,6 +133,7 @@ impl Renderer {
118 pipeline,133 pipeline,
119 image_pipeline,134 image_pipeline,
120 erase_pipeline,135 erase_pipeline,
136 multiply_pipeline,
121 atlas,137 atlas,
122 bind_group,138 bind_group,
123 image_sampler,139 image_sampler,
...@@ -268,6 +284,7 @@ impl Renderer {...@@ -268,6 +284,7 @@ impl Renderer {
268 Blend::Over => (&gpu.pipeline, &gpu.bind_group),284 Blend::Over => (&gpu.pipeline, &gpu.bind_group),
269 Blend::Image(id) => (&gpu.image_pipeline, &self.images[&id].texture),285 Blend::Image(id) => (&gpu.image_pipeline, &self.images[&id].texture),
270 Blend::Erase => (&gpu.erase_pipeline, &gpu.bind_group),286 Blend::Erase => (&gpu.erase_pipeline, &gpu.bind_group),
287 Blend::Multiply => (&gpu.multiply_pipeline, &gpu.bind_group),
271 };288 };
272 pass.set_pipeline(pipeline);289 pass.set_pipeline(pipeline);
273 pass.set_bind_group(0, binding, &[]);290 pass.set_bind_group(0, binding, &[]);
crates/mobile/include/snowbound.h+5-1
...@@ -16,9 +16,10 @@ typedef void (*sb_coordinator)(const char *path, bool write, void (*body)(void *...@@ -16,9 +16,10 @@ typedef void (*sb_coordinator)(const char *path, bool write, void (*body)(void *
16void sb_set_coordinator(sb_coordinator coordinator);16void sb_set_coordinator(sb_coordinator coordinator);
17void sb_set_sync_wake(void (*wake)(void));17void sb_set_sync_wake(void (*wake)(void));
18char *sb_tags(void);18char *sb_tags(void);
19char *sb_pens(void);
19bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba);20bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba);
2021
21const Library *sb_library_open(const char *path, const char *cache, char **error);22const Library *sb_library_open(const char *path, const char *cache, bool local, char **error);
22const Library *sb_library_server(const char *address, const char *share, const char *user, const char *password,23const Library *sb_library_server(const char *address, const char *share, const char *user, const char *password,
23 const char *domain, const char *root, const char *cache, char **error);24 const char *domain, const char *root, const char *cache, char **error);
24char *sb_library_sections(const Library *library);25char *sb_library_sections(const Library *library);
...@@ -28,6 +29,7 @@ char *sb_library_search(const Library *library, const Section *open, const char...@@ -28,6 +29,7 @@ char *sb_library_search(const Library *library, const Section *open, const char
28char *sb_library_tagged(const Library *library, const Section *open, const char *path);29char *sb_library_tagged(const Library *library, const Section *open, const char *path);
29char *sb_library_sync_status(const Library *library);30char *sb_library_sync_status(const Library *library);
30void sb_library_set_offline(const Library *library, bool offline);31void sb_library_set_offline(const Library *library, bool offline);
32void sb_library_touched(const Library *library, const char *path);
31void sb_library_sync_now(const Library *library);33void sb_library_sync_now(const Library *library);
32void sb_library_free(const Library *library);34void sb_library_free(const Library *library);
3335
...@@ -73,6 +75,8 @@ bool sb_view_find(View *view, const char *query);...@@ -73,6 +75,8 @@ bool sb_view_find(View *view, const char *query);
73bool sb_view_select_paragraph(View *view, const char *id);75bool sb_view_select_paragraph(View *view, const char *id);
74char *sb_view_copy(View *view, bool cut);76char *sb_view_copy(View *view, bool cut);
75void sb_view_insert_space(View *view);77void sb_view_insert_space(View *view);
78void sb_view_set_tool(View *view, uint8_t tool, uint8_t pen);
79bool sb_view_ink_selection(const View *view, float rect[4]);
76char *sb_view_paper(const View *view);80char *sb_view_paper(const View *view);
77bool sb_view_set_paper(View *view, int16_t red, uint8_t green, uint8_t blue, int8_t ruled);81bool sb_view_set_paper(View *view, int16_t red, uint8_t green, uint8_t blue, int8_t ruled);
78bool sb_view_set_art(View *view, const char *name);82bool sb_view_set_art(View *view, const char *name);
crates/mobile/src/lib.rs+76-12
...@@ -15,9 +15,9 @@ pub use library::{Library, Section, Share};...@@ -15,9 +15,9 @@ pub use library::{Library, Section, Share};
15use canvas::{15use canvas::{
16 date::DateField,16 date::DateField,
17 document::TextPosition,17 document::TextPosition,
18 editor::{Formatting, NoteTag, Selection, TextOutline, Toggle},18 editor::{FAVORITES, Formatting, NoteTag, Selection, TextOutline, Toggle},
19 gpu::{Paper, Viewport, page::PageScene},19 gpu::{Paper, Viewport, page::PageScene},
20 interaction::{Hit, ObjectFocus, PageView, Request, Response, TextColors},20 interaction::{Hit, ObjectFocus, PageView, Request, Response, TextColors, ink::Tool},
21 layout::TextEngine,21 layout::TextEngine,
22};22};
23use draw::edit::{Key, NamedKey, SelectionUnit};23use draw::edit::{Key, NamedKey, SelectionUnit};
...@@ -74,7 +74,8 @@ enum Target {...@@ -74,7 +74,8 @@ enum Target {
74 /// Another outline's text, which a touch focuses before the platform's text interaction74 /// Another outline's text, which a touch focuses before the platform's text interaction
75 /// takes over.75 /// takes over.
76 Text = 2,76 Text = 2,
77 /// The focused outline's move or width grip, or the selected picture and its handles.77 /// The focused outline's move or width grip, the selected picture and its handles, or
78 /// picked drawings.
78 Grip = 3,79 Grip = 3,
79}80}
8081
...@@ -210,10 +211,17 @@ impl Canvas {...@@ -210,10 +211,17 @@ impl Canvas {
210 }211 }
211212
212 fn target(&self, point: [f32; 2]) -> Target {213 fn target(&self, point: [f32; 2]) -> Target {
213 if self.page.inserting_space() {214 let device = self.device(point);
215 let [x, y] = self.page.viewport.document_point(device);
216 let on_ink = self
217 .page
218 .editor
219 .ink_extent(self.page.ink_selection())
220 .is_some_and(|[x0, y0, x1, y1]| (x0..=x1).contains(&x) && (y0..=y1).contains(&y));
221 if self.page.inserting_space() || on_ink {
214 return Target::Grip;222 return Target::Grip;
215 }223 }
216 match self.page.hit(self.device(point)) {224 match self.page.hit(device) {
217 Some(Hit::Handle { .. } | Hit::Resize { .. }) => Target::Grip,225 Some(Hit::Handle { .. } | Hit::Resize { .. }) => Target::Grip,
218 Some(Hit::Image { id, .. })226 Some(Hit::Image { id, .. })
219 if self.page.object_focus() == Some(ObjectFocus::Image(id)) =>227 if self.page.object_focus() == Some(ObjectFocus::Image(id)) =>
...@@ -512,17 +520,43 @@ impl Canvas {...@@ -512,17 +520,43 @@ impl Canvas {
512 Ok(moved(self.page.change_date(timestamp, text)?))520 Ok(moved(self.page.change_date(timestamp, text)?))
513 }521 }
514522
523 /// Picks what touches on the page do; see `sb_view_set_tool`.
524 fn set_tool(&mut self, tool: u8, pen: u8) -> Result<()> {
525 let tool = match tool {
526 0 => Tool::Select,
527 1 => Tool::Pen(*FAVORITES.get(usize::from(pen)).ok_or("No such pen")?),
528 2 => Tool::Eraser,
529 3 => Tool::Lasso,
530 _ => return Err("No such tool".into()),
531 };
532 let _ = self.page.set_tool(tool);
533 Ok(())
534 }
535
536 /// The frame of the drawings the lasso or a tap picked.
537 fn ink_selection(&self) -> Option<[f32; 4]> {
538 self.page
539 .editor
540 .ink_extent(self.page.ink_selection())
541 .map(|rect| self.document_rect(rect))
542 }
543
515 /// A rectangle in the active outline's coordinates, in the view's points as `[x, y,544 /// A rectangle in the active outline's coordinates, in the view's points as `[x, y,
516 /// width, height]`.545 /// width, height]`.
517 fn view_rect(&self, rect: BoundingBox) -> [f32; 4] {546 fn view_rect(&self, rect: BoundingBox) -> [f32; 4] {
547 let [x, y] = self.active().origin();
548 let [x0, y0, x1, y1] = [rect.x0, rect.y0, rect.x1, rect.y1].map(|value| value as f32);
549 self.document_rect([x0 + x, y0 + y, x1 + x, y1 + y])
550 }
551
552 /// Page rectangle `[x0, y0, x1, y1]` in the view's points as `[x, y, width, height]`.
553 fn document_rect(&self, [x0, y0, x1, y1]: [f32; 4]) -> [f32; 4] {
518 let viewport = self.page.viewport;554 let viewport = self.page.viewport;
519 let origin = self.active().origin();
520 let [x, y] = [rect.x0 as f32 + origin[0], rect.y0 as f32 + origin[1]];
521 [555 [
522 (x * viewport.scale + viewport.origin[0]) / self.scale,556 (x0 * viewport.scale + viewport.origin[0]) / self.scale,
523 (y * viewport.scale + viewport.origin[1]) / self.scale,557 (y0 * viewport.scale + viewport.origin[1]) / self.scale,
524 rect.width() as f32 * viewport.scale / self.scale,558 (x1 - x0) * viewport.scale / self.scale,
525 rect.height() as f32 * viewport.scale / self.scale,559 (y1 - y0) * viewport.scale / self.scale,
526 ]560 ]
527 }561 }
528562
...@@ -848,7 +882,7 @@ pub extern "C" fn sb_view_set_transform(view: &mut View, zoom: f32, x: f32, y: f...@@ -848,7 +882,7 @@ pub extern "C" fn sb_view_set_transform(view: &mut View, zoom: f32, x: f32, y: f
848882
849/// What the view point `x`, `y` lands on: 0 for the page, where the canvas takes a tap; 1883/// What the view point `x`, `y` lands on: 0 for the page, where the canvas takes a tap; 1
850/// for the text taking input; 2 for another outline's text; 3 for the focused outline's884/// for the text taking input; 2 for another outline's text; 3 for the focused outline's
851/// move or width grip, or the selected picture and its handles.885/// move or width grip, the selected picture and its handles, or picked drawings.
852#[unsafe(no_mangle)]886#[unsafe(no_mangle)]
853pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 {887pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 {
854 view.canvas.target([x, y]) as u8888 view.canvas.target([x, y]) as u8
...@@ -957,6 +991,36 @@ pub extern "C" fn sb_view_copy(view: &mut View, cut: bool) -> *mut c_char {...@@ -957,6 +991,36 @@ pub extern "C" fn sb_view_copy(view: &mut View, cut: bool) -> *mut c_char {
957 copied.map_or(std::ptr::null_mut(), owned)991 copied.map_or(std::ptr::null_mut(), owned)
958}992}
959993
994/// Picks what touches do, as OneNote's Draw tab does: 0 Select & Type, where a tap on a
995/// drawing picks it; 1 pen `pen` of `sb_pens`, a stroke per press, drag and release; 2 the
996/// stroke eraser; 3 the lasso. Picked drawings move with a drag and go with
997/// `sb_delete_backward`. Setting a tool, even the same one, drops a stroke under way and
998/// lets picked drawings go.
999#[unsafe(no_mangle)]
1000pub extern "C" fn sb_view_set_tool(view: &mut View, tool: u8, pen: u8) {
1001 let _ = report(view.canvas.set_tool(tool, pen));
1002}
1003
1004/// The frame of the drawings the lasso or a tap picked, as `[x, y, width, height]` in the
1005/// view's points; false with none picked.
1006#[unsafe(no_mangle)]
1007pub extern "C" fn sb_view_ink_selection(view: &View, rect: &mut [f32; 4]) -> bool {
1008 view.canvas
1009 .ink_selection()
1010 .map(|frame| *rect = frame)
1011 .is_some()
1012}
1013
1014/// OneNote 2010's favourite pens as JSON, in its gallery's order: each `[color, width,
1015/// highlighter]`, the colour sRGB or null for the paper's ink and the width in HIMETRIC.
1016#[unsafe(no_mangle)]
1017pub extern "C" fn sb_pens() -> *mut c_char {
1018 library::json(Ok(FAVORITES
1019 .iter()
1020 .map(|pen| (pen.color.map(srgb), pen.width, pen.highlighter))
1021 .collect::<Vec<_>>()))
1022}
1023
960/// Insert Space: the next drag on the page moves what lies below or right of where it1024/// Insert Space: the next drag on the page moves what lies below or right of where it
961/// starts, as far as it goes.1025/// starts, as far as it goes.
962#[unsafe(no_mangle)]1026#[unsafe(no_mangle)]
crates/mobile/src/library.rs+31-9
...@@ -7,7 +7,7 @@ use canvas::search::{Entry, Index, Query, Tagged};...@@ -7,7 +7,7 @@ use canvas::search::{Entry, Index, Query, Tagged};
7use notebook::{7use notebook::{
8 Remote, Replica,8 Remote, Replica,
9 discover::{Folder, SectionState},9 discover::{Folder, SectionState},
10 session::{self, Background, Event, Notebook, SyncStatus},10 session::{self, Background, Event, Known, Notebook, SyncStatus},
11 smb::{Client, Credentials},11 smb::{Client, Credentials},
12};12};
13use onestore::{13use onestore::{
...@@ -265,15 +265,17 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) {...@@ -265,15 +265,17 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) {
265}265}
266266
267impl Library {267impl Library {
268 pub(crate) fn open(path: &Path, cache: &Path) -> Result<Self> {268 /// The notebook folder or lone section file at `path`. A folder `local` to this device
269 /// reports every change to `touched`; any other, as a file provider keeps it, gets offline
270 /// copies of its sections and is checked every few seconds.
271 pub(crate) fn open(path: &Path, cache: &Path, local: bool) -> Result<Self> {
269 let (notebook, place, background) = if path.is_file() {272 let (notebook, place, background) = if path.is_file() {
270 (None, Place::File(path.to_owned()), None)273 (None, Place::File(path.to_owned()), None)
271 } else {274 } else {
272 let notebook = Notebook::open(path, cache)?;275 let notebook = Notebook::open(path, cache)?;
273 // Nothing here tells a file provider's folder on this device from one elsewhere.
274 let background = notebook.background_with(276 let background = notebook.background_with(
275 Background::UNWATCHED,277 local,
276 false,278 !local,
277 |file| Coordinated(file.to_owned()),279 |file| Coordinated(file.to_owned()),
278 sync_woken,280 sync_woken,
279 )?;281 )?;
...@@ -331,8 +333,10 @@ impl Library {...@@ -331,8 +333,10 @@ impl Library {
331 background.watch(333 background.watch(
332 files334 files
333 .iter()335 .iter()
334 .map(|(path, identity)| {336 .map(|(path, identity)| Known {
335 (path.clone(), Some(smb.join(format!("{identity}.sqlite"))))337 path: path.clone(),
338 replica: Some(smb.join(format!("{identity}.sqlite"))),
339 found: None,
336 })340 })
337 .collect(),341 .collect(),
338 );342 );
...@@ -467,6 +471,9 @@ impl Library {...@@ -467,6 +471,9 @@ impl Library {
467 }471 }
468 };472 };
469 section.set_offline(self.offline.load(Ordering::Relaxed));473 section.set_offline(self.offline.load(Ordering::Relaxed));
474 if let Some(background) = &self.background {
475 background.hold(path, &section);
476 }
470 Ok(section)477 Ok(section)
471 }478 }
472479
...@@ -1033,7 +1040,9 @@ fn space(text: *const c_char) -> Result<ExGuid> {...@@ -1033,7 +1040,9 @@ fn space(text: *const c_char) -> Result<ExGuid> {
1033}1040}
10341041
1035/// The notebook folder or lone section file at `path`, its section replicas kept in the1042/// The notebook folder or lone section file at `path`, its section replicas kept in the
1036/// directory `cache`; null with `error` set if it cannot be read.1043/// directory `cache`; null with `error` set if it cannot be read. A folder `local` to this
1044/// device has the host report its changes to `sb_library_touched`; any other is checked
1045/// every few seconds and kept in offline copies.
1037///1046///
1038/// # Safety1047/// # Safety
1039/// `path` and `cache` are NUL-terminated UTF-8; `error` is null or a place for a string.1048/// `path` and `cache` are NUL-terminated UTF-8; `error` is null or a place for a string.
...@@ -1041,9 +1050,10 @@ fn space(text: *const c_char) -> Result<ExGuid> {...@@ -1041,9 +1050,10 @@ fn space(text: *const c_char) -> Result<ExGuid> {
1041pub unsafe extern "C" fn sb_library_open(1050pub unsafe extern "C" fn sb_library_open(
1042 path: *const c_char,1051 path: *const c_char,
1043 cache: *const c_char,1052 cache: *const c_char,
1053 local: bool,
1044 error: *mut *mut c_char,1054 error: *mut *mut c_char,
1045) -> *const Library {1055) -> *const Library {
1046 match Library::open(Path::new(&string(path)), Path::new(&string(cache))) {1056 match Library::open(Path::new(&string(path)), Path::new(&string(cache)), local) {
1047 Ok(library) => Arc::into_raw(Arc::new(library)),1057 Ok(library) => Arc::into_raw(Arc::new(library)),
1048 Err(cause) => {1058 Err(cause) => {
1049 failed(cause, error);1059 failed(cause, error);
...@@ -1168,6 +1178,18 @@ pub extern "C" fn sb_library_set_offline(library: &Library, offline: bool) {...@@ -1168,6 +1178,18 @@ pub extern "C" fn sb_library_set_offline(library: &Library, offline: bool) {
1168 }1178 }
1169}1179}
11701180
1181/// Checks the sections at or below `path`, `/`-separated from the notebook's folder, as the
1182/// host's watch on a local folder reports them changed; `""` names the folder.
1183///
1184/// # Safety
1185/// `path` is NUL-terminated UTF-8.
1186#[unsafe(no_mangle)]
1187pub unsafe extern "C" fn sb_library_touched(library: &Library, path: *const c_char) {
1188 if let Some(background) = &library.background {
1189 background.touched(&[string(path)]);
1190 }
1191}
1192
1171/// Syncs every section of the notebook now, working offline included.1193/// Syncs every section of the notebook now, working offline included.
1172#[unsafe(no_mangle)]1194#[unsafe(no_mangle)]
1173pub extern "C" fn sb_library_sync_now(library: &Library) {1195pub extern "C" fn sb_library_sync_now(library: &Library) {
crates/mobile/src/tests.rs+116-13
...@@ -53,7 +53,7 @@ fn canvas(section: &Section, title: &str) -> Canvas {...@@ -53,7 +53,7 @@ fn canvas(section: &Section, title: &str) -> Canvas {
5353
54fn features() -> (tempfile::TempDir, Section) {54fn features() -> (tempfile::TempDir, Section) {
55 let (directory, file) = copy("media-edit/candidate/Features.one");55 let (directory, file) = copy("media-edit/candidate/Features.one");
56 let library = Arc::new(Library::open(&file, &directory.path().join("cache")).unwrap());56 let library = Arc::new(Library::open(&file, &directory.path().join("cache"), true).unwrap());
57 let section = open(&library, &file.to_string_lossy());57 let section = open(&library, &file.to_string_lossy());
58 (directory, section)58 (directory, section)
59}59}
...@@ -82,8 +82,12 @@ fn focus(canvas: &mut Canvas, text: &str) {...@@ -82,8 +82,12 @@ fn focus(canvas: &mut Canvas, text: &str) {
82#[test]82#[test]
83fn a_notebook_folder_lists_sections_in_order_with_groups_and_colours() {83fn a_notebook_folder_lists_sections_in_order_with_groups_and_colours() {
84 let directory = tempfile::tempdir().unwrap();84 let directory = tempfile::tempdir().unwrap();
85 let library =85 let library = Library::open(
86 Library::open(&corpus("m6/native-features-01/notebook"), directory.path()).unwrap();86 &corpus("m6/native-features-01/notebook"),
87 directory.path(),
88 true,
89 )
90 .unwrap();
87 let tabs = library.tabs().unwrap();91 let tabs = library.tabs().unwrap();
88 let names: Vec<_> = tabs92 let names: Vec<_> = tabs
89 .iter()93 .iter()
...@@ -94,6 +98,7 @@ fn a_notebook_folder_lists_sections_in_order_with_groups_and_colours() {...@@ -94,6 +98,7 @@ fn a_notebook_folder_lists_sections_in_order_with_groups_and_colours() {
94 let lone = Library::open(98 let lone = Library::open(
95 &corpus("media-edit/candidate/Features.one"),99 &corpus("media-edit/candidate/Features.one"),
96 directory.path(),100 directory.path(),
101 true,
97 )102 )
98 .unwrap();103 .unwrap();
99 assert_eq!(lone.tabs().unwrap().len(), 1);104 assert_eq!(lone.tabs().unwrap().len(), 1);
...@@ -255,7 +260,7 @@ fn stored_text(file: &Path, space: ExGuid) -> String {...@@ -255,7 +260,7 @@ fn stored_text(file: &Path, space: ExGuid) -> String {
255fn typing_is_stored_in_the_cache_and_published_to_the_file() {260fn typing_is_stored_in_the_cache_and_published_to_the_file() {
256 let (directory, file) = copy("media-edit/candidate/Features.one");261 let (directory, file) = copy("media-edit/candidate/Features.one");
257 let cache = directory.path().join("cache");262 let cache = directory.path().join("cache");
258 let library = Arc::new(Library::open(&file, &cache).unwrap());263 let library = Arc::new(Library::open(&file, &cache, true).unwrap());
259 let section = open(&library, &file.to_string_lossy());264 let section = open(&library, &file.to_string_lossy());
260 let mut canvas = canvas(&section, "Paragraph controls");265 let mut canvas = canvas(&section, "Paragraph controls");
261 focus(&mut canvas, "Collapsed parent");266 focus(&mut canvas, "Collapsed parent");
...@@ -285,7 +290,7 @@ fn typing_is_stored_in_the_cache_and_published_to_the_file() {...@@ -285,7 +290,7 @@ fn typing_is_stored_in_the_cache_and_published_to_the_file() {
285#[test]290#[test]
286fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin() {291fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin() {
287 let (directory, root) = copy("m6/native-features-01/notebook");292 let (directory, root) = copy("m6/native-features-01/notebook");
288 let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap());293 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
289 let section = open(&library, "Features.one");294 let section = open(&library, "Features.one");
290 let first = section.rows().unwrap()[0].id.parse().unwrap();295 let first = section.rows().unwrap()[0].id.parse().unwrap();
291 let page = section296 let page = section
...@@ -338,7 +343,7 @@ fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin(...@@ -338,7 +343,7 @@ fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin(
338#[test]343#[test]
339fn conflict_pages_list_under_their_page_and_take_no_edits() {344fn conflict_pages_list_under_their_page_and_take_no_edits() {
340 let (directory, root) = copy("conflict-page/candidate");345 let (directory, root) = copy("conflict-page/candidate");
341 let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap());346 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
342 let section = open(&library, "synthetic.one");347 let section = open(&library, "synthetic.one");
343 let rows = section.rows().unwrap();348 let rows = section.rows().unwrap();
344 let version = rows349 let version = rows
...@@ -362,8 +367,12 @@ fn conflict_pages_list_under_their_page_and_take_no_edits() {...@@ -362,8 +367,12 @@ fn conflict_pages_list_under_their_page_and_take_no_edits() {
362#[test]367#[test]
363fn search_finds_pages_by_title_and_text_across_sections() {368fn search_finds_pages_by_title_and_text_across_sections() {
364 let directory = tempfile::tempdir().unwrap();369 let directory = tempfile::tempdir().unwrap();
365 let library =370 let library = Library::open(
366 Library::open(&corpus("m6/native-features-01/notebook"), directory.path()).unwrap();371 &corpus("m6/native-features-01/notebook"),
372 directory.path(),
373 true,
374 )
375 .unwrap();
367 let found = library.search(None, "collapsed PARENT").unwrap();376 let found = library.search(None, "collapsed PARENT").unwrap();
368 assert!(!found.is_empty());377 assert!(!found.is_empty());
369 assert!(found[0].snippet.to_lowercase().contains("collapsed parent"));378 assert!(found[0].snippet.to_lowercase().contains("collapsed parent"));
...@@ -446,7 +455,7 @@ fn a_picture_goes_after_the_caret_paragraph_and_undoes() {...@@ -446,7 +455,7 @@ fn a_picture_goes_after_the_caret_paragraph_and_undoes() {
446#[test]455#[test]
447fn to_do_marks_a_new_pages_body() {456fn to_do_marks_a_new_pages_body() {
448 let (directory, root) = copy("m6/native-features-01/notebook");457 let (directory, root) = copy("m6/native-features-01/notebook");
449 let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap());458 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
450 let section = open(&library, "Features.one");459 let section = open(&library, "Features.one");
451 let page = section460 let page = section
452 .new_page(None, "Sunday, September 27, 2026", "3:04 AM")461 .new_page(None, "Sunday, September 27, 2026", "3:04 AM")
...@@ -571,7 +580,7 @@ extern "C" fn coordinator(...@@ -571,7 +580,7 @@ extern "C" fn coordinator(
571fn a_local_sections_reads_and_publications_go_through_the_hosts_coordination() {580fn a_local_sections_reads_and_publications_go_through_the_hosts_coordination() {
572 library::sb_set_coordinator(coordinator);581 library::sb_set_coordinator(coordinator);
573 let (directory, root) = copy("m6/native-features-01/notebook");582 let (directory, root) = copy("m6/native-features-01/notebook");
574 let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap());583 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
575 let section = open(&library, "Features.one");584 let section = open(&library, "Features.one");
576 let mut canvas = canvas(&section, "Paragraph controls");585 let mut canvas = canvas(&section, "Paragraph controls");
577 focus(&mut canvas, "Collapsed parent");586 focus(&mut canvas, "Collapsed parent");
...@@ -640,7 +649,7 @@ fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() {...@@ -640,7 +649,7 @@ fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() {
640/// A notebook copy with its library, and a section of it open for editing.649/// A notebook copy with its library, and a section of it open for editing.
641fn notebook_open(path: &str) -> (tempfile::TempDir, PathBuf, Arc<Library>, Section) {650fn notebook_open(path: &str) -> (tempfile::TempDir, PathBuf, Arc<Library>, Section) {
642 let (directory, root) = copy("m6/native-features-01/notebook");651 let (directory, root) = copy("m6/native-features-01/notebook");
643 let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap());652 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
644 let section = open(&library, path);653 let section = open(&library, path);
645 (directory, root, library, section)654 (directory, root, library, section)
646}655}
...@@ -703,7 +712,7 @@ fn sync_status_lists_every_section_and_work_offline_holds_edits_until_sync_now()...@@ -703,7 +712,7 @@ fn sync_status_lists_every_section_and_work_offline_holds_edits_until_sync_now()
703#[test]712#[test]
704fn sections_open_the_first_time_while_the_background_syncs() {713fn sections_open_the_first_time_while_the_background_syncs() {
705 let (directory, root) = copy("m6/native-features-01/notebook");714 let (directory, root) = copy("m6/native-features-01/notebook");
706 let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap());715 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
707 let rounds = {716 let rounds = {
708 let library = Arc::clone(&library);717 let library = Arc::clone(&library);
709 std::thread::spawn(move || {718 std::thread::spawn(move || {
...@@ -735,7 +744,7 @@ fn sections_open_the_first_time_while_the_background_syncs() {...@@ -735,7 +744,7 @@ fn sections_open_the_first_time_while_the_background_syncs() {
735#[test]744#[test]
736fn the_tags_summary_lists_tagged_paragraphs_and_opens_on_them() {745fn the_tags_summary_lists_tagged_paragraphs_and_opens_on_them() {
737 let (directory, file) = copy("structural-probe/tag-gallery.one");746 let (directory, file) = copy("structural-probe/tag-gallery.one");
738 let library = Arc::new(Library::open(&file, &directory.path().join("cache")).unwrap());747 let library = Arc::new(Library::open(&file, &directory.path().join("cache"), true).unwrap());
739 let path = file.to_string_lossy().into_owned();748 let path = file.to_string_lossy().into_owned();
740 let section = open(&library, &path);749 let section = open(&library, &path);
741 let tagged = library.tagged(Some((&path, &section))).unwrap();750 let tagged = library.tagged(Some((&path, &section))).unwrap();
...@@ -825,3 +834,97 @@ fn insert_space_turns_the_next_touch_into_a_drag() {...@@ -825,3 +834,97 @@ fn insert_space_turns_the_next_touch_into_a_drag() {
825 let _ = canvas.page.insert_space();834 let _ = canvas.page.insert_space();
826 assert_eq!(canvas.target([300.0, 1500.0]), Target::Grip);835 assert_eq!(canvas.target([300.0, 1500.0]), Target::Grip);
827}836}
837
838/// The page's drawings as the section stores them.
839fn stored_ink(section: &Section, space: ExGuid) -> Vec<onestore::page::Ink> {
840 let (page, _) = section.shared.page(space).unwrap();
841 page.objects
842 .into_iter()
843 .filter_map(|object| match object {
844 onestore::page::PageObject::Ink(ink) => Some(ink),
845 _ => None,
846 })
847 .collect()
848}
849
850/// A press, drags through `points` and a release, and the edit it made stored.
851fn trace(canvas: &mut Canvas, section: &Section, points: &[[f32; 2]]) {
852 canvas.press(points[0]).unwrap();
853 for &point in &points[1..] {
854 canvas.drag(point).unwrap();
855 }
856 canvas.release().unwrap();
857 if let Some(edit) = canvas.edit().unwrap() {
858 section.shared.apply(edit).unwrap();
859 }
860}
861
862fn line(from: [f32; 2], to: [f32; 2]) -> Vec<[f32; 2]> {
863 (0..=20)
864 .map(|step| {
865 let t = step as f32 / 20.0;
866 [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t]
867 })
868 .collect()
869}
870
871#[test]
872fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() {
873 let (_directory, section) = features();
874 let mut canvas = canvas(&section, "Paragraph controls");
875 let space = canvas.space;
876 let before = stored_ink(&section, space).len();
877 // The red pen, second in OneNote's gallery.
878 canvas.set_tool(1, 1).unwrap();
879 trace(&mut canvas, &section, &line([100.0, 600.0], [300.0, 660.0]));
880 let drawn = stored_ink(&section, space);
881 assert_eq!(drawn.len(), before + 1);
882 let stroke = &drawn.last().unwrap().strokes[0];
883 assert_eq!(stroke.color, Some(0x241ced));
884 assert!(stroke.points.len() > 2);
885 // A highlighter stroke is a drawing of its own.
886 canvas.set_tool(1, 5).unwrap();
887 trace(&mut canvas, &section, &line([100.0, 760.0], [300.0, 760.0]));
888 assert_eq!(stored_ink(&section, space).len(), before + 2);
889 assert!(stored_ink(&section, space).last().unwrap().strokes[0].raster_operation == Some(9));
890
891 // The lasso picks the pen stroke; a touch on it then drags it.
892 canvas.set_tool(3, 0).unwrap();
893 let lasso = [
894 [80.0, 580.0],
895 [320.0, 580.0],
896 [320.0, 700.0],
897 [80.0, 700.0],
898 [80.0, 582.0],
899 ];
900 trace(&mut canvas, &section, &lasso);
901 let frame = canvas.ink_selection().unwrap();
902 assert!(frame[2] > 150.0 && frame[3] > 40.0, "{frame:?}");
903 let on = [200.0, 630.0];
904 assert_eq!(canvas.target(on), Target::Grip);
905 trace(&mut canvas, &section, &[on, [230.0, 645.0], [260.0, 660.0]]);
906 let moved = stored_ink(&section, space);
907 let layout = &moved[before].layout;
908 assert!(layout.x.is_some_and(|x| x > 0.0), "{layout:?}");
909 let _ = canvas
910 .page
911 .key(&Key::Named(NamedKey::Backspace), None)
912 .unwrap();
913 section
914 .shared
915 .apply(canvas.edit().unwrap().unwrap())
916 .unwrap();
917 assert_eq!(stored_ink(&section, space).len(), before + 1);
918 assert!(canvas.ink_selection().is_none());
919
920 // The eraser takes the highlighter's stroke it crosses; undo brings it back.
921 canvas.set_tool(2, 0).unwrap();
922 trace(&mut canvas, &section, &line([200.0, 720.0], [200.0, 800.0]));
923 assert_eq!(stored_ink(&section, space).len(), before);
924 let _ = canvas.page.undo(false).unwrap();
925 section
926 .shared
927 .apply(canvas.edit().unwrap().unwrap())
928 .unwrap();
929 assert_eq!(stored_ink(&section, space).len(), before + 1);
930}
crates/notebook/README.md+30-16
...@@ -49,7 +49,9 @@ thread rereads the queue....@@ -49,7 +49,9 @@ thread rereads the queue.
4949
50## Sessions50## Sessions
5151
52`session::Notebook::open(root, cache_dir)` discovers a notebook directory and52`session::Notebook::open(root, cache_dir)` discovers a notebook directory, reading only
53the files its folders list otherwise than when last opened (`discover::Cache`, kept under
54`cache_dir/listings`), and
53`session::Section::open(file, cache_dir, notify)` opens one section file through55`session::Section::open(file, cache_dir, notify)` opens one section file through
54a replica named by the section's document identity, so the same file reopens the56a replica named by the section's document identity, so the same file reopens the
55same queue after a relaunch. A section is `Send + Sync`: `apply(author, edit)`57same queue after a relaunch. A section is `Send + Sync`: `apply(author, edit)`
...@@ -66,20 +68,25 @@ worker stops connecting and edits queue until `wake()` (Sync Now) or `set_offlin...@@ -66,20 +68,25 @@ worker stops connecting and edits queue until `wake()` (Sync Now) or `set_offlin
6668
67`session::Background` keeps the sections no session holds in sync, as OneNote 2010 keeps69`session::Background` keeps the sections no session holds in sync, as OneNote 2010 keeps
68every section of an open notebook: `Background::smb(root, limit, connect, notify)` on a70every section of an open notebook: `Background::smb(root, limit, connect, notify)` on a
69share, `Notebook::background(interval, copies, notify)` for a mounted notebook, then71share, `Notebook::background(watched, copies, notify)` for a mounted notebook, then
70`watch(notebook.replicas())`. On a share, one CHANGE_NOTIFY on the notebook's folder72`watch(notebook.replicas())`. On a share, one CHANGE_NOTIFY on the notebook's folder
71(`smb::Client::watch`) reports what changed; a host watching a mounted folder passes the73(`smb::Client::watch`) reports what changed; a host watching a mounted folder passes the
72changed paths to `touched`. A reported section is checked a second later, a failing one74changed paths to `touched`, and says whether it reports every change as `watched`. A
73every 31 seconds, and any other every `interval` (`Background::BACKSTOP` while a watch75reported section is checked a second later; a reported folder is listed a second later and
74reports). Newly watched sections, and every section after a reconnect, are checked one at a76only its sections listed otherwise are checked. A failing section is tried every 31 seconds,
75time 100 ms apart. A check reads the file's stamp; a section whose replica has edits waiting,77and any other every `Background::BACKSTOP` while a watch reports, `UNWATCHED` otherwise. On
76or whose file moved past the replica's base, has the replica opened for the synchronization78connecting, and on working online again, every folder is listed: a section whose file lists as
77steps that publish or rebase it and closed again, and a section without a replica gets one79discovery or the last check found it needs no reading, the others are checked one at a time
78from the file, its offline copy, on a share or with `copies`. A replica a session holds is80100 ms apart. A check reads the file's stamp; a section whose replica has edits waiting, or
79skipped (`Error::busy`), so opening a section may wait out one step. `status()` gives each81whose file moved past the replica's base, has the replica opened for the synchronization steps
80section's `SyncStatus`, `changed()` the sections another client changed, `wake` and82that publish or rebase it and closed again, and a section without a replica gets one from the
81`set_offline` follow Sync Now and Work Offline, and `discard` stops the thread and deletes the83file, its offline copy, on a share or with `copies`. `hold(path, section)` leaves an open
82replicas holding nothing unpublished, as when the notebook closes.84section to its session: its worker no longer polls while a watch reports, the watch wakes it
85instead, and once its replica is released the background checks the file at once. A replica
86a session holds is otherwise skipped (`Error::busy`), so opening a section may wait out one
87step. `status()` gives each section's `SyncStatus`, `changed()` the sections another client
88changed, `wake` and `set_offline` follow Sync Now and Work Offline, and `discard` stops the
89thread and deletes the replicas holding nothing unpublished, as when the notebook closes.
83`Notebook::replica_path(path)` names a section's replica for either kind of notebook.90`Notebook::replica_path(path)` names a section's replica for either kind of notebook.
8491
85`Section::resume(file, replica, notify)` starts from an owned `Replica` without92`Section::resume(file, replica, notify)` starts from an owned `Replica` without
...@@ -130,7 +137,8 @@ An `Arc<Replica>` can own one background worker. Supply a connection factory, po...@@ -130,7 +137,8 @@ An `Arc<Replica>` can own one background worker. Supply a connection factory, po
130interval and observer; successful publications drain immediately, durable local137interval and observer; successful publications drain immediately, durable local
131edits wake the worker, and `wake()` requests an immediate reachability retry.138edits wake the worker, and `wake()` requests an immediate reachability retry.
132While nothing is queued, or the queue waits on a remote that has not changed since,139While nothing is queued, or the queue waits on a remote that has not changed since,
133a remote whose `Remote::stamp` holds is not read again.140a remote whose `Remote::stamp` holds is not read again; a worker a `Background` holds reads
141the stamp only when the notebook's watch wakes it.
134Transport failures discard the old connection and retry through the factory;142Transport failures discard the old connection and retry through the factory;
135Read contention and `NotCommitted` operations with `WouldBlock` or `ResourceBusy`143Read contention and `NotCommitted` operations with `WouldBlock` or `ResourceBusy`
136reuse the connection. Contended `NotCommitted` operations use randomized backoff,144reuse the connection. Contended `NotCommitted` operations use randomized backoff,
...@@ -252,6 +260,12 @@ another client holds delete-pending reads as denied) is kept as `unavailable` an...@@ -252,6 +260,12 @@ another client holds delete-pending reads as denied) is kept as `unavailable` an
252tried again on the next discovery; the root itself, malformed storage, other failed260tried again on the next discovery; the root itself, malformed storage, other failed
253reads and ambiguous identities reject the discovery.261reads and ambiguous identities reject the discovery.
254262
263`Cache::discover` keeps each file's listing (size and last write time), stamp and what
264discovery took from it; the next discovery reads only the files listed otherwise, taking one
265from a copy where `Source::copy` has it as it stands (on a share, a replica whose base has the
266file's stamp), and `found(path)` gives a section's listing and stamp for `Background::watch`. A file written
267during discovery is read again next time; a failed discovery leaves the cache as it was.
268
255Each file read must be a consistent, bounded snapshot. The result is an269Each file read must be a consistent, bounded snapshot. The result is an
256observation across multiple files, not an atomic notebook transaction or270observation across multiple files, not an atomic notebook transaction or
257authorization to publish an edit. Refresh rejects observed topology changes and271authorization to publish an edit. Refresh rejects observed topology changes and
...@@ -374,8 +388,8 @@ before retrying it. The transport does not automatically replay requests....@@ -374,8 +388,8 @@ before retrying it. The transport does not automatically replay requests.
374`Client::read_dir(path, entry_limit)` enumerates a directory, including the share388`Client::read_dir(path, entry_limit)` enumerates a directory, including the share
375root with an empty path. It follows every response page and returns no partial389root with an empty path. It follows every response page and returns no partial
376list on interruption, entry-limit overflow or close failure. Entries retain exact390list on interruption, entry-limit overflow or close failure. Entries retain exact
377Unicode names, observed sizes and MS-FSCC attributes, including directory/reparse391Unicode names, observed sizes, last write times and MS-FSCC attributes, including
378flags. Concurrent directory changes are not an atomic snapshot; repeated names392directory/reparse flags. Concurrent directory changes are not an atomic snapshot; repeated names
379are rejected with `ResourceBusy`. Notebook identities come from the files, not393are rejected with `ResourceBusy`. Notebook identities come from the files, not
380directory names or sizes. Missing paths, denied access and non-directory paths394directory names or sizes. Missing paths, denied access and non-directory paths
381have distinct I/O error kinds.395have distinct I/O error kinds.
crates/notebook/examples/launch_probe.rs deleted-228
...@@ -1,228 +0,0 @@
1//! Temporary: measures a notebook's launch (catalog and background first pass).
2//! `generate DIR N` | `local DIR CACHE` | `upload ADDRESS SHARE DIR ROOT` |
3//! `smb ADDRESS SHARE ROOT CACHE [SECONDS]`
4
5use notebook::{
6 discover::{Entry, Limits, Local, Source, discover},
7 session::{Background, Notebook},
8};
9use onestore::{
10 document::{Format, Layout},
11 op::{Edit, Op, SectionOp},
12 page::{
13 Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject,
14 text::new_id,
15 },
16};
17use std::{
18 io,
19 path::Path,
20 time::{Duration, Instant},
21};
22
23type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
24
25struct Counting<S>(S, u64, u64);
26
27impl<S: Source> Source for Counting<S> {
28 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> {
29 self.2 += 1;
30 self.0.entries(path, limit)
31 }
32 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
33 let bytes = self.0.read(path, limit)?;
34 self.1 += bytes.len() as u64;
35 Ok(bytes)
36 }
37}
38
39const LIMITS: Limits = Limits {
40 entries: 100_000,
41 bytes_per_file: 256 * 1024 * 1024,
42 depth: 64,
43};
44
45fn page(count: usize) -> Result<Page> {
46 let paragraph = |i: usize| PageParagraph {
47 id: new_id().unwrap(),
48 parent: None,
49 level: 1,
50 style: None,
51 format: Format::default(),
52 content: ParagraphContent::Text(TextObject {
53 id: new_id().unwrap(),
54 date_field: None,
55 text: Paragraph::new(
56 format!("Paragraph {i} of the launch probe, with some words."),
57 Format::default(),
58 ),
59 tags: Vec::new(),
60 }),
61 lists: Vec::new(),
62 tags: Vec::new(),
63 media: Default::default(),
64 collapsed: false,
65 };
66 Ok(Page {
67 title: "Probe".into(),
68 identity: None,
69 created: None,
70 margin_origin: [0.0; 2],
71 color: None,
72 rule_lines: None,
73 objects: vec![PageObject::Outline(Outline {
74 id: new_id()?,
75 title: false,
76 min_width: None,
77 layout: Layout {
78 x: Some(36.0),
79 y: Some(86.4),
80 ..Layout::default()
81 },
82 indents: Vec::new(),
83 paragraphs: (0..count).map(paragraph).collect(),
84 unsupported: Vec::new(),
85 })],
86 definitions: Default::default(),
87 })
88}
89
90fn generate(root: &Path, count: usize) -> Result<()> {
91 let cache = tempfile::tempdir()?;
92 let creation = onestore::PageCreation::new(None, Some("First"), "Probe")?;
93 let mut notebook = Notebook::create(root, cache.path(), 0x00e4a88a, &creation)?;
94 notebook.create_group("", "Group A")?;
95 notebook.create_group("", "Group B")?;
96 for n in 0..count {
97 let folder = ["", "Group A", "Group B"][n % 3];
98 let path = notebook.create_section(folder, &format!("Section {n:03}"), &creation)?;
99 let file = root.join(&path);
100 let arena = onestore::Arena::default();
101 let mut section = onestore::Section::open(&arena, std::fs::read(&file)?)?;
102 let creation = onestore::PageCreation::new(None, Some("Probe"), "Probe")?;
103 section.apply(
104 "Probe",
105 &Edit {
106 at: 133_000_000_000_000_000,
107 ops: vec![Op::Section(SectionOp::Import {
108 creation,
109 page: page(1200)?,
110 })],
111 },
112 )?;
113 let transaction = section.seal()?.ok_or("nothing sealed")?;
114 transaction.commit_file(&file)?;
115 }
116 Ok(())
117}
118
119fn size(root: &Path) -> u64 {
120 std::fs::read_dir(root)
121 .into_iter()
122 .flatten()
123 .flatten()
124 .map(|entry| match entry.file_type() {
125 Ok(kind) if kind.is_dir() => size(&entry.path()),
126 _ => entry.metadata().map_or(0, |metadata| metadata.len()),
127 })
128 .sum()
129}
130
131#[cfg(feature = "smb")]
132fn client(address: &str, share: &str) -> Result<notebook::smb::Client> {
133 Ok(notebook::smb::Client::connect(
134 address,
135 share,
136 notebook::smb::Credentials {
137 username: "",
138 password: "",
139 domain: "",
140 },
141 Duration::from_secs(10),
142 )?)
143}
144
145#[cfg(feature = "smb")]
146fn upload(client: &notebook::smb::Client, local: &Path, remote: &str) -> Result<()> {
147 client.create_directory(remote)?;
148 for entry in std::fs::read_dir(local)? {
149 let entry = entry?;
150 let name = entry.file_name().into_string().map_err(|_| "name")?;
151 let target = format!("{remote}/{name}");
152 if entry.file_type()?.is_dir() {
153 upload(client, &entry.path(), &target)?;
154 } else {
155 client.create(&target, &std::fs::read(entry.path())?)?;
156 }
157 }
158 Ok(())
159}
160
161fn main() -> Result<()> {
162 let args: Vec<String> = std::env::args().skip(1).collect();
163 match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
164 ["generate", root, count] => {
165 generate(Path::new(root), count.parse()?)?;
166 println!("{} bytes", size(Path::new(root)));
167 }
168 ["local", root, cache] => {
169 let start = Instant::now();
170 let notebook = Notebook::open(root, cache)?;
171 let replicas = notebook.replicas();
172 println!(
173 "open: {:?}, {} sections",
174 start.elapsed(),
175 replicas.len()
176 );
177 let mut counting = Counting(Local::open(root)?, 0, 0);
178 discover(&mut counting, LIMITS)?;
179 println!("discovery reads {} bytes, {} listings", counting.1, counting.2);
180 }
181 #[cfg(feature = "smb")]
182 ["upload", address, share, local, root] => {
183 upload(&client(address, share)?, Path::new(local), root)?;
184 }
185 #[cfg(feature = "smb")]
186 ["smb", address, share, root, cache, rest @ ..] => {
187 let seconds: u64 = rest.first().map_or(Ok(0), |s| s.parse())?;
188 let start = Instant::now();
189 let connected = client(address, share)?;
190 let notebook = Notebook::open_smb(std::sync::Arc::new(connected), root, cache)?;
191 let replicas = notebook.replicas();
192 println!(
193 "open: {:?}, {} sections",
194 start.elapsed(),
195 replicas.len()
196 );
197 if seconds > 0 {
198 let (address, share) = (address.to_string(), share.to_string());
199 let background = Background::smb(
200 root,
201 256 * 1024 * 1024,
202 move || {
203 client(&address, &share)
204 .map_err(|error| io::Error::other(error.to_string()))
205 },
206 || {},
207 )?;
208 background.watch(replicas);
209 let start = Instant::now();
210 loop {
211 let status = background.status();
212 if status.iter().all(|(_, status)| status.synced.is_some()) {
213 println!("background first pass: {:?}", start.elapsed());
214 break;
215 }
216 if start.elapsed() > Duration::from_secs(seconds) {
217 println!("background still going after {seconds} s");
218 break;
219 }
220 std::thread::sleep(Duration::from_millis(50));
221 }
222 std::thread::sleep(Duration::from_secs(2));
223 }
224 }
225 _ => return Err("usage".into()),
226 }
227 Ok(())
228}
crates/notebook/src/background.rs+688-139
...@@ -4,11 +4,13 @@...@@ -4,11 +4,13 @@
44
5use crate::{5use crate::{
6 EditStatus, Error, Remote, Replica, Result,6 EditStatus, Error, Remote, Replica, Result,
7 session::{SyncStatus, reached},7 discover::{Entry, Listed},
8 session::{Section, SyncStatus, reached},
8 worker::Signal,9 worker::Signal,
9};10};
10use onestore::Stamp;11use onestore::Stamp;
11use std::{12use std::{
13 collections::{BTreeMap, BTreeSet},
12 io,14 io,
13 path::{Path, PathBuf},15 path::{Path, PathBuf},
14 sync::{16 sync::{
...@@ -28,16 +30,30 @@ const STAGGER: Duration = Duration::from_millis(100);...@@ -28,16 +30,30 @@ const STAGGER: Duration = Duration::from_millis(100);
28/// How long a reported change settles before its section is checked, so the writes of one30/// How long a reported change settles before its section is checked, so the writes of one
29/// commit cost one check.31/// commit cost one check.
30const SETTLE: Duration = Duration::from_secs(1);32const SETTLE: Duration = Duration::from_secs(1);
33/// The most entries a folder listing takes.
34const ENTRIES: usize = 100_000;
3135
32/// Keeps each watched section in sync: a section is checked when a watch on the notebook's36/// Keeps each watched section in sync: a section is checked when a watch on the notebook's
33/// folder reports it changed, when it could not be reached, and otherwise every interval.37/// folder reports it changed, when it could not be reached, and otherwise every interval.
34/// A check costs one stamp read; a section whose replica has edits waiting, or whose file38/// On connecting, and when a watch reports a folder rather than a file, the folders are
35/// moved past the replica's base, has its replica opened for the synchronization steps that39/// listed and only the sections listed otherwise than before are checked, as OneNote 2010
36/// publish or rebase it, then closed again. A section without a replica gets one, its offline40/// reopens a notebook. A check costs one stamp read; a section whose replica has edits waiting,
37/// copy, where the notebook keeps them. A replica a session holds is left to that session's41/// or whose file moved past the replica's base, has its replica opened for the synchronization
38/// worker. Dropping requests cancellation without waiting for the step in flight.42/// steps that publish or rebase it, then closed again. A section without a replica gets one,
43/// its offline copy, where the notebook keeps them. A section a session holds is left to that
44/// session's worker, which the watch wakes. Dropping requests cancellation without waiting for
45/// the step in flight.
39pub struct Background(Arc<Shared>);46pub struct Background(Arc<Shared>);
4047
48/// A section for `Background::watch`, as its notebook knows it.
49pub struct Known {
50 /// The catalog path.
51 pub path: String,
52 pub replica: Option<PathBuf>,
53 /// How the notebook's discovery last found the file: its listing, and its stamp then.
54 pub found: Option<(Listed, Stamp)>,
55}
56
41/// What the background thread and whatever reports changes to it share.57/// What the background thread and whatever reports changes to it share.
42pub(crate) struct Shared {58pub(crate) struct Shared {
43 signal: Arc<Signal>,59 signal: Arc<Signal>,
...@@ -57,20 +73,35 @@ struct Watched {...@@ -57,20 +73,35 @@ struct Watched {
57 sections: Vec<Watch>,73 sections: Vec<Watch>,
58 /// Sections whose file changed since `changed` was last asked.74 /// Sections whose file changed since `changed` was last asked.
59 changed: Vec<String>,75 changed: Vec<String>,
76 /// Folders a watch reported without naming the file, listed at `relist`.
77 folders: BTreeSet<String>,
78 relist: Option<Instant>,
60 /// Counts connections, so a watch that ended with an earlier one is not this one's.79 /// Counts connections, so a watch that ended with an earlier one is not this one's.
61 connection: u64,80 connection: u64,
62 /// The current connection's watch ended: changes since went unreported.81 /// The current connection's watch ended, or none was connected: changes since went
82 /// unreported.
63 lost: bool,83 lost: bool,
84 /// The current connection's watch reports the folder's changes.
85 reported: bool,
64}86}
6587
66struct Watch {88struct Watch {
67 path: String,89 path: String,
68 replica: Option<PathBuf>,90 replica: Option<PathBuf>,
69 /// The file's stamp when last reached.91 /// The file's stamp when last read.
70 stamp: Option<Stamp>,92 stamp: Option<Stamp>,
93 /// How the file was listed when its folder was last listed; the stamp was read since.
94 listed: Option<Listed>,
95 /// The folder's listing since `stamp` shows the file unchanged, so the next check need not
96 /// read it.
97 current: bool,
98 /// A watch reported the file changed since its folder's listing began.
99 reported: bool,
71 status: SyncStatus,100 status: SyncStatus,
72 /// When the section is next checked.101 /// When the section is next checked.
73 due: Instant,102 due: Instant,
103 /// The worker of the session that holds the section.
104 held: Option<Weak<Signal>>,
74}105}
75106
76impl Background {107impl Background {
...@@ -80,18 +111,19 @@ impl Background {...@@ -80,18 +111,19 @@ impl Background {
80 /// How often each section is checked where nothing reports its folder's changes.111 /// How often each section is checked where nothing reports its folder's changes.
81 pub const UNWATCHED: Duration = Duration::from_secs(15);112 pub const UNWATCHED: Duration = Duration::from_secs(15);
82113
83 /// Starts the thread. `connect` binds each catalog path to its file, arming any watch114 /// Starts the thread. `connect` answers how the connection reaches each section file and
84 /// that reports the folder's changes through `Reports`, and answers how long a section115 /// lists each folder, both by catalog path, arming any watch that reports the folder's
85 /// may go unchecked while nothing reports it; it runs again after a transport failure.116 /// changes through `Reports`, and whether one does; it runs again after a transport
86 /// `copies` keeps an offline copy of every section.117 /// failure. `copies` keeps an offline copy of every section.
87 pub(crate) fn start<R, B>(118 pub(crate) fn start<R, B, L>(
88 copies: bool,119 copies: bool,
89 mut connect: impl FnMut(Reports) -> io::Result<(B, Duration)> + Send + 'static,120 mut connect: impl FnMut(Reports) -> io::Result<((B, L), bool)> + Send + 'static,
90 notify: impl Fn() + Send + 'static,121 notify: impl Fn() + Send + 'static,
91 ) -> Result<Self>122 ) -> Result<Self>
92 where123 where
93 R: Remote,124 R: Remote,
94 B: FnMut(&str) -> R,125 B: FnMut(&str) -> R,
126 L: FnMut(&str) -> io::Result<Vec<Entry>>,
95 {127 {
96 let (signal, receiver) = Signal::new();128 let (signal, receiver) = Signal::new();
97 let shared = Arc::new(Shared {129 let shared = Arc::new(Shared {
...@@ -105,51 +137,48 @@ impl Background {...@@ -105,51 +137,48 @@ impl Background {
105 .name("onestore-background".into())137 .name("onestore-background".into())
106 .spawn(move || {138 .spawn(move || {
107 let signal = &owner.signal;139 let signal = &owner.signal;
108 let mut bound: Option<(B, Duration)> = None;140 let mut bound: Option<(B, L)> = None;
109 let mut news = false;141 let mut news = false;
110 while !signal.stopped.load(Ordering::Acquire) {142 while !signal.stopped.load(Ordering::Acquire) {
111 if signal.offline.load(Ordering::Acquire)143 if signal.offline.load(Ordering::Acquire)
112 && !signal.requested.load(Ordering::Acquire)144 && !signal.requested.load(Ordering::Acquire)
113 {145 {
146 // Working online again lists every folder again.
147 if let Ok(mut watched) = owner.watched.lock() {
148 watched.watching(false);
149 watched.lost = true;
150 }
114 bound = None;151 bound = None;
115 let _ = receiver.recv();152 let _ = receiver.recv();
116 continue;153 continue;
117 }154 }
118 let now = Instant::now();155 let now = Instant::now();
119 let (path, replica, seen, connection) = {156 let next = {
120 let Ok(mut watched) = owner.watched.lock() else {157 let Ok(mut watched) = owner.watched.lock() else {
121 return;158 return;
122 };159 };
123 if std::mem::take(&mut watched.lost) {160 if std::mem::take(&mut watched.lost) {
124 bound = None;161 bound = None;
125 watched.rescan(now);162 watched.watching(false);
126 }163 for watch in &mut watched.sections {
127 let due = watched.next().map(|index| &watched.sections[index]);164 watch.due = now;
128 match due.filter(|watch| watch.due <= now) {
129 Some(watch) => (
130 watch.path.clone(),
131 watch.replica.clone(),
132 watch.stamp.clone(),
133 watched.connection,
134 ),
135 None => {
136 let wait = due.map(|watch| watch.due - now);
137 drop(watched);
138 signal.requested.store(false, Ordering::Release);
139 if std::mem::take(&mut news) {
140 notify();
141 }
142 let _ = match wait {
143 Some(wait) => receiver.recv_timeout(wait).ok(),
144 None => receiver.recv().ok(),
145 };
146 continue;
147 }165 }
148 }166 }
167 watched.next(now, bound.is_some())
149 };168 };
150 let (bind, interval) = match &mut bound {169 let (path, replica, seen, current) = match next {
151 Some(bound) => bound,170 Next::Wait(wait) => {
152 None => {171 signal.requested.store(false, Ordering::Release);
172 if std::mem::take(&mut news) {
173 notify();
174 }
175 let _ = match wait {
176 Some(wait) => receiver.recv_timeout(wait).ok(),
177 None => receiver.recv().ok(),
178 };
179 continue;
180 }
181 Next::Connect(connection) => {
153 let reports = Reports {182 let reports = Reports {
154 shared: weak.clone(),183 shared: weak.clone(),
155 connection: connection + 1,184 connection: connection + 1,
...@@ -162,31 +191,64 @@ impl Background {...@@ -162,31 +191,64 @@ impl Background {
162 watched.connection += 1;191 watched.connection += 1;
163 watched.lost = false;192 watched.lost = false;
164 match connected {193 match connected {
165 // Changes while unwatched went unreported.194 Ok((mut files, reported)) => {
166 Ok(connected) => {195 watched.watching(reported);
167 watched.rescan(now);196 let folders = watched.rescanning();
168 bound = Some(connected);197 drop(watched);
198 let listings = list(&mut files.1, folders);
199 let Ok(mut watched) = owner.watched.lock() else {
200 return;
201 };
202 watched.listed(&listings, Some(copies), Instant::now());
203 bound = Some(files);
169 }204 }
170 Err(error) => {205 Err(error) => {
171 let error = Error::RemoteIo(error);206 let error = Error::RemoteIo(error);
207 let retry = now + RETRY;
172 for watch in &mut watched.sections {208 for watch in &mut watched.sections {
173 news |= watch.fail(&error, None);209 news |= watch.fail(&error, None);
174 watch.due = now + RETRY;210 watch.due = watch.due.max(retry);
175 }211 }
212 watched.relist = watched.relist.map(|due| due.max(retry));
176 }213 }
177 }214 }
178 continue;215 continue;
179 }216 }
217 Next::List(folders) => {
218 let Some((_, list_folder)) = &mut bound else {
219 continue;
220 };
221 let listings = list(list_folder, folders);
222 let Ok(mut watched) = owner.watched.lock() else {
223 return;
224 };
225 watched.listed(&listings, None, Instant::now());
226 continue;
227 }
228 Next::Check {
229 path,
230 replica,
231 seen,
232 current,
233 } => (path, replica, seen, current),
180 };234 };
181 let (queued, outcome) =235 let Some((bind, _)) = &mut bound else {
182 step(&mut bind(&path), replica.as_deref(), seen.as_ref(), copies);236 continue;
183 let interval = *interval;237 };
238 let (queued, outcome) = step(
239 &mut bind(&path),
240 replica.as_deref(),
241 seen.as_deref(),
242 current,
243 copies,
244 );
184 if outcome.as_ref().is_err_and(disconnected) {245 if outcome.as_ref().is_err_and(disconnected) {
185 bound = None;246 bound = None;
186 }247 }
187 let Ok(mut watched) = owner.watched.lock() else {248 let Ok(mut watched) = owner.watched.lock() else {
188 return;249 return;
189 };250 };
251 let interval = watched.interval();
190 let Watched {252 let Watched {
191 sections, changed, ..253 sections, changed, ..
192 } = &mut *watched;254 } = &mut *watched;
...@@ -194,6 +256,7 @@ impl Background {...@@ -194,6 +256,7 @@ impl Background {
194 continue;256 continue;
195 };257 };
196 let now = Instant::now();258 let now = Instant::now();
259 watch.current = false;
197 news |= match outcome {260 news |= match outcome {
198 Ok((stamp, moved)) => {261 Ok((stamp, moved)) => {
199 watch.stamp = Some(stamp);262 watch.stamp = Some(stamp);
...@@ -217,15 +280,16 @@ impl Background {...@@ -217,15 +280,16 @@ impl Background {
217 }280 }
218 };281 };
219 }282 }
220 if owner.discard.load(Ordering::Acquire)283 if let Ok(mut watched) = owner.watched.lock() {
221 && let Ok(watched) = owner.watched.lock()284 watched.watching(false);
222 {285 if owner.discard.load(Ordering::Acquire) {
223 for replica in watched286 for replica in watched
224 .sections287 .sections
225 .iter()288 .iter()
226 .filter_map(|watch| watch.replica.as_ref())289 .filter_map(|watch| watch.replica.as_ref())
227 {290 {
228 discard(replica);291 discard(replica);
292 }
229 }293 }
230 }294 }
231 })?;295 })?;
...@@ -248,39 +312,75 @@ impl Background {...@@ -248,39 +312,75 @@ impl Background {
248 true,312 true,
249 move |reports| {313 move |reports| {
250 let client = Arc::new(connect()?);314 let client = Arc::new(connect()?);
251 let interval = match client.watch(&root, move |changed| match changed {315 let reported = match client.watch(&root, move |changed| match changed {
252 Ok(paths) => reports.touched(&paths),316 Ok(paths) => reports.touched(&paths),
253 Err(_) => reports.lost(),317 Err(_) => reports.lost(),
254 }) {318 }) {
255 Ok(()) => Self::BACKSTOP,319 Ok(()) => true,
256 Err(error) if error.kind() == io::ErrorKind::Unsupported => Self::UNWATCHED,320 Err(error) if error.kind() == io::ErrorKind::Unsupported => false,
257 Err(error) => return Err(error),321 Err(error) => return Err(error),
258 };322 };
259 let root = root.clone();323 let (bound, folder) = (Arc::clone(&client), root.clone());
260 let bind = move |path: &str| {324 let bind = move |path: &str| {
261 let file = match root.as_str() {325 let file = match folder.as_str() {
262 "" => path.to_owned(),326 "" => path.to_owned(),
263 root => format!("{root}/{path}"),327 root => format!("{root}/{path}"),
264 };328 };
265 crate::SmbRemote::new(Arc::clone(&client), file, limit)329 crate::SmbRemote::new(Arc::clone(&bound), file, limit)
330 };
331 let root = root.clone();
332 let list = move |folder: &str| {
333 use crate::discover::Source;
334 crate::discover::Smb::new(&client, &root)?.entries(folder, ENTRIES)
266 };335 };
267 Ok((bind, interval))336 Ok(((bind, list), reported))
268 },337 },
269 notify,338 notify,
270 )339 )
271 }340 }
272341
273 /// Watches these sections, by catalog path and replica (`Notebook::replicas`); a section342 /// Watches these sections (`Notebook::replicas`); a section not watched before is first
274 /// not watched before is first checked soon after, the next one a little later.343 /// checked soon after, the next one a little later.
275 pub fn watch(&self, sections: Vec<(String, Option<PathBuf>)>) {344 pub fn watch(&self, sections: Vec<Known>) {
276 if let Ok(mut watched) = self.0.watched.lock() {345 if let Ok(mut watched) = self.0.watched.lock() {
277 watched.watch(sections, Instant::now());346 watched.watch(sections, Instant::now());
278 }347 }
279 self.0.signal.wake();348 self.0.signal.wake();
280 }349 }
281350
282 /// Checks the sections at or below these catalog paths soon, as a watch on the351 /// Leaves the section at catalog `path` to `section`'s worker, which from now on the watch
283 /// notebook's folder reports them changed; `""` names every section.352 /// on the notebook's folder wakes when the file changes, instead of the worker checking
353 /// it every few seconds. Once the section closes, this takes it over again.
354 pub fn hold(&self, path: &str, section: &Section) {
355 let replica = section.replica();
356 let Some(worker) = replica
357 .worker
358 .lock()
359 .ok()
360 .and_then(|worker| worker.upgrade())
361 else {
362 return;
363 };
364 if let Ok(mut watched) = self.0.watched.lock() {
365 let reported = watched.reported;
366 if let Some(watch) = watched.sections.iter_mut().find(|watch| watch.path == path) {
367 worker.watched.store(reported, Ordering::Release);
368 watch.held = Some(Arc::downgrade(&worker));
369 }
370 }
371 let (shared, path) = (Arc::downgrade(&self.0), path.to_owned());
372 if let Ok(mut released) = replica.released.0.lock() {
373 *released = Some(Box::new(move || {
374 if let Some(shared) = shared.upgrade() {
375 shared.released(&path);
376 }
377 }));
378 }
379 }
380
381 /// Checks the sections at or below these catalog paths soon, as a watch on the notebook's
382 /// folder reports them changed: a section's own path checks it, a folder's lists it first
383 /// and checks the sections listed otherwise. `""` names the notebook's folder.
284 pub fn touched(&self, paths: &[String]) {384 pub fn touched(&self, paths: &[String]) {
285 self.0.touched(paths);385 self.0.touched(paths);
286 }386 }
...@@ -322,12 +422,13 @@ impl Background {...@@ -322,12 +422,13 @@ impl Background {
322 .unwrap_or_default()422 .unwrap_or_default()
323 }423 }
324424
325 /// Checks every section now, working offline included (Sync Now).425 /// Reads every section's stamp now, working offline included (Sync Now).
326 pub fn wake(&self) {426 pub fn wake(&self) {
327 if let Ok(mut watched) = self.0.watched.lock() {427 if let Ok(mut watched) = self.0.watched.lock() {
328 let now = Instant::now();428 let now = Instant::now();
329 for watch in &mut watched.sections {429 for watch in &mut watched.sections {
330 watch.due = now;430 watch.due = now;
431 watch.current = false;
331 }432 }
332 }433 }
333 self.0.signal.requested.store(true, Ordering::Release);434 self.0.signal.requested.store(true, Ordering::Release);
...@@ -343,7 +444,8 @@ impl Background {...@@ -343,7 +444,8 @@ impl Background {
343 self.0.signal.wake();444 self.0.signal.wake();
344 }445 }
345446
346 /// Working offline, nothing is checked until `wake`, or until working online again.447 /// Working offline, nothing is checked until `wake`, or until working online again, which
448 /// lists every folder again.
347 pub fn set_offline(&self, offline: bool) {449 pub fn set_offline(&self, offline: bool) {
348 self.0.signal.offline.store(offline, Ordering::Release);450 self.0.signal.offline.store(offline, Ordering::Release);
349 self.0.signal.wake();451 self.0.signal.wake();
...@@ -364,6 +466,18 @@ impl Shared {...@@ -364,6 +466,18 @@ impl Shared {
364 }466 }
365 self.signal.wake();467 self.signal.wake();
366 }468 }
469
470 /// The session holding the section at `path` let it go: it is checked now.
471 fn released(&self, path: &str) {
472 if let Ok(mut watched) = self.watched.lock()
473 && let Some(watch) = watched.sections.iter_mut().find(|watch| watch.path == path)
474 {
475 watch.held = None;
476 watch.current = false;
477 watch.due = Instant::now();
478 }
479 self.signal.wake();
480 }
367}481}
368482
369impl Reports {483impl Reports {
...@@ -387,30 +501,50 @@ impl Reports {...@@ -387,30 +501,50 @@ impl Reports {
387 }501 }
388}502}
389503
504/// What the background thread does next.
505enum Next {
506 Wait(Option<Duration>),
507 /// Connects, as the current connection is `.0`, then lists every folder.
508 Connect(u64),
509 /// Lists these folders, checking the sections listed otherwise.
510 List(BTreeSet<String>),
511 Check {
512 path: String,
513 replica: Option<PathBuf>,
514 seen: Option<Box<Stamp>>,
515 current: bool,
516 },
517}
518
390impl Watched {519impl Watched {
391 fn watch(&mut self, sections: Vec<(String, Option<PathBuf>)>, now: Instant) {520 fn watch(&mut self, sections: Vec<Known>, now: Instant) {
392 let mut previous = std::mem::take(&mut self.sections);521 let mut previous = std::mem::take(&mut self.sections);
393 let mut new = 0;522 let mut new = 0;
394 self.sections = sections523 self.sections = sections
395 .into_iter()524 .into_iter()
396 .map(525 .map(
397 |(path, replica)| match previous.iter().position(|watch| watch.path == path) {526 |known| match previous.iter().position(|watch| watch.path == known.path) {
398 Some(index) => Watch {527 Some(index) => Watch {
399 replica,528 replica: known.replica,
400 ..previous.swap_remove(index)529 ..previous.swap_remove(index)
401 },530 },
402 None => {531 None => {
403 new += 1;532 new += 1;
533 let (listed, stamp) = known.found.unzip();
404 Watch {534 Watch {
405 path,535 path: known.path,
406 replica,536 replica: known.replica,
407 stamp: None,537 stamp,
538 listed,
539 current: false,
540 reported: false,
408 status: SyncStatus {541 status: SyncStatus {
409 synced: None,542 synced: None,
410 error: None,543 error: None,
411 queued: 0,544 queued: 0,
412 },545 },
413 due: now + STAGGER * (new - 1),546 due: now + STAGGER * (new - 1),
547 held: None,
414 }548 }
415 }549 }
416 },550 },
...@@ -418,27 +552,181 @@ impl Watched {...@@ -418,27 +552,181 @@ impl Watched {
418 .collect();552 .collect();
419 }553 }
420554
421 /// Checks every section from `now`, one `STAGGER` after another.555 fn interval(&self) -> Duration {
422 fn rescan(&mut self, now: Instant) {556 if self.reported {
423 for (index, watch) in (0..).zip(&mut self.sections) {557 Background::BACKSTOP
424 watch.due = now + STAGGER * index;558 } else {
559 Background::UNWATCHED
560 }
561 }
562
563 /// Whether a watch reports the folder's changes from now on, as the held sessions' workers
564 /// learn.
565 fn watching(&mut self, reported: bool) {
566 self.reported = reported;
567 for signal in self
568 .sections
569 .iter()
570 .filter_map(|watch| watch.held.as_ref()?.upgrade())
571 {
572 signal.watched.store(reported, Ordering::Release);
573 signal.wake();
425 }574 }
426 }575 }
427576
428 fn touched(&mut self, paths: &[String], now: Instant) {577 fn touched(&mut self, paths: &[String], now: Instant) {
578 for path in paths {
579 let path = path.trim_matches('/');
580 let section = self
581 .sections
582 .iter()
583 .position(|watch| watch.path.eq_ignore_ascii_case(path));
584 match section {
585 Some(index) => {
586 let watch = &mut self.sections[index];
587 watch.due = watch.due.min(now + SETTLE);
588 watch.current = false;
589 watch.reported = true;
590 }
591 None if self.sections.iter().any(|watch| within(&watch.path, path)) => {
592 self.folders.insert(path.to_owned());
593 self.relist = Some(
594 self.relist
595 .map_or(now + SETTLE, |due| due.min(now + SETTLE)),
596 );
597 }
598 None => {}
599 }
600 }
601 }
602
603 /// Every folder holding a section, to list them all, as a listing begins.
604 fn rescanning(&mut self) -> BTreeSet<String> {
605 for watch in &mut self.sections {
606 watch.reported = false;
607 }
608 self.parents(&[String::new()])
609 }
610
611 /// The folders holding the sections at or below `paths`.
612 fn parents<'a>(&self, paths: impl IntoIterator<Item = &'a String> + Clone) -> BTreeSet<String> {
613 self.sections
614 .iter()
615 .filter(|watch| {
616 paths
617 .clone()
618 .into_iter()
619 .any(|path| within(&watch.path, path))
620 })
621 .map(|watch| split(&watch.path).0.to_owned())
622 .collect()
623 }
624
625 /// Takes the folders' listings. With `rescan`, as on connecting (whether copies are kept),
626 /// every section of a listed folder is checked, from now: first those whose file lists as
627 /// when its stamp was last read, which need no reading unless for a copy, then the others
628 /// one `STAGGER` after another. Otherwise only the sections listed otherwise are, as are
629 /// those a watch reported meanwhile either way.
630 fn listed(
631 &mut self,
632 listings: &BTreeMap<String, Vec<Entry>>,
633 rescan: Option<bool>,
634 now: Instant,
635 ) {
636 let mut staggered = 0;
429 for watch in &mut self.sections {637 for watch in &mut self.sections {
430 if paths.iter().any(|path| within(&watch.path, path)) {638 let (folder, name) = split(&watch.path);
431 watch.due = watch.due.min(now + SETTLE);639 let Some(entries) = listings.get(folder) else {
640 continue;
641 };
642 let listed = entries
643 .iter()
644 .find(|entry| entry.name.eq_ignore_ascii_case(name))
645 .map(|entry| entry.listed);
646 let unchanged = watch.stamp.is_some() && listed.is_some() && listed == watch.listed;
647 watch.listed = listed;
648 match rescan {
649 // A report while listing may name a change the listing missed.
650 Some(_) if watch.reported => watch.current = false,
651 Some(copies) => {
652 watch.current = unchanged;
653 let read = !unchanged
654 || copies
655 && watch
656 .replica
657 .as_ref()
658 .is_some_and(|replica| !replica.exists());
659 watch.due = if read {
660 staggered += 1;
661 now + STAGGER * (staggered - 1)
662 } else {
663 now
664 };
665 }
666 None if !unchanged => {
667 watch.current = false;
668 watch.due = watch.due.min(now);
669 }
670 None => {}
432 }671 }
433 }672 }
434 }673 }
435674
436 /// The section checked next.675 /// What to do at `now`, waking the held sections that are due; `bound` while connected.
437 fn next(&self) -> Option<usize> {676 fn next(&mut self, now: Instant, bound: bool) -> Next {
438 (0..self.sections.len()).min_by_key(|&index| self.sections[index].due)677 let interval = self.interval();
678 loop {
679 let due = (0..self.sections.len()).min_by_key(|&index| self.sections[index].due);
680 let at = due.map(|index| self.sections[index].due);
681 if let Some(relist) = self.relist
682 && relist <= now
683 && at.is_none_or(|at| relist <= at)
684 {
685 if !bound {
686 return Next::Connect(self.connection);
687 }
688 let folders = std::mem::take(&mut self.folders);
689 self.relist = None;
690 return Next::List(self.parents(&folders));
691 }
692 let Some(index) = due.filter(|&index| self.sections[index].due <= now) else {
693 let wait = [at, self.relist].into_iter().flatten().min();
694 return Next::Wait(wait.map(|at| at.saturating_duration_since(now)));
695 };
696 let watch = &mut self.sections[index];
697 if let Some(worker) = watch.held.as_ref().and_then(Weak::upgrade) {
698 worker.wake();
699 watch.due = now + interval;
700 continue;
701 }
702 if !bound {
703 return Next::Connect(self.connection);
704 }
705 return Next::Check {
706 path: watch.path.clone(),
707 replica: watch.replica.clone(),
708 seen: watch.stamp.clone().map(Box::new),
709 current: watch.current,
710 };
711 }
439 }712 }
440}713}
441714
715/// Lists each of `folders` through `list`; a folder that cannot be listed lists nothing, so
716/// that its sections are checked.
717fn list(
718 list: &mut impl FnMut(&str) -> io::Result<Vec<Entry>>,
719 folders: BTreeSet<String>,
720) -> BTreeMap<String, Vec<Entry>> {
721 folders
722 .into_iter()
723 .map(|folder| {
724 let entries = list(&folder).unwrap_or_default();
725 (folder, entries)
726 })
727 .collect()
728}
729
442/// Deletes the replica at `replica` if it holds nothing unpublished and no one holds it.730/// Deletes the replica at `replica` if it holds nothing unpublished and no one holds it.
443fn discard(replica: &Path) {731fn discard(replica: &Path) {
444 if matches!(crate::peek(replica), Ok((_, 0))) {732 if matches!(crate::peek(replica), Ok((_, 0))) {
...@@ -460,6 +748,11 @@ fn within(section: &str, path: &str) -> bool {...@@ -460,6 +748,11 @@ fn within(section: &str, path: &str) -> bool {
460 && matches!(section.as_bytes().get(path.len()), None | Some(b'/'))748 && matches!(section.as_bytes().get(path.len()), None | Some(b'/'))
461}749}
462750
751/// A catalog path's folder and name.
752fn split(path: &str) -> (&str, &str) {
753 path.rsplit_once('/').unwrap_or(("", path))
754}
755
463/// Whether a failed step lost the connection, rather than failing for its one file.756/// Whether a failed step lost the connection, rather than failing for its one file.
464fn disconnected(error: &Error) -> bool {757fn disconnected(error: &Error) -> bool {
465 use io::ErrorKind::*;758 use io::ErrorKind::*;
...@@ -495,17 +788,22 @@ fn summary(status: &SyncStatus) -> (bool, Option<io::ErrorKind>, u64) {...@@ -495,17 +788,22 @@ fn summary(status: &SyncStatus) -> (bool, Option<io::ErrorKind>, u64) {
495}788}
496789
497/// One section's step: how many of its edits wait (`None` while unknown, as while a session790/// One section's step: how many of its edits wait (`None` while unknown, as while a session
498/// holds its replica), then the file's stamp now and whether it changed since `seen`. With791/// holds its replica), then the file's stamp now and whether it changed since `seen`, which
499/// `copies`, a section without a replica gets one from the file as it is now.792/// with `current` is the stamp now, unread. With `copies`, a section without a replica gets
793/// one from the file as it is now.
500fn step<R: Remote>(794fn step<R: Remote>(
501 remote: &mut R,795 remote: &mut R,
502 replica: Option<&Path>,796 replica: Option<&Path>,
503 seen: Option<&Stamp>,797 seen: Option<&Stamp>,
798 current: bool,
504 copies: bool,799 copies: bool,
505) -> (Option<u64>, Result<(Stamp, bool)>) {800) -> (Option<u64>, Result<(Stamp, bool)>) {
506 let stamp = match remote.stamp() {801 let stamp = match seen.filter(|_| current) {
507 Ok(stamp) => stamp,802 Some(seen) => seen.clone(),
508 Err(error) => return (None, Err(Error::RemoteIo(error))),803 None => match remote.stamp() {
804 Ok(stamp) => stamp,
805 Err(error) => return (None, Err(Error::RemoteIo(error))),
806 },
509 };807 };
510 let moved = seen.is_some_and(|seen| *seen != stamp);808 let moved = seen.is_some_and(|seen| *seen != stamp);
511 let Some(replica) = replica else {809 let Some(replica) = replica else {
...@@ -562,6 +860,7 @@ fn step<R: Remote>(...@@ -562,6 +860,7 @@ fn step<R: Remote>(
562#[cfg(test)]860#[cfg(test)]
563mod tests {861mod tests {
564 use super::*;862 use super::*;
863 use crate::discover::EntryKind;
565 const BACKSTOP: Duration = Background::BACKSTOP;864 const BACKSTOP: Duration = Background::BACKSTOP;
566 use onestore::{CommitError, Transaction};865 use onestore::{CommitError, Transaction};
567 use std::{866 use std::{
...@@ -569,37 +868,120 @@ mod tests {...@@ -569,37 +868,120 @@ mod tests {
569 sync::mpsc::{self, Receiver},868 sync::mpsc::{self, Receiver},
570 };869 };
571870
572 fn sections(count: usize) -> Vec<(String, Option<PathBuf>)> {871 fn path(n: usize) -> String {
872 let folder = if n.is_multiple_of(2) { "" } else { "Group/" };
873 format!("{folder}Section {n:03}.one")
874 }
875
876 fn listed(n: usize) -> Listed {
877 Listed {
878 size: 1000 + n as u64,
879 modified: 7,
880 }
881 }
882
883 fn stamp(n: usize) -> Stamp {
884 Stamp {
885 header: [n as u8; 1024],
886 length: n as u64,
887 }
888 }
889
890 /// `count` sections, each found by discovery as `listed` and `stamp` have it, with `warm`.
891 fn sections(count: usize, warm: bool) -> Vec<Known> {
573 (0..count)892 (0..count)
574 .map(|n| {893 .map(|n| Known {
575 let folder = if n % 2 == 0 { "" } else { "Group/" };894 path: path(n),
576 (format!("{folder}Section {n:03}.one"), None)895 replica: None,
896 found: warm.then(|| (listed(n), stamp(n))),
577 })897 })
578 .collect()898 .collect()
579 }899 }
580900
581 /// Runs the schedule as the thread would with every check succeeding, up to `end`,901 /// The notebook's folders as a listing shows its first `count` sections.
582 /// answering which sections were checked when.902 fn listing(count: usize) -> BTreeMap<String, Vec<Entry>> {
583 fn run(watched: &mut Watched, end: Instant, interval: Duration) -> Vec<(Instant, String)> {903 let mut folders: BTreeMap<String, Vec<Entry>> = BTreeMap::new();
904 for n in 0..count {
905 let path = path(n);
906 let (folder, name) = split(&path);
907 folders.entry(folder.to_owned()).or_default().push(Entry {
908 name: name.to_owned(),
909 kind: EntryKind::File,
910 listed: listed(n),
911 });
912 }
913 folders
914 }
915
916 /// Connects at `now`, a watch reporting, and the folders listing as `listings` has them;
917 /// `meanwhile` runs while they are listed.
918 fn connect(
919 watched: &mut Watched,
920 listings: &BTreeMap<String, Vec<Entry>>,
921 now: Instant,
922 meanwhile: impl FnOnce(&mut Watched),
923 ) {
924 watched.watching(true);
925 let folders = watched.rescanning();
926 meanwhile(watched);
927 let listings = folders
928 .into_iter()
929 .map(|folder| (folder.clone(), listings[&folder].clone()))
930 .collect();
931 watched.listed(&listings, Some(false), now);
932 }
933
934 /// Runs the schedule as the thread would from `now` to `end`, every check succeeding and
935 /// every folder listing as `listings` has it, answering each check: when, which section,
936 /// and whether it read the file's stamp.
937 fn run(
938 watched: &mut Watched,
939 mut now: Instant,
940 end: Instant,
941 listings: &BTreeMap<String, Vec<Entry>>,
942 ) -> Vec<(Instant, String, bool)> {
584 let mut checks = Vec::new();943 let mut checks = Vec::new();
585 while let Some(index) = watched.next() {944 loop {
586 let watch = &mut watched.sections[index];945 match watched.next(now, true) {
587 if watch.due > end {946 Next::Wait(Some(wait)) if now + wait <= end => now += wait,
588 break;947 Next::Wait(_) => return checks,
948 Next::Connect(_) => unreachable!("connected"),
949 Next::List(folders) => {
950 let listings = folders
951 .into_iter()
952 .map(|folder| (folder.clone(), listings[&folder].clone()))
953 .collect();
954 watched.listed(&listings, None, now);
955 }
956 Next::Check { path, current, .. } => {
957 let interval = watched.interval();
958 let watch = watched
959 .sections
960 .iter_mut()
961 .find(|watch| watch.path == path)
962 .unwrap();
963 watch.current = false;
964 watch.due = now + interval;
965 checks.push((now, path, !current));
966 }
589 }967 }
590 checks.push((watch.due, watch.path.clone()));
591 watch.due += interval;
592 }968 }
593 checks
594 }969 }
595970
596 #[test]971 #[test]
597 fn an_idle_notebook_is_read_once_staggered_then_left_alone() {972 fn a_new_notebook_is_read_once_staggered_then_left_alone() {
598 let start = Instant::now();973 let start = Instant::now();
599 let mut watched = Watched::default();974 let mut watched = Watched::default();
600 watched.watch(sections(200), start);975 watched.watch(sections(200, false), start);
601 let checks = run(&mut watched, start + BACKSTOP - SETTLE, BACKSTOP);976 connect(&mut watched, &listing(200), start, |_| {});
977 let checks = run(
978 &mut watched,
979 start,
980 start + BACKSTOP - SETTLE,
981 &listing(200),
982 );
602 assert_eq!(checks.len(), 200, "one first check each, then none");983 assert_eq!(checks.len(), 200, "one first check each, then none");
984 assert!(checks.iter().all(|(.., read)| *read));
603 for pair in checks.windows(2) {985 for pair in checks.windows(2) {
604 assert!(pair[1].0 - pair[0].0 >= STAGGER, "never a burst");986 assert!(pair[1].0 - pair[0].0 >= STAGGER, "never a burst");
605 }987 }
...@@ -607,19 +989,59 @@ mod tests {...@@ -607,19 +989,59 @@ mod tests {
607 // where a 15-second poll made 5,760.989 // where a 15-second poll made 5,760.
608 let day = run(990 let day = run(
609 &mut watched,991 &mut watched,
992 start + BACKSTOP - SETTLE,
610 start + Duration::from_secs(86_400) - SETTLE,993 start + Duration::from_secs(86_400) - SETTLE,
611 BACKSTOP,994 &listing(200),
612 );995 );
613 assert_eq!(day.len(), 200 * (86_400 / BACKSTOP.as_secs() as usize - 1));996 assert_eq!(day.len(), 200 * (86_400 / BACKSTOP.as_secs() as usize - 1));
614 }997 }
615998
999 #[test]
1000 fn reopening_reads_only_the_files_listed_otherwise() {
1001 let start = Instant::now();
1002 let mut watched = Watched::default();
1003 watched.watch(sections(200, true), start);
1004 let mut listings = listing(200);
1005 // Another client wrote one section while the notebook was closed.
1006 listings.get_mut("Group").unwrap()[3].listed.modified += 1;
1007 connect(&mut watched, &listings, start, |_| {});
1008 let checks = run(&mut watched, start, start + SETTLE, &listings);
1009 assert_eq!(checks.len(), 200);
1010 let read: Vec<_> = checks.iter().filter(|(.., read)| *read).collect();
1011 assert_eq!(read.len(), 1, "{read:?}");
1012 assert_eq!(read[0].1, "Group/Section 007.one");
1013 assert!(
1014 checks.iter().all(|(at, ..)| *at == start),
1015 "nothing to read, nothing to stagger"
1016 );
1017 }
1018
1019 #[test]
1020 fn a_change_reported_while_listing_is_read_though_the_listing_missed_it() {
1021 let start = Instant::now();
1022 let mut watched = Watched::default();
1023 watched.watch(sections(4, true), start);
1024 connect(&mut watched, &listing(4), start, |watched| {
1025 watched.touched(&[path(2)], start);
1026 });
1027 let checks = run(&mut watched, start, start + SETTLE * 2, &listing(4));
1028 let read: Vec<_> = checks
1029 .into_iter()
1030 .filter(|(.., read)| *read)
1031 .map(|(_, path, _)| path)
1032 .collect();
1033 assert_eq!(read, vec![path(2)]);
1034 }
1035
616 #[test]1036 #[test]
617 fn a_reported_change_checks_exactly_the_sections_it_names() {1037 fn a_reported_change_checks_exactly_the_sections_it_names() {
618 let start = Instant::now();1038 let start = Instant::now();
619 let mut watched = Watched::default();1039 let mut watched = Watched::default();
620 watched.watch(sections(200), start);1040 watched.watch(sections(200, true), start);
1041 let mut listings = listing(200);
1042 connect(&mut watched, &listings, start, |_| {});
621 let now = start + Duration::from_secs(60);1043 let now = start + Duration::from_secs(60);
622 run(&mut watched, now, BACKSTOP);1044 run(&mut watched, start, now, &listings);
623 // A commit's several writes are reported apiece, and settle into one check.1045 // A commit's several writes are reported apiece, and settle into one check.
624 for _ in 0..3 {1046 for _ in 0..3 {
625 watched.touched(&["section 004.ONE".into()], now);1047 watched.touched(&["section 004.ONE".into()], now);
...@@ -628,31 +1050,81 @@ mod tests {...@@ -628,31 +1050,81 @@ mod tests {
628 &["Section 004.one".into()],1050 &["Section 004.one".into()],
629 now + Duration::from_millis(300),1051 now + Duration::from_millis(300),
630 );1052 );
631 let checks = run(&mut watched, now + Duration::from_secs(60), BACKSTOP);1053 let checks = run(&mut watched, now, now + Duration::from_secs(60), &listings);
632 assert_eq!(checks, vec![(now + SETTLE, "Section 004.one".to_owned())]);1054 assert_eq!(
633 // A group's folder names the sections in it, and "" every section.1055 checks,
1056 vec![(now + SETTLE, "Section 004.one".to_owned(), true)]
1057 );
1058 // A folder, as a watch names it when it cannot name the file, is listed, and only the
1059 // section listed otherwise is checked.
634 let now = now + Duration::from_secs(60);1060 let now = now + Duration::from_secs(60);
1061 listings.get_mut("Group").unwrap()[2].listed.size += 1;
635 watched.touched(&["Group".into()], now);1062 watched.touched(&["Group".into()], now);
636 let group = run(&mut watched, now + SETTLE, BACKSTOP);1063 let group = run(&mut watched, now, now + SETTLE * 2, &listings);
637 assert_eq!(group.len(), 100);1064 assert_eq!(
638 assert!(group.iter().all(|(_, path)| path.starts_with("Group/")));1065 group,
639 watched.touched(&["Grou".into(), String::new()], now);1066 vec![(now + SETTLE, "Group/Section 005.one".to_owned(), true)]
640 assert_eq!(run(&mut watched, now + SETTLE, BACKSTOP).len(), 200);1067 );
1068 // `""` lists every folder; nothing listed otherwise, nothing is checked.
1069 watched.touched(&["Grou".into(), String::new()], now + SETTLE * 2);
1070 assert!(run(&mut watched, now + SETTLE * 2, now + SETTLE * 4, &listings).is_empty());
641 }1071 }
6421072
1073 /// Each file's image and how many times it was written, by path.
1074 type Images = HashMap<String, (Vec<u8>, u64)>;
1075
643 /// Section files in memory, each stamp read reported as it happens.1076 /// Section files in memory, each stamp read reported as it happens.
644 #[derive(Clone)]1077 #[derive(Clone)]
645 struct Files {1078 struct Files {
646 images: Arc<Mutex<HashMap<String, Vec<u8>>>>,1079 images: Arc<Mutex<Images>>,
647 stamps: mpsc::Sender<String>,1080 stamps: mpsc::Sender<String>,
648 }1081 }
6491082
1083 impl Files {
1084 fn write(&self, path: &str, image: Vec<u8>) {
1085 let mut images = self.images.lock().unwrap();
1086 let version = images.get(path).map_or(0, |(_, version)| version + 1);
1087 images.insert(path.to_owned(), (image, version));
1088 }
1089
1090 fn list(&self, folder: &str) -> Vec<Entry> {
1091 let images = self.images.lock().unwrap();
1092 images
1093 .iter()
1094 .filter(|(path, _)| split(path).0 == folder)
1095 .map(|(path, (image, version))| Entry {
1096 name: split(path).1.to_owned(),
1097 kind: EntryKind::File,
1098 listed: Listed {
1099 size: image.len() as u64,
1100 modified: *version,
1101 },
1102 })
1103 .collect()
1104 }
1105
1106 fn known(&self, path: &str) -> Known {
1107 let (image, version) = self.images.lock().unwrap()[path].clone();
1108 Known {
1109 path: path.to_owned(),
1110 replica: None,
1111 found: Some((
1112 Listed {
1113 size: image.len() as u64,
1114 modified: version,
1115 },
1116 Stamp::of(&image).unwrap(),
1117 )),
1118 }
1119 }
1120 }
1121
650 struct File(Files, String);1122 struct File(Files, String);
6511123
652 impl Remote for File {1124 impl Remote for File {
653 fn read(&mut self) -> io::Result<Vec<u8>> {1125 fn read(&mut self) -> io::Result<Vec<u8>> {
654 let images = self.0.images.lock().unwrap();1126 let images = self.0.images.lock().unwrap();
655 Ok(images[&self.1].clone())1127 Ok(images[&self.1].0.clone())
656 }1128 }
6571129
658 fn stamp(&mut self) -> io::Result<Stamp> {1130 fn stamp(&mut self) -> io::Result<Stamp> {
...@@ -678,17 +1150,19 @@ mod tests {...@@ -678,17 +1150,19 @@ mod tests {
678 }1150 }
6791151
680 #[test]1152 #[test]
681 fn a_watch_wakes_the_section_it_reports_and_a_lost_one_rescans() {1153 fn a_watch_wakes_the_section_it_reports_and_a_lost_one_lists_again() {
682 let (sender, stamps) = mpsc::channel();1154 let (sender, stamps) = mpsc::channel();
683 let files = Files {1155 let files = Files {
684 images: Arc::default(),1156 images: Arc::default(),
685 stamps: sender,1157 stamps: sender,
686 };1158 };
687 let mut paths: Vec<String> = sections(6).into_iter().map(|(path, _)| path).collect();1159 let mut paths: Vec<String> = (0..6).map(path).collect();
688 paths.sort();1160 paths.sort();
689 for path in &paths {1161 for path in &paths {
690 let image = onestore::create_section("s.one", path, "Author").unwrap();1162 files.write(
691 files.images.lock().unwrap().insert(path.clone(), image);1163 path,
1164 onestore::create_section("s.one", path, "Author").unwrap(),
1165 );
692 }1166 }
693 let (armed, watches) = mpsc::channel();1167 let (armed, watches) = mpsc::channel();
694 let remote = files.clone();1168 let remote = files.clone();
...@@ -696,38 +1170,113 @@ mod tests {...@@ -696,38 +1170,113 @@ mod tests {
696 false,1170 false,
697 move |reports| {1171 move |reports| {
698 armed.send(reports).unwrap();1172 armed.send(reports).unwrap();
699 let files = remote.clone();1173 let (files, listed) = (remote.clone(), remote.clone());
700 Ok((1174 let bind = move |path: &str| File(files.clone(), path.to_owned());
701 move |path: &str| File(files.clone(), path.to_owned()),1175 let list = move |folder: &str| Ok(listed.list(folder));
702 Duration::from_secs(3600),1176 Ok(((bind, list), true))
703 ))
704 },1177 },
705 || {},1178 || {},
706 )1179 )
707 .unwrap();1180 .unwrap();
708 background.watch(sections(6));1181 // A section the catalog read unchanged is not read again; a new one is.
709 let mut first = quiet(&stamps, STAGGER * 4);1182 let mut known: Vec<Known> = paths[1..].iter().map(|path| files.known(path)).collect();
710 first.sort();1183 known.push(Known {
711 assert_eq!(first, paths, "each section's first check");1184 path: paths[0].clone(),
1185 replica: None,
1186 found: None,
1187 });
1188 background.watch(known);
1189 assert_eq!(quiet(&stamps, STAGGER * 4), vec![paths[0].clone()]);
712 let watch = watches.recv().unwrap();1190 let watch = watches.recv().unwrap();
713 assert!(quiet(&stamps, SETTLE).is_empty(), "idle reads nothing");1191 assert!(quiet(&stamps, SETTLE).is_empty(), "idle reads nothing");
7141192
1193 // A folder reported without its file is listed; only the file listed otherwise is read.
1194 let other = &paths[4];
1195 files.write(
1196 other,
1197 onestore::create_section("s.one", "Other", "Author").unwrap(),
1198 );
1199 watch.touched(&[String::new()]);
1200 assert_eq!(quiet(&stamps, SETTLE * 2), vec![other.clone()]);
1201 assert_eq!(background.changed(), vec![other.clone()]);
1202
715 let changed = &paths[3];1203 let changed = &paths[3];
716 files.images.lock().unwrap().insert(1204 files.write(
717 changed.clone(),1205 changed,
718 onestore::create_section("s.one", "Changed", "Author").unwrap(),1206 onestore::create_section("s.one", "Changed", "Author").unwrap(),
719 );1207 );
720 watch.touched(std::slice::from_ref(changed));1208 watch.touched(std::slice::from_ref(changed));
721 assert_eq!(quiet(&stamps, SETTLE * 2), vec![changed.clone()]);1209 assert_eq!(quiet(&stamps, SETTLE * 2), vec![changed.clone()]);
722 assert_eq!(background.changed(), vec![changed.clone()]);1210 assert_eq!(background.changed(), vec![changed.clone()]);
7231211
724 // A watch that ended leaves changes unreported, so everything is read again.1212 // A watch that ended leaves changes unreported, so every folder is listed again, and
1213 // the files listed otherwise since the last listing are read.
1214 let third = &paths[5];
1215 files.write(
1216 third,
1217 onestore::create_section("s.one", "Third", "Author").unwrap(),
1218 );
725 watch.lost();1219 watch.lost();
726 let rewatch = watches.recv_timeout(SETTLE).unwrap();1220 let rewatch = watches.recv_timeout(SETTLE).unwrap();
727 assert_eq!(quiet(&stamps, STAGGER * 4).len(), 6);1221 let mut read = quiet(&stamps, STAGGER * 4);
1222 read.sort();
1223 assert_eq!(read, vec![changed.clone(), third.clone()]);
728 // The ended watch's connection is gone; only the current one's loss counts.1224 // The ended watch's connection is gone; only the current one's loss counts.
729 watch.lost();1225 watch.lost();
730 assert!(quiet(&stamps, SETTLE).is_empty());1226 assert!(quiet(&stamps, SETTLE).is_empty());
731 drop(rewatch);1227 drop(rewatch);
732 }1228 }
1229
1230 #[test]
1231 fn a_held_section_is_left_to_its_worker_which_the_watch_wakes() {
1232 let (sender, stamps) = mpsc::channel();
1233 let files = Files {
1234 images: Arc::default(),
1235 stamps: sender,
1236 };
1237 let held = path(0);
1238 files.write(
1239 &held,
1240 onestore::create_section("s.one", &held, "Author").unwrap(),
1241 );
1242 let (armed, watches) = mpsc::channel();
1243 let remote = files.clone();
1244 let background = Background::start(
1245 false,
1246 move |reports| {
1247 armed.send(reports).unwrap();
1248 let (files, listed) = (remote.clone(), remote.clone());
1249 let bind = move |path: &str| File(files.clone(), path.to_owned());
1250 let list = move |folder: &str| Ok(listed.list(folder));
1251 Ok(((bind, list), true))
1252 },
1253 || {},
1254 )
1255 .unwrap();
1256 background.watch(vec![files.known(&held)]);
1257 let watch = watches.recv().unwrap();
1258 assert!(quiet(&stamps, SETTLE).is_empty());
1259 // As `hold` leaves it to a session's worker.
1260 let (signal, woken) = Signal::new();
1261 if let Ok(mut watched) = background.0.watched.lock() {
1262 watched.sections[0].held = Some(Arc::downgrade(&signal));
1263 signal.watched.store(watched.reported, Ordering::Release);
1264 }
1265 assert!(
1266 signal.watched.load(Ordering::Acquire),
1267 "the watch reports, so the worker need not poll"
1268 );
1269 watch.touched(std::slice::from_ref(&held));
1270 assert!(woken.recv_timeout(SETTLE * 2).is_ok(), "the watch wakes it");
1271 assert!(
1272 quiet(&stamps, SETTLE).is_empty(),
1273 "the worker reads, not this"
1274 );
1275 // The watch ending hands polling back to the worker.
1276 watch.lost();
1277 assert!(woken.recv_timeout(SETTLE).is_ok());
1278 assert!(!signal.watched.load(Ordering::Acquire));
1279 drop(signal);
1280 drop(background);
1281 }
733}1282}
crates/notebook/src/discover.rs+254-123
...@@ -1,10 +1,10 @@...@@ -1,10 +1,10 @@
1//! Read-only notebook discovery over a caller-supplied root.1//! Read-only notebook discovery over a caller-supplied root.
22
3use onestore::{3use onestore::{
4 FileType, RevisionIndex, Store,4 FileType, RevisionIndex, Stamp, Store,
5 document::{Document, Kind},5 document::{Document, Kind},
6};6};
7use serde::Serialize;7use serde::{Deserialize, Serialize};
8use std::{8use std::{
9 collections::{BTreeMap, BTreeSet},9 collections::{BTreeMap, BTreeSet},
10 io,10 io,
...@@ -64,7 +64,7 @@ pub struct Toc {...@@ -64,7 +64,7 @@ pub struct Toc {
64 pub unresolved: Vec<TocReference>,64 pub unresolved: Vec<TocReference>,
65}65}
6666
67#[derive(Debug, Serialize)]67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct TocReference {68pub struct TocReference {
69 /// Native TOCs can retain an identity after removing its cached filename.69 /// Native TOCs can retain an identity after removing its cached filename.
70 pub filename: Option<String>,70 pub filename: Option<String>,
...@@ -83,6 +83,86 @@ pub enum EntryKind {...@@ -83,6 +83,86 @@ pub enum EntryKind {
83pub struct Entry {83pub struct Entry {
84 pub name: String,84 pub name: String,
85 pub kind: EntryKind,85 pub kind: EntryKind,
86 pub listed: Listed,
87}
88
89/// A file as its folder's listing shows it: enough to tell that it changed without reading it.
90#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
91pub struct Listed {
92 pub size: u64,
93 /// When it was last written, in the source's own units.
94 pub modified: u64,
95}
96
97/// Each file a discovery read, with the listing it was read under, so that the next discovery
98/// reads only the files listed otherwise, as OneNote 2010 reopens a notebook it has cached.
99#[derive(Default, Serialize, Deserialize)]
100pub struct Cache(BTreeMap<String, Read>);
101
102/// A notebook file as discovery read it.
103#[derive(Clone, Serialize, Deserialize)]
104struct Read {
105 listed: Listed,
106 /// The file's stamp: its header in hex, and its length.
107 header: String,
108 length: u64,
109 held: Held,
110}
111
112/// What discovery takes from a file.
113#[derive(Clone, Serialize, Deserialize)]
114enum Held {
115 Section {
116 name: Option<String>,
117 color: Option<u32>,
118 document: [u8; 16],
119 },
120 Locked,
121 Toc(Vec<TocReference>),
122}
123
124impl Cache {
125 /// `discover`, reading only the files listed otherwise than when this cache last read them.
126 /// A failed discovery leaves the cache as it was.
127 pub fn discover(&mut self, source: &mut impl Source, limits: Limits) -> Result<Folder, Error> {
128 let mut remaining = limits.entries;
129 let mut identities = BTreeMap::new();
130 let mut read = BTreeMap::new();
131 let folder = scan(
132 source,
133 "",
134 &limits,
135 0,
136 &mut remaining,
137 &mut identities,
138 (&self.0, &mut read),
139 )?;
140 self.0 = read;
141 Ok(folder)
142 }
143
144 /// How the file at `path` was listed when last read, and its stamp then.
145 pub fn found(&self, path: &str) -> Option<(Listed, Stamp)> {
146 let read = self.0.get(path)?;
147 Some((read.listed, read.stamp()?))
148 }
149}
150
151impl Read {
152 fn stamp(&self) -> Option<Stamp> {
153 let header: Vec<u8> = (0..self.header.len())
154 .step_by(2)
155 .map(|at| u8::from_str_radix(self.header.get(at..at + 2)?, 16).ok())
156 .collect::<Option<_>>()?;
157 Some(Stamp {
158 header: header.try_into().ok()?,
159 length: self.length,
160 })
161 }
162
163 fn file_id(&self) -> Option<[u8; 16]> {
164 Some(onestore::Header::parse(&self.stamp()?.header).ok()?.file_id)
165 }
86}166}
87167
88/// Rooted file access. Paths are relative, UTF-8, and use `/` between components.168/// Rooted file access. Paths are relative, UTF-8, and use `/` between components.
...@@ -95,6 +175,11 @@ pub trait Source {...@@ -95,6 +175,11 @@ pub trait Source {
95 fn read_asset(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {175 fn read_asset(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
96 self.read(path, limit)176 self.read(path, limit)
97 }177 }
178 /// The file at `path` as a local copy of the file whose identity was `known` holds it,
179 /// where the copy holds it as it stands now, so that it need not be read.
180 fn copy(&mut self, _path: &str, _known: [u8; 16]) -> Option<Vec<u8>> {
181 None
182 }
98}183}
99184
100/// Reads an external file-data reference from the section's sibling `_onefiles` folder.185/// Reads an external file-data reference from the section's sibling `_onefiles` folder.
...@@ -170,9 +255,7 @@ pub enum Error {...@@ -170,9 +255,7 @@ pub enum Error {
170/// Discovers rooted notebook topology within caller-specified work and size limits.255/// Discovers rooted notebook topology within caller-specified work and size limits.
171/// Reserved `_onefiles` directories contain payloads, not section groups.256/// Reserved `_onefiles` directories contain payloads, not section groups.
172pub fn discover(source: &mut impl Source, limits: Limits) -> Result<Folder, Error> {257pub fn discover(source: &mut impl Source, limits: Limits) -> Result<Folder, Error> {
173 let mut remaining = limits.entries;258 Cache::default().discover(source, limits)
174 let mut identities = BTreeMap::new();
175 scan(source, "", &limits, 0, &mut remaining, &mut identities)
176}259}
177260
178fn scan(261fn scan(
...@@ -182,6 +265,7 @@ fn scan(...@@ -182,6 +265,7 @@ fn scan(
182 depth: usize,265 depth: usize,
183 remaining: &mut usize,266 remaining: &mut usize,
184 identities: &mut BTreeMap<[u8; 16], String>,267 identities: &mut BTreeMap<[u8; 16], String>,
268 (cached, read): (&BTreeMap<String, Read>, &mut BTreeMap<String, Read>),
185) -> Result<Folder, Error> {269) -> Result<Folder, Error> {
186 if depth > limits.depth {270 if depth > limits.depth {
187 return Err(Error::Limit { path: path.into() });271 return Err(Error::Limit { path: path.into() });
...@@ -218,7 +302,15 @@ fn scan(...@@ -218,7 +302,15 @@ fn scan(
218 continue;302 continue;
219 }303 }
220 // A group whose own listing or TOC cannot be read is unavailable, not fatal.304 // A group whose own listing or TOC cannot be read is unavailable, not fatal.
221 match scan(source, &child, limits, depth + 1, remaining, identities) {305 match scan(
306 source,
307 &child,
308 limits,
309 depth + 1,
310 remaining,
311 identities,
312 (cached, read),
313 ) {
222 Ok(group) => result.groups.push(group),314 Ok(group) => result.groups.push(group),
223 Err(error @ Error::Io { .. }) if unavailable(&error) => {315 Err(error @ Error::Io { .. }) if unavailable(&error) => {
224 result.unavailable.push(Unavailable {316 result.unavailable.push(Unavailable {
...@@ -244,134 +336,102 @@ fn scan(...@@ -244,134 +336,102 @@ fn scan(
244 {336 {
245 return Err(Error::Entry { path: child });337 return Err(Error::Entry { path: child });
246 }338 }
247 let bytes = match source.read(&child, limits.bytes_per_file) {339 let reused = cached
248 Ok(bytes) => bytes,340 .get(&child)
249 Err(error) => {341 .filter(|known| known.listed == entry.listed)
250 let error = Error::Io {342 .and_then(|known| Some((known.file_id()?, known)));
343 let (file_id, held) = match reused {
344 Some((file_id, known)) => {
345 read.insert(child.clone(), known.clone());
346 (file_id, Ok(known.held.clone()))
347 }
348 None => {
349 let copied = cached
350 .get(&child)
351 .and_then(Read::file_id)
352 .and_then(|known| source.copy(&child, known));
353 let bytes =
354 match copied.map_or_else(|| source.read(&child, limits.bytes_per_file), Ok) {
355 Ok(bytes) => bytes,
356 Err(error) => {
357 let error = Error::Io {
358 path: child.clone(),
359 error,
360 };
361 if expected == FileType::Section && unavailable(&error) {
362 result.unavailable.push(Unavailable {
363 path: child,
364 group: false,
365 error: error.to_string(),
366 });
367 continue;
368 }
369 return Err(error);
370 }
371 };
372 if bytes.len() > limits.bytes_per_file {
373 return Err(Error::Limit { path: child });
374 }
375 let store = Store::parse(&bytes).map_err(|error| Error::Document {
251 path: child.clone(),376 path: child.clone(),
252 error,377 error,
253 };378 })?;
254 if expected == FileType::Section && unavailable(&error) {379 if store.header.file_type != expected || !store.checksum_mismatches.is_empty() {
255 result.unavailable.push(Unavailable {380 return Err(Error::Document {
256 path: child,381 path: child,
257 group: false,382 error: onestore::Error {
258 error: error.to_string(),383 offset: 0,
384 message: "Unexpected file type or checksum mismatch",
385 },
259 });386 });
260 continue;
261 }387 }
262 return Err(error);388 let held = held(&store, expected);
389 if let Ok(held) = &held {
390 read.insert(
391 child.clone(),
392 Read {
393 listed: entry.listed,
394 header: bytes[..1024].iter().map(|b| format!("{b:02x}")).collect(),
395 length: bytes.len() as u64,
396 held: held.clone(),
397 },
398 );
399 }
400 (store.header.file_id, held)
263 }401 }
264 };402 };
265 if bytes.len() > limits.bytes_per_file {403 match held {
266 return Err(Error::Limit { path: child });404 Ok(Held::Section {
267 }405 name,
268 let store = Store::parse(&bytes).map_err(|error| Error::Document {406 color,
269 path: child.clone(),407 document,
270 error,408 }) => result.sections.push(Section {
271 })?;409 path: child.clone(),
272 if store.header.file_type != expected || !store.checksum_mismatches.is_empty() {410 file_id,
273 return Err(Error::Document {411 state: SectionState::Readable {
274 path: child,412 name,
275 error: onestore::Error {413 color,
276 offset: 0,414 document,
277 message: "Unexpected file type or checksum mismatch",
278 },415 },
279 });416 }),
280 }417 Ok(Held::Locked) => result.sections.push(Section {
281 let file_id = store.header.file_id;418 path: child.clone(),
282 let parsed = (|| {419 file_id,
283 let index = RevisionIndex::parse(&store)?;420 state: SectionState::Locked,
284 let document = Document::parse(&index)?;421 }),
285 let revision = document.active(document.root)?;422 Ok(Held::Toc(unresolved)) => {
286 if expected == FileType::Section {
287 if revision
288 .roots
289 .get(&1)
290 .and_then(|id| revision.nodes.get(id))
291 .is_some_and(|node| matches!(node.kind, Kind::Encrypted { .. }))
292 {
293 result.sections.push(Section {
294 path: child.clone(),
295 file_id,
296 state: SectionState::Locked,
297 });
298 return Ok(());
299 }
300 index.validate_current()?;
301 document.pages()?;
302 let (name, color) = revision
303 .roots
304 .get(&2)
305 .and_then(|id| revision.nodes.get(id))
306 .map_or((None, None), |node| match &node.kind {
307 Kind::SectionMetadata { name, color } => (name.clone(), *color),
308 _ => (None, None),
309 });
310 result.sections.push(Section {
311 path: child.clone(),
312 file_id,
313 state: SectionState::Readable {
314 name,
315 color: color.filter(|color| *color != 0xffff_ffff),
316 document: index.root.guid,
317 },
318 });
319 } else {
320 index.validate_current()?;
321 let node = revision.roots.get(&1).and_then(|id| revision.nodes.get(id));
322 let Some(Kind::Toc { entries, .. }) = node.map(|node| &node.kind) else {
323 return Err(onestore::Error {
324 offset: 0,
325 message: "Missing notebook TOC root",
326 });
327 };
328 let mut seen = BTreeSet::new();
329 let mut unresolved = Vec::new();
330 for id in entries {
331 let Some(Kind::Toc {
332 filename,
333 identity: Some(file),
334 order: Some(order),
335 ..
336 }) = revision.nodes.get(id).map(|node| &node.kind)
337 else {
338 return Err(onestore::Error {
339 offset: 0,
340 message: "Incomplete notebook TOC reference",
341 });
342 };
343 if filename.as_deref().is_some_and(|name| !component(name))
344 || !seen.insert(*file)
345 {
346 return Err(onestore::Error {
347 offset: 0,
348 message: "Invalid or duplicated notebook TOC reference",
349 });
350 }
351 unresolved.push(TocReference {
352 filename: filename.clone(),
353 file: *file,
354 order: *order,
355 });
356 }
357 result.toc = Some(Toc {423 result.toc = Some(Toc {
358 filename: entry.name.clone(),424 filename: entry.name.clone(),
359 file_id,425 file_id,
360 unresolved,426 unresolved,
361 });427 })
362 }
363 Ok(())
364 })();
365 if let Err(error) = parsed {
366 if expected == FileType::Section {
367 result.sections.push(Section {
368 path: child.clone(),
369 file_id,
370 state: SectionState::Unreadable(error),
371 });
372 } else {
373 return Err(Error::Document { path: child, error });
374 }428 }
429 Err(error) if expected == FileType::Section => result.sections.push(Section {
430 path: child.clone(),
431 file_id,
432 state: SectionState::Unreadable(error),
433 }),
434 Err(error) => return Err(Error::Document { path: child, error }),
375 }435 }
376 if let Some(first) = identities.insert(file_id, child.clone()) {436 if let Some(first) = identities.insert(file_id, child.clone()) {
377 return Err(Error::DuplicateIdentity {437 return Err(Error::DuplicateIdentity {
...@@ -429,12 +489,83 @@ fn scan(...@@ -429,12 +489,83 @@ fn scan(
429 error,489 error,
430 })?;490 })?;
431 observed.sort();491 observed.sort();
432 if observed != listing {492 let names = |entries: &[Entry]| {
493 entries
494 .iter()
495 .map(|entry| (entry.name.clone(), entry.kind))
496 .collect::<Vec<_>>()
497 };
498 // A file written meanwhile lists otherwise, and is read again next time.
499 if names(&observed) != names(&listing) {
433 return Err(Error::Changed { path: path.into() });500 return Err(Error::Changed { path: path.into() });
434 }501 }
435 Ok(result)502 Ok(result)
436}503}
437504
505/// What discovery takes from the file `store` holds, a section or a TOC as `expected`.
506fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> {
507 let index = RevisionIndex::parse(store)?;
508 let document = Document::parse(&index)?;
509 let revision = document.active(document.root)?;
510 let root = |id| {
511 revision
512 .roots
513 .get(&id)
514 .and_then(|id| revision.nodes.get(id))
515 };
516 if expected == FileType::Section {
517 if root(1).is_some_and(|node| matches!(node.kind, Kind::Encrypted { .. })) {
518 return Ok(Held::Locked);
519 }
520 index.validate_current()?;
521 document.pages()?;
522 let (name, color) = root(2).map_or((None, None), |node| match &node.kind {
523 Kind::SectionMetadata { name, color } => (name.clone(), *color),
524 _ => (None, None),
525 });
526 return Ok(Held::Section {
527 name,
528 color: color.filter(|color| *color != 0xffff_ffff),
529 document: index.root.guid,
530 });
531 }
532 index.validate_current()?;
533 let Some(Kind::Toc { entries, .. }) = root(1).map(|node| &node.kind) else {
534 return Err(onestore::Error {
535 offset: 0,
536 message: "Missing notebook TOC root",
537 });
538 };
539 let mut seen = BTreeSet::new();
540 let mut unresolved = Vec::new();
541 for id in entries {
542 let Some(Kind::Toc {
543 filename,
544 identity: Some(file),
545 order: Some(order),
546 ..
547 }) = revision.nodes.get(id).map(|node| &node.kind)
548 else {
549 return Err(onestore::Error {
550 offset: 0,
551 message: "Incomplete notebook TOC reference",
552 });
553 };
554 if filename.as_deref().is_some_and(|name| !component(name)) || !seen.insert(*file) {
555 return Err(onestore::Error {
556 offset: 0,
557 message: "Invalid or duplicated notebook TOC reference",
558 });
559 }
560 unresolved.push(TocReference {
561 filename: filename.clone(),
562 file: *file,
563 order: *order,
564 });
565 }
566 Ok(Held::Toc(unresolved))
567}
568
438/// Access denied or a file gone mid-listing; a lost connection stays fatal.569/// Access denied or a file gone mid-listing; a lost connection stays fatal.
439fn unavailable(error: &Error) -> bool {570fn unavailable(error: &Error) -> bool {
440 matches!(error, Error::Io { error, .. } if matches!(571 matches!(error, Error::Io { error, .. } if matches!(
crates/notebook/src/discover/source.rs+38-2
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1use super::{Entry, EntryKind, Source, component};1use super::{Entry, EntryKind, Listed, Source, component};
2use std::{2use std::{
3 io,3 io,
4 path::{Path, PathBuf},4 path::{Path, PathBuf},
...@@ -43,6 +43,7 @@ impl Source for Local {...@@ -43,6 +43,7 @@ impl Source for Local {
43 .into_string()43 .into_string()
44 .map_err(|_| io::ErrorKind::InvalidData)?;44 .map_err(|_| io::ErrorKind::InvalidData)?;
45 let kind = entry.file_type()?;45 let kind = entry.file_type()?;
46 let metadata = entry.metadata()?;
46 entries.push(Entry {47 entries.push(Entry {
47 name,48 name,
48 kind: if kind.is_dir() {49 kind: if kind.is_dir() {
...@@ -52,6 +53,13 @@ impl Source for Local {...@@ -52,6 +53,13 @@ impl Source for Local {
52 } else {53 } else {
53 EntryKind::Other54 EntryKind::Other
54 },55 },
56 listed: Listed {
57 size: metadata.len(),
58 modified: metadata
59 .modified()?
60 .duration_since(std::time::UNIX_EPOCH)
61 .map_or(0, |since| since.as_nanos() as u64),
62 },
55 });63 });
56 }64 }
57 Ok(entries)65 Ok(entries)
...@@ -67,6 +75,8 @@ impl Source for Local {...@@ -67,6 +75,8 @@ impl Source for Local {
67pub struct Smb<'a> {75pub struct Smb<'a> {
68 client: &'a crate::smb::Client,76 client: &'a crate::smb::Client,
69 root: String,77 root: String,
78 /// Where the sections' replicas are, named by file identity.
79 copies: Option<PathBuf>,
70}80}
7181
72#[cfg(feature = "smb")]82#[cfg(feature = "smb")]
...@@ -76,7 +86,19 @@ impl<'a> Smb<'a> {...@@ -76,7 +86,19 @@ impl<'a> Smb<'a> {
76 if !root.is_empty() && !root.split('/').all(component) {86 if !root.is_empty() && !root.split('/').all(component) {
77 return Err(io::ErrorKind::InvalidInput.into());87 return Err(io::ErrorKind::InvalidInput.into());
78 }88 }
79 Ok(Self { client, root })89 Ok(Self {
90 client,
91 root,
92 copies: None,
93 })
94 }
95
96 /// Takes a section whose file stands as its replica's base in `folder` from the replica.
97 pub(crate) fn copies(self, folder: PathBuf) -> Self {
98 Self {
99 copies: Some(folder),
100 ..self
101 }
80 }102 }
81103
82 fn path(&self, relative: &str) -> io::Result<String> {104 fn path(&self, relative: &str) -> io::Result<String> {
...@@ -107,6 +129,10 @@ impl Source for Smb<'_> {...@@ -107,6 +129,10 @@ impl Source for Smb<'_> {
107 } else {129 } else {
108 EntryKind::File130 EntryKind::File
109 },131 },
132 listed: Listed {
133 size: entry.size,
134 modified: entry.modified,
135 },
110 })136 })
111 .collect())137 .collect())
112 }138 }
...@@ -118,4 +144,14 @@ impl Source for Smb<'_> {...@@ -118,4 +144,14 @@ impl Source for Smb<'_> {
118 fn read_asset(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {144 fn read_asset(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
119 self.client.read_asset(&self.path(path)?, limit)145 self.client.read_asset(&self.path(path)?, limit)
120 }146 }
147
148 /// Reads the file's stamp, then its replica's base if that has the same.
149 fn copy(&mut self, path: &str, known: [u8; 16]) -> Option<Vec<u8>> {
150 let replica = crate::session::replica_file(self.copies.as_ref()?, &known);
151 if !replica.exists() {
152 return None;
153 }
154 let stamp = self.client.stamp(&self.path(path).ok()?).ok()?;
155 crate::copied(&replica, &stamp).ok().flatten()
156 }
121}157}
crates/notebook/src/lib.rs+32-2
...@@ -99,6 +99,20 @@ pub struct Replica {...@@ -99,6 +99,20 @@ pub struct Replica {
99 worker: Arc<Mutex<std::sync::Weak<worker::Signal>>>,99 worker: Arc<Mutex<std::sync::Weak<worker::Signal>>>,
100 /// The section's root object space, which names the document.100 /// The section's root object space, which names the document.
101 root: ExGuid,101 root: ExGuid,
102 /// Last, so that it runs once the fields above have closed the cache.
103 released: Released,
104}
105
106/// Runs once the replica is released and its cache closed, as `Background::hold` asks.
107#[derive(Default)]
108struct Released(Mutex<Option<Box<dyn FnOnce() + Send>>>);
109
110impl Drop for Released {
111 fn drop(&mut self) {
112 if let Some(released) = self.0.get_mut().ok().and_then(Option::take) {
113 released();
114 }
115 }
102}116}
103117
104impl Replica {118impl Replica {
...@@ -210,6 +224,7 @@ impl Replica {...@@ -210,6 +224,7 @@ impl Replica {
210 synchronization: Mutex::new(()),224 synchronization: Mutex::new(()),
211 worker,225 worker,
212 root,226 root,
227 released: Released::default(),
213 })228 })
214 }229 }
215230
...@@ -340,8 +355,8 @@ fn validate(source: &[u8]) -> Result<ExGuid> {...@@ -340,8 +355,8 @@ fn validate(source: &[u8]) -> Result<ExGuid> {
340 Ok(onestore::Section::open(&arena, source.to_vec())?.root())355 Ok(onestore::Section::open(&arena, source.to_vec())?.root())
341}356}
342357
343/// A closed cache's base stamp and how many edits wait, without opening the replica.358/// A closed cache, without opening the replica.
344fn peek(path: &Path) -> Result<(onestore::Stamp, u64)> {359fn closed(path: &Path) -> Result<Connection> {
345 let connection = cache_connection(path)?;360 let connection = cache_connection(path)?;
346 let application: u32 =361 let application: u32 =
347 connection.pragma_query_value(None, "application_id", |row| row.get(0))?;362 connection.pragma_query_value(None, "application_id", |row| row.get(0))?;
...@@ -349,12 +364,27 @@ fn peek(path: &Path) -> Result<(onestore::Stamp, u64)> {...@@ -349,12 +364,27 @@ fn peek(path: &Path) -> Result<(onestore::Stamp, u64)> {
349 if application != APPLICATION_ID || version != schema::VERSION {364 if application != APPLICATION_ID || version != schema::VERSION {
350 return Err(io::Error::from(io::ErrorKind::InvalidData).into());365 return Err(io::Error::from(io::ErrorKind::InvalidData).into());
351 }366 }
367 Ok(connection)
368}
369
370/// A closed cache's base stamp and how many edits wait.
371fn peek(path: &Path) -> Result<(onestore::Stamp, u64)> {
372 let connection = closed(path)?;
352 let base = base::stamp(&connection, base::Image::Base)?373 let base = base::stamp(&connection, base::Image::Base)?
353 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image"))?;374 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image"))?;
354 let queued: i64 = connection.query_row("SELECT count(*) FROM edits", [], |row| row.get(0))?;375 let queued: i64 = connection.query_row("SELECT count(*) FROM edits", [], |row| row.get(0))?;
355 Ok((base, unsigned(queued)?))376 Ok((base, unsigned(queued)?))
356}377}
357378
379/// A closed cache's base image, if its stamp is `stamp`: the section file as it stands.
380fn copied(path: &Path, stamp: &onestore::Stamp) -> Result<Option<Vec<u8>>> {
381 let connection = closed(path)?;
382 if base::stamp(&connection, base::Image::Base)?.as_ref() != Some(stamp) {
383 return Ok(None);
384 }
385 base::read(&connection, base::Image::Base)
386}
387
358fn pending(connection: &Connection) -> Result<Vec<PendingEdit>> {388fn pending(connection: &Connection) -> Result<Vec<PendingEdit>> {
359 queue::load(connection, None)389 queue::load(connection, None)
360}390}
crates/notebook/src/session.rs+100-31
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1//! The application's view of a notebook: sections opened through a local replica that1//! The application's view of a notebook: sections opened through a local replica that
2//! publishes their edits to the section file in the background.2//! publishes their edits to the section file in the background.
33
4pub use crate::background::Background;4pub use crate::background::{Background, Known};
5use crate::{5use crate::{
6 EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover,6 EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover,
7};7};
...@@ -72,7 +72,14 @@ fn entries(folder: &discover::Folder) -> Entries {...@@ -72,7 +72,14 @@ fn entries(folder: &discover::Folder) -> Entries {
72/// Where a notebook's files live: a mounted directory or an SMB share. Paths are catalog72/// Where a notebook's files live: a mounted directory or an SMB share. Paths are catalog
73/// paths, `/`-separated and relative to the notebook root.73/// paths, `/`-separated and relative to the notebook root.
74pub trait Storage: Send + Sync {74pub trait Storage: Send + Sync {
75 fn discover(&self, limits: discover::Limits) -> Result<discover::Folder>;75 /// Reads the notebook's catalog, reading only the files `cache` holds as listed otherwise.
76 fn discover(
77 &self,
78 cache: &mut discover::Cache,
79 limits: discover::Limits,
80 ) -> Result<discover::Folder>;
81 /// Where the notebook lives, which names its catalog's cache.
82 fn location(&self) -> String;
76 fn exists(&self, path: &str) -> bool;83 fn exists(&self, path: &str) -> bool;
77 fn read(&self, path: &str) -> Result<Vec<u8>>;84 fn read(&self, path: &str) -> Result<Vec<u8>>;
78 /// Creates a file holding `bytes`; an existing file is an error.85 /// Creates a file holding `bytes`; an existing file is an error.
...@@ -110,11 +117,16 @@ impl Directory {...@@ -110,11 +117,16 @@ impl Directory {
110}117}
111118
112impl Storage for Directory {119impl Storage for Directory {
113 fn discover(&self, limits: discover::Limits) -> Result<discover::Folder> {120 fn discover(
114 Ok(discover::discover(121 &self,
115 &mut discover::Local::open(&self.0)?,122 cache: &mut discover::Cache,
116 limits,123 limits: discover::Limits,
117 )?)124 ) -> Result<discover::Folder> {
125 Ok(cache.discover(&mut discover::Local::open(&self.0)?, limits)?)
126 }
127
128 fn location(&self) -> String {
129 self.0.to_string_lossy().into_owned()
118 }130 }
119131
120 fn exists(&self, path: &str) -> bool {132 fn exists(&self, path: &str) -> bool {
...@@ -162,6 +174,8 @@ impl Storage for Directory {...@@ -162,6 +174,8 @@ impl Storage for Directory {
162pub struct Share {174pub struct Share {
163 client: Arc<crate::smb::Client>,175 client: Arc<crate::smb::Client>,
164 root: String,176 root: String,
177 /// Where the sections' replicas are.
178 copies: PathBuf,
165}179}
166180
167#[cfg(feature = "smb")]181#[cfg(feature = "smb")]
...@@ -177,11 +191,17 @@ impl Share {...@@ -177,11 +191,17 @@ impl Share {
177191
178#[cfg(feature = "smb")]192#[cfg(feature = "smb")]
179impl Storage for Share {193impl Storage for Share {
180 fn discover(&self, limits: discover::Limits) -> Result<discover::Folder> {194 fn discover(
181 Ok(discover::discover(195 &self,
182 &mut discover::Smb::new(&self.client, &self.root)?,196 cache: &mut discover::Cache,
183 limits,197 limits: discover::Limits,
184 )?)198 ) -> Result<discover::Folder> {
199 let mut source = discover::Smb::new(&self.client, &self.root)?.copies(self.copies.clone());
200 Ok(cache.discover(&mut source, limits)?)
201 }
202
203 fn location(&self) -> String {
204 format!("{}/{}", self.client.share(), self.root)
185 }205 }
186206
187 fn exists(&self, path: &str) -> bool {207 fn exists(&self, path: &str) -> bool {
...@@ -240,6 +260,9 @@ pub struct Notebook {...@@ -240,6 +260,9 @@ pub struct Notebook {
240 root: Option<PathBuf>,260 root: Option<PathBuf>,
241 cache: PathBuf,261 cache: PathBuf,
242 catalog: discover::Folder,262 catalog: discover::Folder,
263 /// What reading the catalog took from each file, kept at `listing` in the cache.
264 read: discover::Cache,
265 listing: PathBuf,
243}266}
244267
245impl Notebook {268impl Notebook {
...@@ -274,7 +297,16 @@ impl Notebook {...@@ -274,7 +297,16 @@ impl Notebook {
274 cache: impl AsRef<Path>,297 cache: impl AsRef<Path>,
275 ) -> Result<Self> {298 ) -> Result<Self> {
276 let root = root.replace('\\', "/");299 let root = root.replace('\\', "/");
277 Self::with(Box::new(Share { client, root }), None, cache)300 let copies = cache.as_ref().join("smb");
301 Self::with(
302 Box::new(Share {
303 client,
304 root,
305 copies,
306 }),
307 None,
308 cache,
309 )
278 }310 }
279311
280 fn with(312 fn with(
...@@ -283,14 +315,40 @@ impl Notebook {...@@ -283,14 +315,40 @@ impl Notebook {
283 cache: impl AsRef<Path>,315 cache: impl AsRef<Path>,
284 ) -> Result<Self> {316 ) -> Result<Self> {
285 let cache = cache.as_ref().to_path_buf();317 let cache = cache.as_ref().to_path_buf();
286 std::fs::create_dir_all(&cache)?;318 let listings = cache.join("listings");
287 let catalog = storage.discover(LIMITS)?;319 std::fs::create_dir_all(&listings)?;
288 Ok(Self {320 let name: String = <sha2::Sha256 as sha2::Digest>::digest(storage.location())[..16]
321 .iter()
322 .map(|byte| format!("{byte:02x}"))
323 .collect();
324 let listing = listings.join(format!("{name}.json"));
325 let mut read = std::fs::read(&listing)
326 .ok()
327 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
328 .unwrap_or_default();
329 let catalog = storage.discover(&mut read, LIMITS)?;
330 let notebook = Self {
289 storage,331 storage,
290 root,332 root,
291 cache,333 cache,
292 catalog,334 catalog,
293 })335 read,
336 listing,
337 };
338 notebook.keep_listing();
339 Ok(notebook)
340 }
341
342 /// Keeps what the catalog read for the next time the notebook opens; failing costs only
343 /// reading the files again.
344 fn keep_listing(&self) {
345 let _ = (|| -> Result<()> {
346 let folder = self.listing.parent().unwrap_or(Path::new("."));
347 let mut file = tempfile::NamedTempFile::new_in(folder)?;
348 serde_json::to_writer(&mut file, &self.read).map_err(io::Error::from)?;
349 file.persist(&self.listing).map_err(|error| error.error)?;
350 Ok(())
351 })();
294 }352 }
295353
296 pub fn catalog(&self) -> &discover::Folder {354 pub fn catalog(&self) -> &discover::Folder {
...@@ -301,7 +359,8 @@ impl Notebook {...@@ -301,7 +359,8 @@ impl Notebook {
301 /// file identity so a renamed or moved section stays the same section. A failed read359 /// file identity so a renamed or moved section stays the same section. A failed read
302 /// keeps the previous catalog: an unreachable notebook is not an empty one.360 /// keeps the previous catalog: an unreachable notebook is not an empty one.
303 pub fn refresh(&mut self) -> Result<Vec<Change>> {361 pub fn refresh(&mut self) -> Result<Vec<Change>> {
304 let catalog = self.storage.discover(LIMITS)?;362 let catalog = self.storage.discover(&mut self.read, LIMITS)?;
363 self.keep_listing();
305 let (before, before_orders) = entries(&self.catalog);364 let (before, before_orders) = entries(&self.catalog);
306 let (after, after_orders) = entries(&catalog);365 let (after, after_orders) = entries(&catalog);
307 let mut changes = Vec::new();366 let mut changes = Vec::new();
...@@ -900,15 +959,18 @@ impl Notebook {...@@ -900,15 +959,18 @@ impl Notebook {
900 })959 })
901 }960 }
902961
903 /// Every readable section's catalog path and replica, for `Background::watch`.962 /// Every readable section, for `Background::watch`.
904 pub fn replicas(&self) -> Vec<(String, Option<PathBuf>)> {963 pub fn replicas(&self) -> Vec<Known> {
905 let mut sections = Vec::new();964 let mut sections = Vec::new();
906 let mut folders = vec![&self.catalog];965 let mut folders = vec![&self.catalog];
907 while let Some(folder) = folders.pop() {966 while let Some(folder) = folders.pop() {
908 for section in &folder.sections {967 for section in &folder.sections {
909 if matches!(section.state, discover::SectionState::Readable { .. }) {968 if matches!(section.state, discover::SectionState::Readable { .. }) {
910 let replica = self.replica_path(&section.path).ok();969 sections.push(Known {
911 sections.push((section.path.clone(), replica));970 path: section.path.clone(),
971 replica: self.replica_path(&section.path).ok(),
972 found: self.read.found(&section.path),
973 });
912 }974 }
913 }975 }
914 folders.extend(folder.groups.iter().rev());976 folders.extend(folder.groups.iter().rev());
...@@ -917,23 +979,24 @@ impl Notebook {...@@ -917,23 +979,24 @@ impl Notebook {
917 }979 }
918980
919 /// Keeps the sections of a mounted notebook in sync while they are not open981 /// Keeps the sections of a mounted notebook in sync while they are not open
920 /// (`Background`): each file is checked every `interval`, and sooner when982 /// (`Background`): a file is checked when `Background::touched` reports it changed, and
921 /// `Background::touched` reports it changed. `copies` keeps an offline copy of every983 /// otherwise every `Background::BACKSTOP` when `watched`, as a host that watches the
922 /// section, for a folder that is not on this computer.984 /// folder reports every change, or else every `Background::UNWATCHED`. `copies` keeps an
985 /// offline copy of every section, for a folder that is not on this computer.
923 pub fn background(986 pub fn background(
924 &self,987 &self,
925 interval: Duration,988 watched: bool,
926 copies: bool,989 copies: bool,
927 notify: impl Fn() + Send + 'static,990 notify: impl Fn() + Send + 'static,
928 ) -> Result<Background> {991 ) -> Result<Background> {
929 self.background_with(interval, copies, |file| FileRemote(file.to_owned()), notify)992 self.background_with(watched, copies, |file| FileRemote(file.to_owned()), notify)
930 }993 }
931994
932 /// `background`, reaching each section file through the remote `remote` makes for it995 /// `background`, reaching each section file through the remote `remote` makes for it
933 /// (`section_with`).996 /// (`section_with`).
934 pub fn background_with<R: Remote + 'static>(997 pub fn background_with<R: Remote + 'static>(
935 &self,998 &self,
936 interval: Duration,999 watched: bool,
937 copies: bool,1000 copies: bool,
938 remote: impl Fn(&Path) -> R + Clone + Send + 'static,1001 remote: impl Fn(&Path) -> R + Clone + Send + 'static,
939 notify: impl Fn() + Send + 'static,1002 notify: impl Fn() + Send + 'static,
...@@ -948,8 +1011,14 @@ impl Notebook {...@@ -948,8 +1011,14 @@ impl Notebook {
948 Background::start(1011 Background::start(
949 copies,1012 copies,
950 move |_| {1013 move |_| {
951 let (root, remote) = (root.clone(), remote.clone());1014 let (folder, remote) = (root.clone(), remote.clone());
952 Ok((move |path: &str| remote(&root.join(path)), interval))1015 let bind = move |path: &str| remote(&folder.join(path));
1016 let mut local = discover::Local::open(&root)?;
1017 let list = move |folder: &str| {
1018 use discover::Source;
1019 local.entries(folder, LIMITS.entries)
1020 };
1021 Ok(((bind, list), watched))
953 },1022 },
954 notify,1023 notify,
955 )1024 )
...@@ -1085,7 +1154,7 @@ fn catalog_path(folder: &str, name: &str) -> String {...@@ -1085,7 +1154,7 @@ fn catalog_path(folder: &str, name: &str) -> String {
1085}1154}
10861155
1087/// The replica in `cache` of the section `identity` names.1156/// The replica in `cache` of the section `identity` names.
1088fn replica_file(cache: &Path, identity: &[u8; 16]) -> PathBuf {1157pub(crate) fn replica_file(cache: &Path, identity: &[u8; 16]) -> PathBuf {
1089 let name: String = identity.iter().map(|byte| format!("{byte:02x}")).collect();1158 let name: String = identity.iter().map(|byte| format!("{byte:02x}")).collect();
1090 cache.join(format!("{name}.sqlite"))1159 cache.join(format!("{name}.sqlite"))
1091}1160}
crates/notebook/src/smb/directory.rs+8-2
...@@ -9,6 +9,8 @@ use std::collections::BTreeMap;...@@ -9,6 +9,8 @@ use std::collections::BTreeMap;
9pub struct DirectoryEntry {9pub struct DirectoryEntry {
10 pub name: String,10 pub name: String,
11 pub size: u64,11 pub size: u64,
12 /// LastWriteTime, FILETIME.
13 pub modified: u64,
12 /// MS-FSCC file attributes; directory is 0x10 and reparse point is 0x400.14 /// MS-FSCC file attributes; directory is 0x10 and reparse point is 0x400.
13 pub attributes: u32,15 pub attributes: u32,
14}16}
...@@ -71,7 +73,7 @@ impl Client {...@@ -71,7 +73,7 @@ impl Client {
71 };73 };
72 for entry in decode(&response.output_buffer)? {74 for entry in decode(&response.output_buffer)? {
73 if entries75 if entries
74 .insert(entry.name, (entry.size, entry.attributes))76 .insert(entry.name, (entry.size, entry.modified, entry.attributes))
75 .is_some()77 .is_some()
76 {78 {
77 return Err(io::ErrorKind::ResourceBusy.into());79 return Err(io::ErrorKind::ResourceBusy.into());
...@@ -89,9 +91,10 @@ impl Client {...@@ -89,9 +91,10 @@ impl Client {
89 Ok(entries91 Ok(entries
90 .into_iter()92 .into_iter()
91 .filter(|(name, _)| name != "." && name != "..")93 .filter(|(name, _)| name != "." && name != "..")
92 .map(|(name, (size, attributes))| DirectoryEntry {94 .map(|(name, (size, modified, attributes))| DirectoryEntry {
93 name,95 name,
94 size,96 size,
97 modified,
95 attributes,98 attributes,
96 })99 })
97 .collect())100 .collect())
...@@ -199,6 +202,7 @@ fn decode(mut bytes: &[u8]) -> io::Result<Vec<DirectoryEntry>> {...@@ -199,6 +202,7 @@ fn decode(mut bytes: &[u8]) -> io::Result<Vec<DirectoryEntry>> {
199 entries.push(DirectoryEntry {202 entries.push(DirectoryEntry {
200 name,203 name,
201 size: size.try_into().map_err(|_| io::ErrorKind::InvalidData)?,204 size: size.try_into().map_err(|_| io::ErrorKind::InvalidData)?,
205 modified: u64::from_le_bytes(bytes[24..32].try_into().unwrap()),
202 attributes: u32::from_le_bytes(bytes[56..60].try_into().unwrap()),206 attributes: u32::from_le_bytes(bytes[56..60].try_into().unwrap()),
203 });207 });
204 if next == 0 {208 if next == 0 {
...@@ -214,6 +218,7 @@ mod tests {...@@ -214,6 +218,7 @@ mod tests {
214218
215 fn record(name: &str, size: u64, attributes: u32) -> Vec<u8> {219 fn record(name: &str, size: u64, attributes: u32) -> Vec<u8> {
216 let mut bytes = vec![0; 64];220 let mut bytes = vec![0; 64];
221 bytes[24..32].copy_from_slice(&(size + 7).to_le_bytes());
217 bytes[40..48].copy_from_slice(&size.to_le_bytes());222 bytes[40..48].copy_from_slice(&size.to_le_bytes());
218 bytes[56..60].copy_from_slice(&attributes.to_le_bytes());223 bytes[56..60].copy_from_slice(&attributes.to_le_bytes());
219 let name: Vec<_> = name.encode_utf16().flat_map(u16::to_le_bytes).collect();224 let name: Vec<_> = name.encode_utf16().flat_map(u16::to_le_bytes).collect();
...@@ -240,6 +245,7 @@ mod tests {...@@ -240,6 +245,7 @@ mod tests {
240 expected.push(DirectoryEntry {245 expected.push(DirectoryEntry {
241 name,246 name,
242 size,247 size,
248 modified: size + 7,
243 attributes,249 attributes,
244 });250 });
245 }251 }
crates/notebook/src/smb/mod.rs+5
...@@ -48,6 +48,11 @@ pub struct Client {...@@ -48,6 +48,11 @@ pub struct Client {
48}48}
4949
50impl Client {50impl Client {
51 /// The server and share, as `//server/share`.
52 pub(crate) fn share(&self) -> String {
53 format!("//{}/{}", self.tree.server, self.tree.share_name)
54 }
55
51 pub fn connect(56 pub fn connect(
52 address: &str,57 address: &str,
53 share: &str,58 share: &str,
crates/notebook/src/worker.rs+20-3
...@@ -18,6 +18,9 @@ pub(super) struct Signal {...@@ -18,6 +18,9 @@ pub(super) struct Signal {
18 synced: AtomicU64,18 synced: AtomicU64,
19 /// Asked for by `wake`: steps run while offline until one leaves nothing to do.19 /// Asked for by `wake`: steps run while offline until one leaves nothing to do.
20 pub(crate) requested: AtomicBool,20 pub(crate) requested: AtomicBool,
21 /// A watch on the file's folder wakes the worker when the file changes, so an idle worker
22 /// waits for that instead of checking the file on its own (`Background::hold`).
23 pub(crate) watched: AtomicBool,
21 sender: SyncSender<()>,24 sender: SyncSender<()>,
22}25}
2326
...@@ -29,6 +32,7 @@ impl Signal {...@@ -29,6 +32,7 @@ impl Signal {
29 offline: AtomicBool::new(false),32 offline: AtomicBool::new(false),
30 synced: AtomicU64::new(0),33 synced: AtomicU64::new(0),
31 requested: AtomicBool::new(false),34 requested: AtomicBool::new(false),
35 watched: AtomicBool::new(false),
32 sender,36 sender,
33 };37 };
34 (Arc::new(signal), receiver)38 (Arc::new(signal), receiver)
...@@ -91,7 +95,8 @@ impl Drop for SyncWorker {...@@ -91,7 +95,8 @@ impl Drop for SyncWorker {
9195
92impl Replica {96impl Replica {
93 /// Starts one worker, reconnecting through `connect` after transport failures.97 /// Starts one worker, reconnecting through `connect` after transport failures.
94 /// Local edits wake it; `interval` controls idle polling and transport retries. While98 /// Local edits wake it; `interval` controls idle polling, unless a watch wakes it
99 /// (`Background::hold`), and transport retries. While
95 /// nothing is queued, or the queue waits on a remote that has not changed since, a100 /// nothing is queued, or the queue waits on a remote that has not changed since, a
96 /// remote whose `stamp` holds is not read again.101 /// remote whose `stamp` holds is not read again.
97 /// Contended operations returning `NotCommitted` also back off by up to one second.102 /// Contended operations returning `NotCommitted` also back off by up to one second.
...@@ -134,6 +139,15 @@ impl Replica {...@@ -134,6 +139,15 @@ impl Replica {
134 // The first step, and the first after a failure, always runs, so `observe`139 // The first step, and the first after a failure, always runs, so `observe`
135 // hears that the remote is reachable and what state the queue is in.140 // hears that the remote is reachable and what state the queue is in.
136 let mut reported = false;141 let mut reported = false;
142 // With nothing to do: a watched worker waits for the watch, any other looks
143 // again after `interval`.
144 let rest = || {
145 if worker_signal.watched.load(Ordering::Acquire) {
146 let _ = receiver.recv();
147 } else {
148 let _ = receiver.recv_timeout(interval);
149 }
150 };
137 while !worker_signal.stopped.load(Ordering::Acquire) {151 while !worker_signal.stopped.load(Ordering::Acquire) {
138 if worker_signal.offline.load(Ordering::Acquire)152 if worker_signal.offline.load(Ordering::Acquire)
139 && !worker_signal.requested.load(Ordering::Acquire)153 && !worker_signal.requested.load(Ordering::Acquire)
...@@ -148,7 +162,7 @@ impl Replica {...@@ -148,7 +162,7 @@ impl Replica {
148 if reported && replica.settled(remote).unwrap_or(false) {162 if reported && replica.settled(remote).unwrap_or(false) {
149 worker_signal.requested.store(false, Ordering::Release);163 worker_signal.requested.store(false, Ordering::Release);
150 worker_signal.synced.store(crate::now(), Ordering::Release);164 worker_signal.synced.store(crate::now(), Ordering::Release);
151 let _ = receiver.recv_timeout(interval);165 rest();
152 continue;166 continue;
153 }167 }
154 let result = replica.sync_once(remote);168 let result = replica.sync_once(remote);
...@@ -167,6 +181,7 @@ impl Replica {...@@ -167,6 +181,7 @@ impl Replica {
167 worker_signal.synced.store(crate::now(), Ordering::Release);181 worker_signal.synced.store(crate::now(), Ordering::Release);
168 }182 }
169 observe(&result);183 observe(&result);
184 let idle = matches!(result, Ok(Synced { edit: None, .. }));
170 match result {185 match result {
171 Ok(Synced {186 Ok(Synced {
172 edit: Some((_, EditStatus::Published { .. })),187 edit: Some((_, EditStatus::Published { .. })),
...@@ -209,7 +224,9 @@ impl Replica {...@@ -209,7 +224,9 @@ impl Replica {
209 Err(error) => return Err(error),224 Err(error) => return Err(error),
210 }225 }
211 worker_signal.requested.store(false, Ordering::Release);226 worker_signal.requested.store(false, Ordering::Release);
212 if !worker_signal.stopped.load(Ordering::Acquire) {227 if idle {
228 rest();
229 } else if !worker_signal.stopped.load(Ordering::Acquire) {
213 let _ = receiver.recv_timeout(interval);230 let _ = receiver.recv_timeout(interval);
214 }231 }
215 }232 }
crates/notebook/tests/discovery.rs+80-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1use notebook::discover::{Entry, Error, Limits, Local, Source, discover};1use notebook::discover::{Cache, Entry, Error, Limits, Local, Source, discover};
2use std::{fs, io, path::Path};2use std::{fs, io, path::Path};
33
4fn limits() -> Limits {4fn limits() -> Limits {
...@@ -357,3 +357,82 @@ fn unreadable_children_are_listed_as_unavailable_and_retried() {...@@ -357,3 +357,82 @@ fn unreadable_children_are_listed_as_unavailable_and_retried() {
357 fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap();357 fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap();
358 assert!(denied.is_err());358 assert!(denied.is_err());
359}359}
360
361/// A local folder that notes each file read, with copies of some files by identity.
362struct Counting {
363 source: Local,
364 read: Vec<String>,
365 copies: Vec<([u8; 16], Vec<u8>)>,
366}
367
368impl Source for Counting {
369 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> {
370 self.source.entries(path, limit)
371 }
372
373 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
374 self.read.push(path.to_owned());
375 self.source.read(path, limit)
376 }
377
378 fn copy(&mut self, _: &str, known: [u8; 16]) -> Option<Vec<u8>> {
379 let (_, copy) = self
380 .copies
381 .iter()
382 .find(|(identity, _)| *identity == known)?;
383 Some(copy.clone())
384 }
385}
386
387#[test]
388fn a_cached_discovery_reads_only_the_files_listed_otherwise() {
389 let root = tempfile::tempdir().unwrap();
390 fixture(root.path());
391 let two = |title| onestore::create_section("two.one", title, "Fixture").unwrap();
392 fs::write(root.path().join("two.one"), two("Two")).unwrap();
393 let mut source = Counting {
394 source: Local::open(root.path()).unwrap(),
395 read: Vec::new(),
396 copies: Vec::new(),
397 };
398 let mut cache = Cache::default();
399 let first = cache.discover(&mut source, limits()).unwrap();
400 assert_eq!(std::mem::take(&mut source.read).len(), 3);
401 let again = cache.discover(&mut source, limits()).unwrap();
402 assert!(source.read.is_empty(), "{:?}", source.read);
403 assert_eq!(
404 serde_json::to_value(&again).unwrap(),
405 serde_json::to_value(&first).unwrap()
406 );
407
408 let changed = two("Two, changed");
409 fs::write(root.path().join("two.one"), &changed).unwrap();
410 let after = cache.discover(&mut source, limits()).unwrap();
411 assert_eq!(std::mem::take(&mut source.read), ["two.one"]);
412 let fresh = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();
413 assert_eq!(
414 serde_json::to_value(&after).unwrap(),
415 serde_json::to_value(&fresh).unwrap()
416 );
417 assert!(cache.found("two.one").unwrap().1 == onestore::Stamp::of(&changed).unwrap());
418
419 // Kept across launches, it reads nothing unchanged.
420 let mut kept: Cache = serde_json::from_slice(&serde_json::to_vec(&cache).unwrap()).unwrap();
421 kept.discover(&mut source, limits()).unwrap();
422 assert!(source.read.is_empty(), "{:?}", source.read);
423
424 // A file written as a copy of it holds, as a replica its own edits published from, is
425 // taken from the copy.
426 let copied = two("Two, published");
427 fs::write(root.path().join("two.one"), &copied).unwrap();
428 let identity = onestore::Store::parse(&changed).unwrap().header.file_id;
429 source.copies.push((identity, copied.clone()));
430 kept.discover(&mut source, limits()).unwrap();
431 assert!(source.read.is_empty(), "{:?}", source.read);
432 assert!(kept.found("two.one").unwrap().1 == onestore::Stamp::of(&copied).unwrap());
433
434 // A failed discovery leaves the cache as it was.
435 fs::write(root.path().join("one.one"), b"unfinished").unwrap();
436 assert!(kept.discover(&mut source, limits()).is_err());
437 assert!(kept.found("two.one").is_some());
438}
crates/notebook/tests/session.rs+111-13
...@@ -386,6 +386,14 @@ fn a_notebook_directory_lists_its_sections_and_opens_them() {...@@ -386,6 +386,14 @@ fn a_notebook_directory_lists_its_sections_and_opens_them() {
386 section.close().unwrap();386 section.close().unwrap();
387}387}
388388
389/// The replicas in `cache`, beside the folder of catalog listings.
390fn replicas(cache: &Path) -> usize {
391 std::fs::read_dir(cache)
392 .unwrap()
393 .filter(|entry| entry.as_ref().unwrap().path().is_file())
394 .count()
395}
396
389#[test]397#[test]
390fn a_notebook_only_opens_discovered_section_paths() {398fn a_notebook_only_opens_discovered_section_paths() {
391 let directory = tempfile::tempdir().unwrap();399 let directory = tempfile::tempdir().unwrap();
...@@ -404,7 +412,7 @@ fn a_notebook_only_opens_discovered_section_paths() {...@@ -404,7 +412,7 @@ fn a_notebook_only_opens_discovered_section_paths() {
404 Err(notebook::Error::Io(error)) if error.kind() == std::io::ErrorKind::NotFound412 Err(notebook::Error::Io(error)) if error.kind() == std::io::ErrorKind::NotFound
405 ));413 ));
406 }414 }
407 assert_eq!(std::fs::read_dir(&cache).unwrap().count(), 0);415 assert_eq!(replicas(&cache), 0);
408 assert_eq!(std::fs::read(&outside).unwrap(), source);416 assert_eq!(std::fs::read(&outside).unwrap(), source);
409}417}
410418
...@@ -427,7 +435,7 @@ fn a_catalog_section_replaced_by_an_outside_symlink_is_rejected() {...@@ -427,7 +435,7 @@ fn a_catalog_section_replaced_by_an_outside_symlink_is_rejected() {
427 notebook.section("notes.one", || {}),435 notebook.section("notes.one", || {}),
428 Err(notebook::Error::Io(error)) if error.kind() == std::io::ErrorKind::PermissionDenied436 Err(notebook::Error::Io(error)) if error.kind() == std::io::ErrorKind::PermissionDenied
429 ));437 ));
430 assert_eq!(std::fs::read_dir(&cache).unwrap().count(), 0);438 assert_eq!(replicas(&cache), 0);
431 assert_eq!(std::fs::read(&outside).unwrap(), source);439 assert_eq!(std::fs::read(&outside).unwrap(), source);
432}440}
433441
...@@ -1028,9 +1036,7 @@ fn a_closed_sections_queued_edits_publish_in_the_background_when_online() {...@@ -1028,9 +1036,7 @@ fn a_closed_sections_queued_edits_publish_in_the_background_when_online() {
1028 let directory = tempfile::tempdir().unwrap();1036 let directory = tempfile::tempdir().unwrap();
1029 let notebook = two_sections(directory.path());1037 let notebook = two_sections(directory.path());
1030 let file = directory.path().join("Shared/Second.one");1038 let file = directory.path().join("Shared/Second.one");
1031 let background = notebook1039 let background = notebook.background(false, false, || {}).unwrap();
1032 .background(Duration::from_millis(20), false, || {})
1033 .unwrap();
1034 background.set_offline(true);1040 background.set_offline(true);
1035 background.watch(notebook.replicas());1041 background.watch(notebook.replicas());
1036 // The round started with the background ends before the edit exists.1042 // The round started with the background ends before the edit exists.
...@@ -1083,7 +1089,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {...@@ -1083,7 +1089,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {
1083 let notified = Arc::new(AtomicUsize::new(0));1089 let notified = Arc::new(AtomicUsize::new(0));
1084 let counter = Arc::clone(&notified);1090 let counter = Arc::clone(&notified);
1085 let background = notebook1091 let background = notebook
1086 .background(Duration::from_millis(20), false, move || {1092 .background(true, false, move || {
1087 counter.fetch_add(1, Ordering::SeqCst);1093 counter.fetch_add(1, Ordering::SeqCst);
1088 })1094 })
1089 .unwrap();1095 .unwrap();
...@@ -1107,6 +1113,8 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {...@@ -1107,6 +1113,8 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {
1107 .unwrap();1113 .unwrap();
1108 changed.push((name, space, native));1114 changed.push((name, space, native));
1109 }1115 }
1116 // As a watch reports a folder when it cannot name the file.
1117 background.touched(&[String::new()]);
1110 let mut noticed = Vec::new();1118 let mut noticed = Vec::new();
1111 until("both changes were noticed", || {1119 until("both changes were noticed", || {
1112 noticed.extend(background.changed());1120 noticed.extend(background.changed());
...@@ -1130,9 +1138,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {...@@ -1130,9 +1138,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {
1130fn a_reported_change_wakes_its_section_without_polling() {1138fn a_reported_change_wakes_its_section_without_polling() {
1131 let directory = tempfile::tempdir().unwrap();1139 let directory = tempfile::tempdir().unwrap();
1132 let notebook = two_sections(directory.path());1140 let notebook = two_sections(directory.path());
1133 let background = notebook1141 let background = notebook.background(true, false, || {}).unwrap();
1134 .background(Duration::from_secs(3600), false, || {})
1135 .unwrap();
1136 background.watch(notebook.replicas());1142 background.watch(notebook.replicas());
1137 until("both sections were reached", || {1143 until("both sections were reached", || {
1138 background1144 background
...@@ -1169,12 +1175,10 @@ fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() {...@@ -1169,12 +1175,10 @@ fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() {
1169 let replicas: Vec<_> = notebook1175 let replicas: Vec<_> = notebook
1170 .replicas()1176 .replicas()
1171 .into_iter()1177 .into_iter()
1172 .map(|(_, replica)| replica.unwrap())1178 .map(|known| known.replica.unwrap())
1173 .collect();1179 .collect();
1174 assert!(replicas.iter().all(|replica| !replica.exists()));1180 assert!(replicas.iter().all(|replica| !replica.exists()));
1175 let background = notebook1181 let background = notebook.background(true, true, || {}).unwrap();
1176 .background(Duration::from_secs(3600), true, || {})
1177 .unwrap();
1178 background.watch(notebook.replicas());1182 background.watch(notebook.replicas());
1179 until("both sections have offline copies", || {1183 until("both sections have offline copies", || {
1180 replicas.iter().all(|replica| replica.exists())1184 replicas.iter().all(|replica| replica.exists())
...@@ -1199,3 +1203,97 @@ fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() {...@@ -1199,3 +1203,97 @@ fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() {
1199 let kept = notebook::Replica::open(&second).unwrap();1203 let kept = notebook::Replica::open(&second).unwrap();
1200 assert_eq!(kept.pending().unwrap().len(), 1);1204 assert_eq!(kept.pending().unwrap().len(), 1);
1201}1205}
1206
1207/// A section file whose stamp reads are counted.
1208struct Counted(std::path::PathBuf, Arc<AtomicUsize>);
1209
1210impl notebook::Remote for Counted {
1211 fn read(&mut self) -> std::io::Result<Vec<u8>> {
1212 onestore::read_file(&self.0)
1213 }
1214
1215 fn stamp(&mut self) -> std::io::Result<onestore::Stamp> {
1216 self.1.fetch_add(1, Ordering::SeqCst);
1217 onestore::Stamp::of(&self.read()?).map_err(std::io::Error::other)
1218 }
1219
1220 fn publish(
1221 &mut self,
1222 transaction: &onestore::Transaction,
1223 ) -> Result<(), onestore::CommitError> {
1224 transaction.commit_file(&self.0)
1225 }
1226
1227 fn confirm(&mut self, base: &onestore::Stamp) -> Result<(), onestore::CommitError> {
1228 onestore::confirm_file(&self.0, base)
1229 }
1230}
1231
1232#[test]
1233fn an_open_section_the_background_holds_checks_its_file_when_the_watch_reports_it() {
1234 let directory = tempfile::tempdir().unwrap();
1235 let notebook = two_sections(directory.path());
1236 let background = notebook.background(true, false, || {}).unwrap();
1237 background.watch(notebook.replicas());
1238 until("both sections were reached", || {
1239 background
1240 .status()
1241 .iter()
1242 .all(|(_, status)| status.synced.is_some())
1243 });
1244 let stamps = Arc::new(AtomicUsize::new(0));
1245 let counter = Arc::clone(&stamps);
1246 let section = notebook
1247 .section_with(
1248 "Second.one",
1249 move |file| Ok(Counted(file.to_owned(), Arc::clone(&counter))),
1250 || {},
1251 )
1252 .unwrap();
1253 background.hold("Second.one", &section);
1254 std::thread::sleep(Duration::from_secs(3));
1255 let before = stamps.load(Ordering::SeqCst);
1256 std::thread::sleep(Duration::from_secs(5));
1257 assert_eq!(
1258 stamps.load(Ordering::SeqCst),
1259 before,
1260 "an idle held section reads nothing"
1261 );
1262
1263 let file = directory.path().join("Shared/Second.one");
1264 let bytes = onestore::read_file(&file).unwrap();
1265 let space = notebook::session::stored_pages(&bytes).unwrap()[0].space;
1266 let native = edited(&model_ops::page_of(&bytes, space), "Native ");
1267 ops::save(&bytes, space, &native)
1268 .unwrap()
1269 .commit_file(&file)
1270 .unwrap();
1271 background.touched(&["Second.one".to_owned()]);
1272 let mut changed = Vec::new();
1273 until("the session heard of the change", || {
1274 changed.extend(
1275 section
1276 .events()
1277 .into_iter()
1278 .filter_map(|event| match event {
1279 Event::Changed(spaces) => Some(spaces),
1280 _ => None,
1281 }),
1282 );
1283 !changed.is_empty()
1284 });
1285 assert!(changed.concat().contains(&space));
1286 assert!(background.changed().is_empty(), "the session reports it");
1287
1288 // Once the session stops, the background takes the section over at once.
1289 section.set_offline(true);
1290 let before = section.page(space).unwrap();
1291 typed(&section, space, &before, 0..0, "Closed ");
1292 section.close().unwrap();
1293 let mut after = edited(&before, "Closed ");
1294 until("the background published the closed section's edit", || {
1295 let stored = stored_page(&file, space);
1296 after.title = stored.title.clone();
1297 stored == after
1298 });
1299}
crates/onestore/README.md+2-2
...@@ -45,8 +45,8 @@ harness also accepts `--client-profile release`....@@ -45,8 +45,8 @@ harness also accepts `--client-profile release`.
45| `Document::active`, `Document::pages_in`, `Revision::parents`, `RevisionIndex::active` | Resolve the active revision, the pages of a space and parent links without repeating the lookups |45| `Document::active`, `Document::pages_in`, `Revision::parents`, `RevisionIndex::active` | Resolve the active revision, the pages of a space and parent links without repeating the lookups |
46| `Page::copy` | The page's content under fresh identities for copying into another section, definitions and payloads included; indent levels only an outline group carries refuse |46| `Page::copy` | The page's content under fresh identities for copying into another section, definitions and payloads included; indent levels only an outline group carries refuse |
47| `page::link::internal_link`, `page::link::parse_internal_link` | Build and read the `onenote:#…` URLs OneNote stores for links to sections, pages and paragraphs, by identity |47| `page::link::internal_link`, `page::link::parse_internal_link` | Build and read the `onenote:#…` URLs OneNote stores for links to sections, pages and paragraphs, by identity |
48| `page::Recording`, `page::MediaIndex` | A recording attachment's identity and type, and a paragraph's link to a moment in recordings; read from native pages and kept through edits, never authored |48| `page::Recording`, `page::MediaIndex`, `PageOp::Media` | An audio or video file's recording identity, kind and length, and a paragraph's link to a moment in recordings, as OneNote stores them; the page lists its recordings as they come and go |
49| `page::Page`, `page::Paragraph`, `page::Ink`, `page::Math` | Build an editable page model (title, outlines, paragraphs with coalesced text spans, tables, images, attachments in paragraphs or on the page, ink drawings and handwriting decoded to stroke polylines in page points, a moved drawing offset by its position) with stored identities; equations parse from their linear text and run data into a tree that renders the MathML OneNote exports; content outside the model is retained as `Unsupported` |49| `page::Page`, `page::Paragraph`, `page::Ink`, `page::Math` | Build an editable page model (title, outlines, paragraphs with coalesced text spans, tables, images, attachments in paragraphs or on the page, ink drawings and handwriting decoded to stroke polylines in page points, a moved drawing offset by its position, a highlighter's raster operation, a drawn shape's kind and anchors) with stored identities; equations parse from their linear text and run data into a tree that renders the MathML OneNote exports; content outside the model is retained as `Unsupported` |
50| `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned |50| `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned |
51| `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode |51| `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode |
52| `PageCreation`, `SectionOp::Create` | Add an empty top-level page and its section entry atomically, under identities the intent retains across retries |52| `PageCreation`, `SectionOp::Create` | Add an empty top-level page and its section entry atomically, under identities the intent retains across retries |
crates/onestore/examples/ink_lab.rs deleted-25
...@@ -1,25 +0,0 @@
1use onestore::{RevisionIndex, Store, document::Document};
2fn main() {
3 let path = std::env::args().nth(1).unwrap();
4 let needle = std::env::args().nth(2).unwrap();
5 let bytes = std::fs::read(path).unwrap();
6 let store = Store::parse(&bytes).unwrap();
7 let index = RevisionIndex::parse(&store).unwrap();
8 let document = Document::parse(&index).unwrap();
9 for (space, _) in document.pages().unwrap() {
10 let page = onestore::page::Page::from_space(&document, space).unwrap();
11 if page.title != needle {
12 continue;
13 }
14 if std::env::var_os("INK_MODEL").is_some() {
15 for object in &page.objects {
16 if let onestore::page::PageObject::Ink(ink) = object {
17 println!("{}", serde_json::to_string(ink).unwrap());
18 }
19 }
20 continue;
21 }
22 let revision = document.spaces[&space].active().unwrap();
23 println!("{}", serde_json::to_string_pretty(revision).unwrap());
24 }
25}
crates/onestore/examples/scratch_dump.rs deleted-29
...@@ -1,29 +0,0 @@
1use onestore::{RevisionIndex, Store, document::Document};
2fn main() {
3 let path = std::env::args().nth(1).unwrap();
4 let needle = std::env::args().nth(2).unwrap();
5 let bytes = std::fs::read(path).unwrap();
6 let store = Store::parse(&bytes).unwrap();
7 let index = RevisionIndex::parse(&store).unwrap();
8 let document = Document::parse(&index).unwrap();
9 for (space, _) in document.pages().unwrap() {
10 let page = onestore::page::Page::from_space(&document, space).unwrap();
11 if page.title != needle { continue; }
12 let revision = document.spaces[&space].active().unwrap();
13 println!("{}", serde_json::to_string_pretty(revision).unwrap());
14 if let Ok(out) = std::env::var("PAYLOAD_OUT") {
15 for object in &page.objects {
16 if let onestore::page::PageObject::Outline(o) = object {
17 for p in &o.paragraphs {
18 if let onestore::page::ParagraphContent::Attachment(a) = &p.content {
19 std::fs::write(format!("{out}/{}", a.filename), a.bytes.as_deref().unwrap()).unwrap();
20 if let Some(i) = &a.preview { std::fs::write(format!("{out}/{}.png", a.filename), i).unwrap(); }
21 }
22 }
23 }
24 }
25 }
26 }
27}
28#[allow(dead_code)]
29fn unused() {}
crates/onestore/src/op/apply.rs+10-1
...@@ -1222,7 +1222,16 @@ impl<'a> Writer<'_, 'a> {...@@ -1222,7 +1222,16 @@ impl<'a> Writer<'_, 'a> {
1222 .iter()1222 .iter()
1223 .map(|stroke| (stroke.id, stroke))1223 .map(|stroke| (stroke.id, stroke))
1224 .collect();1224 .collect();
1225 self.write(|page| content::ink_changes(page, ink, ink.id, data, &strokes, holder))1225 self.write(|page| content::ink_changes(page, ink, ink.id, data, &strokes, holder))?;
1226 // A drawing moved before, as undoing its deletion brings it back, keeps its offset.
1227 match (ink.layout.x, ink.layout.y) {
1228 (None, None) => Ok(()),
1229 (Some(x), Some(y)) => {
1230 let edit = OutlineEdit::Position { x, y };
1231 self.write(|page| edit.changes(page, ink.id))
1232 }
1233 _ => Err(OpError::Unsupported("An ink position needs both coordinates").into()),
1234 }
1226 }1235 }
12271236
1228 /// Adds a page object on top, then moves it before `before`.1237 /// Adds a page object on top, then moves it before `before`.
crates/onestore/src/op/content.rs+16-4
...@@ -642,7 +642,11 @@ fn stroke_values(stroke: &InkStroke, index: u32, filetime: u64) -> Result<Values...@@ -642,7 +642,11 @@ fn stroke_values(stroke: &InkStroke, index: u32, filetime: u64) -> Result<Values
642 });642 });
643 let mut origin = ((left - stroke.width / 2.0) / 36.0).to_le_bytes().to_vec();643 let mut origin = ((left - stroke.width / 2.0) / 36.0).to_le_bytes().to_vec();
644 origin.extend_from_slice(&((top - stroke.height / 2.0) / 36.0).to_le_bytes());644 origin.extend_from_slice(&((top - stroke.height / 2.0) / 36.0).to_le_bytes());
645 let bias = if stroke.raster_operation == Some(9) { 2 } else { 0 };645 let bias = if stroke.raster_operation == Some(9) {
646 2
647 } else {
648 0
649 };
646 values.extend([650 values.extend([
647 (0x14003419, index.to_le_bytes().to_vec()),651 (0x14003419, index.to_le_bytes().to_vec()),
648 (0x1000341b, 0x409_u16.to_le_bytes().to_vec()),652 (0x1000341b, 0x409_u16.to_le_bytes().to_vec()),
...@@ -671,8 +675,13 @@ pub(crate) fn ink_changes(...@@ -671,8 +675,13 @@ pub(crate) fn ink_changes(
671 if ink.strokes.is_empty() {675 if ink.strokes.is_empty() {
672 return Err(invalid("New ink needs at least one stroke"));676 return Err(invalid("New ink needs at least one stroke"));
673 }677 }
674 if ink.layout != Default::default() {678 let placed = crate::document::Layout {
675 return Err(invalid("Ink positions come from its strokes"));679 x: None,
680 y: None,
681 ..ink.layout.clone()
682 };
683 if placed != Default::default() {
684 return Err(invalid("Ink sizes come from its strokes"));
676 }685 }
677 let (modified, filetime) = crate::create::current_timestamps()?;686 let (modified, filetime) = crate::create::current_timestamps()?;
678 let modified = modified.to_le_bytes();687 let modified = modified.to_le_bytes();
...@@ -700,7 +709,10 @@ pub(crate) fn ink_changes(...@@ -700,7 +709,10 @@ pub(crate) fn ink_changes(
700 let (kind, property, bytes) = shape.stored();709 let (kind, property, bytes) = shape.stored();
701 values.extend([(0x0c001d4f, vec![kind]), (property, bytes)]);710 values.extend([(0x0c001d4f, vec![kind]), (property, bytes)]);
702 }711 }
703 values.push((0x14001d4e, (1 + u32::from(ink.shape.is_some())).to_le_bytes().to_vec()));712 values.push((
713 0x14001d4e,
714 (1 + u32::from(ink.shape.is_some())).to_le_bytes().to_vec(),
715 ));
704 let mut object = PropertyObject {716 let mut object = PropertyObject {
705 jcid: 0x60014,717 jcid: 0x60014,
706 bytes: crate::create::properties(&values)?,718 bytes: crate::create::properties(&values)?,
crates/onestore/src/page/ink.rs+9-3
...@@ -329,8 +329,10 @@ impl InkShape {...@@ -329,8 +329,10 @@ impl InkShape {
329 match (kind?, line, anchors) {329 match (kind?, line, anchors) {
330 (11, Some(line), _) if line.len() == 16 => {330 (11, Some(line), _) if line.len() == 16 => {
331 let v = floats(line);331 let v = floats(line);
332 Some(Self::Line([[v[0], v[1]], [v[2], v[3]]].map(|end| end.map(|v| v * 36.0))))332 Some(Self::Line(
333 .filter(Self::finite)333 [[v[0], v[1]], [v[2], v[3]]].map(|end| end.map(|v| v * 36.0)),
334 ))
335 .filter(Self::finite)
334 }336 }
335 (12, _, Some(bytes)) if bytes.len() >= 24 && (bytes.len() - 24) % 8 == 0 => {337 (12, _, Some(bytes)) if bytes.len() >= 24 && (bytes.len() - 24) % 8 == 0 => {
336 let v = floats(bytes);338 let v = floats(bytes);
...@@ -358,7 +360,11 @@ impl InkShape {...@@ -358,7 +360,11 @@ impl InkShape {
358 pub(crate) fn stored(&self) -> (u8, u32, Vec<u8>) {360 pub(crate) fn stored(&self) -> (u8, u32, Vec<u8>) {
359 let half = |v: &f32| (v / 36.0).to_le_bytes();361 let half = |v: &f32| (v / 36.0).to_le_bytes();
360 match self {362 match self {
361 Self::Line(ends) => (11, 0x1c001dac, ends.iter().flatten().flat_map(half).collect()),363 Self::Line(ends) => (
364 11,
365 0x1c001dac,
366 ends.iter().flatten().flat_map(half).collect(),
367 ),
362 Self::Closed { transform, anchors } => (368 Self::Closed { transform, anchors } => (
363 12,369 12,
364 0x1c001daa,370 0x1c001daa,
crates/onestore/src/page/text.rs+5
...@@ -172,6 +172,11 @@ pub fn new_id() -> Result<ExGuid, EditError> {...@@ -172,6 +172,11 @@ pub fn new_id() -> Result<ExGuid, EditError> {
172 Ok(id)172 Ok(id)
173}173}
174174
175/// A GUID of its own, as a recording's identity takes (AudioRecordingGuid).
176pub fn new_guid() -> Result<[u8; 16], EditError> {
177 crate::write::fresh_guid().map_err(|_| EditError::Identity)
178}
179
175impl Paragraph {180impl Paragraph {
176 pub fn project(&self) -> Result<TextProjection, EditError> {181 pub fn project(&self) -> Result<TextProjection, EditError> {
177 let mut runs = Vec::new();182 let mut runs = Vec::new();
crates/onestore/tests/page_ink.rs+36-8
...@@ -323,7 +323,12 @@ fn native_pens_highlighters_and_shapes_read_as_drawn() {...@@ -323,7 +323,12 @@ fn native_pens_highlighters_and_shapes_read_as_drawn() {
323 .all(|(a, b)| (a[0] - b[0]).abs() < 0.05 && (a[1] - b[1]).abs() < 0.05)323 .all(|(a, b)| (a[0] - b[0]).abs() < 0.05 && (a[1] - b[1]).abs() < 0.05)
324 };324 };
325 for (native, kind, from, to) in [325 for (native, kind, from, to) in [
326 (rectangle, ShapeKind::Rectangle, [252.0, 86.4], [324.0, 122.4]),326 (
327 rectangle,
328 ShapeKind::Rectangle,
329 [252.0, 86.4],
330 [324.0, 122.4],
331 ),
327 (line, ShapeKind::Line, [342.0, 86.4], [414.0, 122.4]),332 (line, ShapeKind::Line, [342.0, 86.4], [414.0, 122.4]),
328 (arrow, ShapeKind::Arrow, [252.0, 158.4], [324.0, 194.4]),333 (arrow, ShapeKind::Arrow, [252.0, 158.4], [324.0, 194.4]),
329 (ellipse, ShapeKind::Ellipse, [342.0, 158.4], [414.0, 194.4]),334 (ellipse, ShapeKind::Ellipse, [342.0, 158.4], [414.0, 194.4]),
...@@ -368,8 +373,11 @@ fn native_pens_highlighters_and_shapes_read_as_drawn() {...@@ -368,8 +373,11 @@ fn native_pens_highlighters_and_shapes_read_as_drawn() {
368 .map(|ink| (ink.layout.x, ink.layout.y))373 .map(|ink| (ink.layout.x, ink.layout.y))
369 .collect();374 .collect();
370 assert_eq!(moved.len(), 2);375 assert_eq!(moved.len(), 2);
371 assert!(moved.iter().any(|&(x, y)| x == Some(45.0)376 assert!(
372 && y.is_some_and(|y| (y - 15.0).abs() < 1e-4)));377 moved
378 .iter()
379 .any(|&(x, y)| x == Some(45.0) && y.is_some_and(|y| (y - 15.0).abs() < 1e-4))
380 );
373}381}
374382
375fn tool_page(source: &[u8]) -> (ExGuid, Page, Vec<Ink>) {383fn tool_page(source: &[u8]) -> (ExGuid, Page, Vec<Ink>) {
...@@ -418,8 +426,20 @@ fn tool_page(source: &[u8]) -> (ExGuid, Page, Vec<Ink>) {...@@ -418,8 +426,20 @@ fn tool_page(source: &[u8]) -> (ExGuid, Page, Vec<Ink>) {
418 let inks = vec![426 let inks = vec![
419 free(&marker, vec![[60.0, 190.0], [220.0, 190.0]]),427 free(&marker, vec![[60.0, 190.0], [220.0, 190.0]]),
420 free(&accent, wave),428 free(&accent, wave),
421 Ink::drawn(ShapeKind::Rectangle, [252.0, 140.4], [324.0, 194.4], &shape_pen).unwrap(),429 Ink::drawn(
422 Ink::drawn(ShapeKind::Ellipse, [342.0, 140.4], [432.0, 194.4], &shape_pen).unwrap(),430 ShapeKind::Rectangle,
431 [252.0, 140.4],
432 [324.0, 194.4],
433 &shape_pen,
434 )
435 .unwrap(),
436 Ink::drawn(
437 ShapeKind::Ellipse,
438 [342.0, 140.4],
439 [432.0, 194.4],
440 &shape_pen,
441 )
442 .unwrap(),
423 Ink::drawn(ShapeKind::Arrow, [252.0, 230.4], [360.0, 266.4], &shape_pen).unwrap(),443 Ink::drawn(ShapeKind::Arrow, [252.0, 230.4], [360.0, 266.4], &shape_pen).unwrap(),
424 Ink::drawn(ShapeKind::Line, [378.0, 230.4], [450.0, 230.4], &shape_pen).unwrap(),444 Ink::drawn(ShapeKind::Line, [378.0, 230.4], [450.0, 230.4], &shape_pen).unwrap(),
425 ];445 ];
...@@ -528,8 +548,14 @@ fn pens_highlighters_and_shapes_are_written_as_onenote_draws_them() {...@@ -528,8 +548,14 @@ fn pens_highlighters_and_shapes_are_written_as_onenote_draws_them() {
528 other => panic!("{other:?}"),548 other => panic!("{other:?}"),
529 }549 }
530 }550 }
531 assert_eq!(extra(wave.id, 0x14001d4e), Some(1u32.to_le_bytes().to_vec()));551 assert_eq!(
532 assert_eq!(extra(rectangle.id, 0x14001d4e), Some(2u32.to_le_bytes().to_vec()));552 extra(wave.id, 0x14001d4e),
553 Some(1u32.to_le_bytes().to_vec())
554 );
555 assert_eq!(
556 extra(rectangle.id, 0x14001d4e),
557 Some(2u32.to_le_bytes().to_vec())
558 );
533 match &node(rectangle.id).kind {559 match &node(rectangle.id).kind {
534 Kind::Ink {560 Kind::Ink {
535 shape_kind: Some(12),561 shape_kind: Some(12),
...@@ -576,7 +602,9 @@ fn onenote_drawings_take_erasing_moving_and_more_strokes() {...@@ -576,7 +602,9 @@ fn onenote_drawings_take_erasing_moving_and_more_strokes() {
576 }602 }
577 if let Some(shape) = &mut ink.shape {603 if let Some(shape) = &mut ink.shape {
578 *shape = match shape {604 *shape = match shape {
579 InkShape::Line([a, b]) => InkShape::Line([[a[0], a[1] + 200.0], [b[0], b[1] + 200.0]]),605 InkShape::Line([a, b]) => {
606 InkShape::Line([[a[0], a[1] + 200.0], [b[0], b[1] + 200.0]])
607 }
580 InkShape::Closed { transform, anchors } => InkShape::Closed {608 InkShape::Closed { transform, anchors } => InkShape::Closed {
581 transform: {609 transform: {
582 let mut t = *transform;610 let mut t = *transform;
crates/onestore/tests/page_media.rs+113-1
...@@ -13,6 +13,10 @@ const NATIVE_READ: &str = include_str!("../../../corpus/m6/native-features-01/re...@@ -13,6 +13,10 @@ const NATIVE_READ: &str = include_str!("../../../corpus/m6/native-features-01/re
13const TITLE: &str = "Files and recording";13const TITLE: &str = "Files and recording";
1414
15fn page(bytes: &[u8]) -> (ExGuid, Page) {15fn page(bytes: &[u8]) -> (ExGuid, Page) {
16 titled(bytes, TITLE)
17}
18
19fn titled(bytes: &[u8], title: &str) -> (ExGuid, Page) {
16 let store = Store::parse(bytes).unwrap();20 let store = Store::parse(bytes).unwrap();
17 let index = RevisionIndex::parse(&store).unwrap();21 let index = RevisionIndex::parse(&store).unwrap();
18 let document = Document::parse(&index).unwrap();22 let document = Document::parse(&index).unwrap();
...@@ -21,7 +25,7 @@ fn page(bytes: &[u8]) -> (ExGuid, Page) {...@@ -21,7 +25,7 @@ fn page(bytes: &[u8]) -> (ExGuid, Page) {
21 .unwrap()25 .unwrap()
22 .into_iter()26 .into_iter()
23 .map(|(space, _)| (space, Page::from_space(&document, space).unwrap()))27 .map(|(space, _)| (space, Page::from_space(&document, space).unwrap()))
24 .find(|(_, page)| page.title == TITLE)28 .find(|(_, page)| page.title == title)
25 .unwrap()29 .unwrap()
26}30}
2731
...@@ -158,3 +162,111 @@ fn editing_a_recorded_page_keeps_the_recording_and_its_annotation() {...@@ -158,3 +162,111 @@ fn editing_a_recorded_page_keeps_the_recording_and_its_annotation() {
158 std::fs::write(directory.join("expected-count.txt"), count.to_string()).unwrap();162 std::fs::write(directory.join("expected-count.txt"), count.to_string()).unwrap();
159 }163 }
160}164}
165
166/// The recordings page `space` lists (AudioRecordingGuids).
167fn playlist(bytes: &[u8], space: ExGuid) -> Vec<[u8; 16]> {
168 let store = Store::parse(bytes).unwrap();
169 let index = RevisionIndex::parse(&store).unwrap();
170 let document = Document::parse(&index).unwrap();
171 let revision = document.spaces[&space].active().unwrap();
172 revision
173 .nodes
174 .values()
175 .find_map(|node| match &node.kind {
176 onestore::document::Kind::Page { recordings, .. } => Some(recordings.clone()),
177 _ => None,
178 })
179 .unwrap()
180}
181
182/// OneNote 2010's own recording (`corpus/recording/native`): a WMA started after "Before
183/// recording", its line saying when and notes linked while it ran.
184const RECORDED: &[u8] = include_bytes!("../../../corpus/recording/native/notebook/files.one");
185/// The same page after OneNote deleted the recording: the page lists no recording, and the
186/// notes keep their links.
187const DELETED: &[u8] = include_bytes!("../../../corpus/recording/native/deleted/files.one");
188
189/// `ONESTORE_RECORDING_EDIT_EXPORT` names a new directory receiving the edited section for
190/// a cold reopen (`corpus/recording/edit`).
191#[test]
192fn onenote_s_recording_round_trips_through_deleting_restoring_and_linking() {
193 let (space, native) = titled(RECORDED, "Recorded");
194 let (native_recording, _) = recording(&native);
195 assert_eq!(native_recording.kind, 1);
196 assert!(native_recording.duration_ms.is_some_and(|ms| ms > 30_000));
197 assert_eq!(playlist(RECORDED, space), vec![native_recording.id]);
198 let (_, deleted) = titled(DELETED, "Recorded");
199 assert!(playlist(DELETED, space).is_empty());
200 assert!(recording_paragraph(&deleted).is_none());
201
202 // Deleting the recording unlists it, as OneNote does; the notes keep their links.
203 let mut removed = native.clone();
204 let outline = body(&mut removed);
205 let at = outline
206 .paragraphs
207 .iter()
208 .position(|paragraph| matches!(paragraph.content, ParagraphContent::Attachment(_)))
209 .unwrap();
210 let file = outline.paragraphs.remove(at);
211 let written = ops::saved(RECORDED, space, &removed).unwrap();
212 assert!(playlist(&written, space).is_empty());
213 assert_eq!(titled(&written, "Recorded").1, removed);
214
215 // Putting it back, as undo does, stores the same recording, listed again.
216 let written = ops::saved(&written, space, &native).unwrap();
217 let (_, restored) = titled(&written, "Recorded");
218 assert_eq!(restored, native);
219 let bytes = |page: &Page| match &recording_paragraph(page).unwrap().content {
220 ParagraphContent::Attachment(file) => file.bytes.clone(),
221 _ => unreachable!(),
222 };
223 assert_eq!(bytes(&restored), bytes(&native));
224 assert!(bytes(&restored).unwrap().starts_with(b"0&\xb2\x75"));
225 assert_eq!(playlist(&written, space), vec![native_recording.id]);
226 assert_eq!(recording_paragraph(&restored).unwrap(), &file);
227
228 // A note written while playing it back links to a moment, as one taken while recording.
229 let mut linked = restored.clone();
230 let mut added = ops::paragraph("Linked by Snowbound");
231 added.media = MediaIndex {
232 recordings: vec![native_recording.id],
233 time_ms: Some(25_000),
234 };
235 body(&mut linked).paragraphs.push(added);
236 let written = ops::saved(&written, space, &linked).unwrap();
237 assert_eq!(titled(&written, "Recorded").1, linked);
238
239 if let Some(directory) = std::env::var_os("ONESTORE_RECORDING_EDIT_EXPORT") {
240 let directory = std::path::PathBuf::from(directory);
241 std::fs::create_dir_all(&directory).unwrap();
242 std::fs::write(directory.join("files.one"), &written).unwrap();
243 std::fs::write(
244 directory.join("Open Notebook.onetoc2"),
245 include_bytes!("../../../corpus/recording/native/notebook/Open Notebook.onetoc2"),
246 )
247 .unwrap();
248 }
249}
250
251fn body(page: &mut Page) -> &mut onestore::page::Outline {
252 page.objects
253 .iter_mut()
254 .find_map(|object| match object {
255 PageObject::Outline(outline) if !outline.title => Some(outline),
256 _ => None,
257 })
258 .unwrap()
259}
260
261fn recording_paragraph(page: &Page) -> Option<&onestore::page::PageParagraph> {
262 page.objects
263 .iter()
264 .filter_map(|object| match object {
265 PageObject::Outline(outline) => Some(&outline.paragraphs),
266 _ => None,
267 })
268 .flatten()
269 .find(|paragraph| {
270 matches!(&paragraph.content, ParagraphContent::Attachment(file) if file.recording.is_some())
271 })
272}
crates/snowbound/assets/icons/eraser.svg created+11
...@@ -0,0 +1,11 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#ffc2d1"/><stop offset="1" stop-color="#f06a8f"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#d7dee7"/></linearGradient>
5 </defs>
6 <path d="M10 2.75L14.25 7L9.5 11.75L5.25 7.5Z" fill="#000000" fill-opacity="0.16"/>
7 <path d="M10 2L14.25 6.25L9.5 11L5.25 6.75Z" fill="url(#g0)" stroke="#b83a60" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
8 <path d="M5.25 6.75L9.5 11L7 13.5H4.5L2.25 11.25A0.9 0.9 0 0 1 2.25 9.75Z" fill="url(#g1)" stroke="#5a6676" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
9 <path d="M9.9 3.2L6.5 6.6" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.85"/>
10 <path d="M8 14.5H14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
11</svg>
crates/snowbound/assets/icons/lasso.svg created+4
...@@ -0,0 +1,4 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M8 1.5A6.5 4.25 0 1 1 8 10A6.5 4.25 0 1 1 8 1.5Z" fill="none" stroke="currentColor" stroke-width="1"/>
3 <path d="M4.5 9.2C3.2 10.4 3.6 12 5.2 12.2C6.4 12.35 6.9 13.4 6 14.6" fill="none" stroke="currentColor" stroke-width="1"/>
4</svg>
crates/snowbound/assets/icons/pen.svg created+11
...@@ -0,0 +1,11 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#d3d9e0"/><stop offset="1" stop-color="#7b8796"/></linearGradient>
5 </defs>
6 <path d="M12.3 1.75L14.25 3.7L6.2 11.75L4.25 9.8Z" fill="#000000" fill-opacity="0.16"/>
7 <path class="accent" d="M12.3 1L14.25 2.95L6.2 11L4.25 9.05Z" fill="url(#g0)" stroke="#1560c4" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
8 <path d="M4.25 9.05L6.2 11L3.1 12.4L1.75 13.75L2.85 11.9Z" fill="url(#g1)" stroke="#5a6676" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
9 <path d="M11.9 2.2L5.6 8.5" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.85"/>
10 <path d="M1 15H9" fill="none" class="accent" stroke="#1f7cf0" stroke-width="1" stroke-linecap="round"/>
11</svg>
crates/snowbound/assets/icons/select.svg created+5
...@@ -0,0 +1,5 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M3 1.75L3 13.25L5.9 10.5L8 15.25L10 14.35L7.9 9.75L11.75 9.5Z" fill="#000000" fill-opacity="0.16"/>
3 <path d="M3 1L3 12.5L5.9 9.75L8 14.5L10 13.6L7.9 9L11.75 8.75Z" fill="#ffffff" stroke="currentColor" stroke-width="0.8" stroke-linejoin="round"/>
4 <path d="M12 2.5H15M13.5 2.5V7.5M12 7.5H15" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
5</svg>
crates/snowbound/assets/icons/shape-arrow.svg created+3
...@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.5 13.5L13.5 2.5M8 2.5H13.5V8" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>
crates/snowbound/assets/icons/shape-line.svg created+3
...@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.5 13.5L13.5 2.5" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
3</svg>
crates/snowbound/assets/icons/shape-oval.svg created+3
...@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M8 3.5A6.5 4.5 0 1 1 8 12.5A6.5 4.5 0 1 1 8 3.5Z" fill="none" stroke="currentColor" stroke-width="1.2"/>
3</svg>
crates/snowbound/assets/icons/shape-rectangle.svg created+3
...@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M1.5 3.5H14.5V12.5H1.5Z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
3</svg>
crates/snowbound/assets/icons/shapes.svg created+8
...@@ -0,0 +1,8 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
4 </defs>
5 <path d="M1.5 2.25H9.5V10.25H1.5Z" fill="none" stroke="currentColor" stroke-width="1"/>
6 <path d="M10.5 7A4 4 0 1 1 10.5 15A4 4 0 1 1 10.5 7Z" fill="#000000" fill-opacity="0.16"/>
7 <path class="accent" d="M10.5 6.25A4 4 0 1 1 10.5 14.25A4 4 0 1 1 10.5 6.25Z" fill="url(#g0)" stroke="#1560c4" stroke-width="0.8"/>
8</svg>
crates/snowbound/src/art.rs+10
...@@ -66,3 +66,13 @@ pub const SYNC_BUSY: &[&str] = art!("icons/sync-busy");...@@ -66,3 +66,13 @@ pub const SYNC_BUSY: &[&str] = art!("icons/sync-busy");
66pub const SYNC_DONE: &[&str] = art!("icons/sync-done");66pub const SYNC_DONE: &[&str] = art!("icons/sync-done");
67pub const SYNC_ERROR: &[&str] = art!("icons/sync-error");67pub const SYNC_ERROR: &[&str] = art!("icons/sync-error");
68pub const SYNC_OFFLINE: &[&str] = art!("icons/sync-offline");68pub const SYNC_OFFLINE: &[&str] = art!("icons/sync-offline");
69
70pub const ERASER: &[&str] = art!("icons/eraser");
71pub const LASSO: &[&str] = art!("icons/lasso");
72pub const PEN: &[&str] = art!("icons/pen");
73pub const SELECT: &[&str] = art!("icons/select");
74pub const SHAPES: &[&str] = art!("icons/shapes");
75pub const SHAPE_ARROW: &[&str] = art!("icons/shape-arrow");
76pub const SHAPE_LINE: &[&str] = art!("icons/shape-line");
77pub const SHAPE_OVAL: &[&str] = art!("icons/shape-oval");
78pub const SHAPE_RECTANGLE: &[&str] = art!("icons/shape-rectangle");
crates/snowbound/src/attachment.rs+25-9
...@@ -5,11 +5,12 @@ use onestore::page::Attachment;...@@ -5,11 +5,12 @@ use onestore::page::Attachment;
5use std::{error::Error, path::Path};5use std::{error::Error, path::Path};
66
7/// The size OneNote 2010 shows a file's icon at, in points.7/// The size OneNote 2010 shows a file's icon at, in points.
8const ICON_SIZE: [f32; 2] = [24.0, 24.0];8pub(crate) const ICON_SIZE: [f32; 2] = [24.0, 24.0];
99
10impl State {10impl State {
11 /// Attach File, or a file dropped at `at`, a window point: a copy of the file's bytes11 /// Attach File, or a file dropped at `at`, a window point: a copy of the file's bytes
12 /// at the caret or at `at`, with the icon the system shows for it.12 /// at the caret or at `at`, with the icon the system shows for it; audio and video are
13 /// recordings, as OneNote attaches them.
13 pub(crate) fn attach(14 pub(crate) fn attach(
14 &mut self,15 &mut self,
15 path: &Path,16 path: &Path,
...@@ -31,9 +32,9 @@ impl State {...@@ -31,9 +32,9 @@ impl State {
31 source_path: path.to_str().map(str::to_owned),32 source_path: path.to_str().map(str::to_owned),
32 size: Some(ICON_SIZE),33 size: Some(ICON_SIZE),
33 layout: Default::default(),34 layout: Default::default(),
34 bytes: Some(bytes.into()),
35 preview: platform::file_icon(path).map(Into::into),35 preview: platform::file_icon(path).map(Into::into),
36 recording: None,36 recording: crate::recording::attached(name, &bytes),
37 bytes: Some(bytes.into()),
37 };38 };
38 let response = match at {39 let response = match at {
39 Some(point) => {40 Some(point) => {
...@@ -53,11 +54,27 @@ impl State {...@@ -53,11 +54,27 @@ impl State {
53 Ok(())54 Ok(())
54 }55 }
5556
56 /// Open: a copy of the file, in a folder of its own, with the system's application for it.57 /// Open: a recording plays; another file opens as a copy, in a folder of its own, with
57 pub(crate) fn open_attachment(&self, file: &Attachment) -> Result<(), Box<dyn Error>> {58 /// the system's application for it.
59 pub(crate) fn open_attachment(&mut self, file: &Attachment) -> Result<(), Box<dyn Error>> {
60 if file.recording.is_some() {
61 return self.play(file, 0);
62 }
63 if let Some(path) = self.copy_attachment(file)? {
64 platform::open_file(&path);
65 }
66 Ok(())
67 }
68
69 /// A copy of the file, in a folder of its own; none, once said, where it is not stored
70 /// in the section.
71 pub(crate) fn copy_attachment(
72 &self,
73 file: &Attachment,
74 ) -> Result<Option<std::path::PathBuf>, Box<dyn Error>> {
58 let Some(bytes) = &file.bytes else {75 let Some(bytes) = &file.bytes else {
59 unstored();76 unstored();
60 return Ok(());77 return Ok(None);
61 };78 };
62 let root = std::env::temp_dir().join("Snowbound Attachments");79 let root = std::env::temp_dir().join("Snowbound Attachments");
63 std::fs::create_dir_all(&root)?;80 std::fs::create_dir_all(&root)?;
...@@ -68,8 +85,7 @@ impl State {...@@ -68,8 +85,7 @@ impl State {
68 .ok_or("No folder is free for a copy of the file")?;85 .ok_or("No folder is free for a copy of the file")?;
69 let path = folder.join(&file.filename);86 let path = folder.join(&file.filename);
70 std::fs::write(&path, bytes)?;87 std::fs::write(&path, bytes)?;
71 platform::open_file(&path);88 Ok(Some(path))
72 Ok(())
73 }89 }
7490
75 /// Save As: the file's bytes where the user chooses, under its name by default.91 /// Save As: the file's bytes where the user chooses, under its name by default.
crates/snowbound/src/commands.rs+85-8
...@@ -2,9 +2,10 @@...@@ -2,9 +2,10 @@
2//! chords on each platform, when it applies and what it does.2//! chords on each platform, when it applies and what it does.
33
4use crate::{Command as Work, FONTS, HIGHLIGHTS, SIZES, State, page, platform, search};4use crate::{Command as Work, FONTS, HIGHLIGHTS, SIZES, State, page, platform, search};
5use canvas::editor::{Alignment, FormatState, Formatting, ListStyle, Toggle};5use canvas::editor::{Alignment, FAVORITES, FormatState, Formatting, ListStyle, Pen, Toggle};
6use canvas::interaction::Request;6use canvas::interaction::{Request, ink::Tool};
7use draw::edit::{Key, Modifiers, NamedKey, Platform};7use draw::edit::{Key, Modifiers, NamedKey, Platform};
8use onestore::page::ink::ShapeKind;
8use std::{error::Error, sync::Arc};9use std::{error::Error, sync::Arc};
910
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
...@@ -54,6 +55,12 @@ pub enum Id {...@@ -54,6 +55,12 @@ pub enum Id {
54 DateTime,55 DateTime,
55 RecordAudio,56 RecordAudio,
56 RecordVideo,57 RecordVideo,
58 /// The Draw tab's tools.
59 SelectType,
60 Pen,
61 Eraser,
62 Lasso,
63 Shape(ShapeKind),
57 Toggle(Toggle),64 Toggle(Toggle),
58 Highlight,65 Highlight,
59 FontColor,66 FontColor,
...@@ -90,6 +97,8 @@ pub enum Choice {...@@ -90,6 +97,8 @@ pub enum Choice {
90 RuleLines(Option<usize>),97 RuleLines(Option<usize>),
91 /// A template whose art becomes the page's background, or `None` for none.98 /// A template whose art becomes the page's background, or `None` for none.
92 Art(Option<&'static str>),99 Art(Option<&'static str>),
100 /// A place in the pen gallery, which Pen then draws with.
101 Pen(usize),
93}102}
94103
95#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]104#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
...@@ -299,6 +308,14 @@ pub const COMMANDS: &[Command] = &[...@@ -299,6 +308,14 @@ pub const COMMANDS: &[Command] = &[
299 ),308 ),
300 row!(Id::RecordAudio, "Record Audio", NONE, NONE),309 row!(Id::RecordAudio, "Record Audio", NONE, NONE),
301 row!(Id::RecordVideo, "Record Video", NONE, NONE),310 row!(Id::RecordVideo, "Record Video", NONE, NONE),
311 row!(Id::SelectType, "Select & Type", NONE, NONE),
312 row!(Id::Pen, "Pen", NONE, NONE),
313 row!(Id::Eraser, "Eraser", NONE, NONE),
314 row!(Id::Lasso, "Lasso Select", NONE, NONE),
315 row!(Id::Shape(ShapeKind::Line), "Line", NONE, NONE),
316 row!(Id::Shape(ShapeKind::Arrow), "Arrow", NONE, NONE),
317 row!(Id::Shape(ShapeKind::Rectangle), "Rectangle", NONE, NONE),
318 row!(Id::Shape(ShapeKind::Ellipse), "Oval", NONE, NONE),
302 row!(Id::Toggle(Toggle::Bold), "Bold", &[cmd('b')], &[cmd('b')]),319 row!(Id::Toggle(Toggle::Bold), "Bold", &[cmd('b')], &[cmd('b')]),
303 row!(320 row!(
304 Id::Toggle(Toggle::Italic),321 Id::Toggle(Toggle::Italic),
...@@ -438,7 +455,8 @@ pub fn title(choice: Choice) -> String {...@@ -438,7 +455,8 @@ pub fn title(choice: Choice) -> String {
438 | Choice::Table(_)455 | Choice::Table(_)
439 | Choice::PageColor(_)456 | Choice::PageColor(_)
440 | Choice::RuleLines(_)457 | Choice::RuleLines(_)
441 | Choice::Art(_) => {458 | Choice::Art(_)
459 | Choice::Pen(_) => {
442 unreachable!("Only the toolbar offers these")460 unreachable!("Only the toolbar offers these")
443 }461 }
444 }462 }
...@@ -596,6 +614,20 @@ pub fn shortcut(id: Id) -> String {...@@ -596,6 +614,20 @@ pub fn shortcut(id: Id) -> String {
596}614}
597615
598impl State {616impl State {
617 /// The pen gallery: a pen in the open section's accent, then OneNote's favourites. The
618 /// accent takes the light theme's shade in either theme, so a stroke stores one colour
619 /// and shows it in both.
620 pub(crate) fn pens(&self) -> [Pen; 15] {
621 let section = crate::section_color(
622 self.session
623 .as_ref()
624 .and_then(|session| session.tabs[session.tab].color),
625 );
626 let [red, green, blue] = draw::srgb_bytes(ui::Theme::light().section(section).accent);
627 let accent = Pen::new(35.0, Some(u32::from_le_bytes([red, green, blue, 0])));
628 std::array::from_fn(|place| place.checked_sub(1).map_or(accent, |at| FAVORITES[at]))
629 }
630
599 pub(crate) fn format_state(&self) -> FormatState {631 pub(crate) fn format_state(&self) -> FormatState {
600 self.view.editor.format_state().unwrap_or_default()632 self.view.editor.format_state().unwrap_or_default()
601 }633 }
...@@ -643,7 +675,9 @@ impl State {...@@ -643,7 +675,9 @@ impl State {
643 Choice::Highlight(_) | Choice::Color(_) | Choice::List(_) => return enabled(text),675 Choice::Highlight(_) | Choice::Color(_) | Choice::List(_) => return enabled(text),
644 Choice::Table(_) => Id::Table,676 Choice::Table(_) => Id::Table,
645 Choice::PageColor(_) | Choice::RuleLines(_) | Choice::Art(_) => Id::PageColor,677 Choice::PageColor(_) | Choice::RuleLines(_) | Choice::Art(_) => Id::PageColor,
678 Choice::Pen(_) => Id::Pen,
646 };679 };
680 let tool = self.view.tool();
647 match id {681 match id {
648 Id::Settings682 Id::Settings
649 | Id::NewNotebook683 | Id::NewNotebook
...@@ -708,12 +742,31 @@ impl State {...@@ -708,12 +742,31 @@ impl State {
708 },742 },
709 Id::PageColor => enabled(writable && session.is_some()),743 Id::PageColor => enabled(writable && session.is_some()),
710 Id::ScreenClipping => enabled(text && cfg!(target_os = "macos")),744 Id::ScreenClipping => enabled(text && cfg!(target_os = "macos")),
711 // Record Audio and Video await #36.745 Id::RecordAudio => Status {
712 Id::RecordAudio | Id::RecordVideo => enabled(false),746 enabled: matches!(self.media, crate::recording::Media::Recording { .. })
747 || text && !modal,
748 checked: matches!(self.media, crate::recording::Media::Recording { .. }),
749 },
750 // Recording video would need a camera pipeline and an encoder OneNote 2010 plays.
751 Id::RecordVideo => enabled(false),
713 Id::InsertSpace => Status {752 Id::InsertSpace => Status {
714 enabled: writable,753 enabled: writable,
715 checked: self.view.inserting_space(),754 checked: self.view.inserting_space(),
716 },755 },
756 Id::SelectType => Status {
757 enabled: page,
758 checked: tool == Tool::Select,
759 },
760 Id::Pen | Id::Eraser | Id::Lasso | Id::Shape(_) => Status {
761 enabled: writable,
762 checked: match (id, tool) {
763 (Id::Pen, Tool::Pen(_))
764 | (Id::Eraser, Tool::Eraser)
765 | (Id::Lasso, Tool::Lasso) => true,
766 (Id::Shape(kind), Tool::Shape(shape, _)) => kind == shape,
767 _ => false,
768 },
769 },
717 Id::Table770 Id::Table
718 | Id::Picture771 | Id::Picture
719 | Id::Attachment772 | Id::Attachment
...@@ -826,6 +879,11 @@ impl State {...@@ -826,6 +879,11 @@ impl State {
826 let lines = lines.map(|index| canvas::template::RULE_LINES[index].1);879 let lines = lines.map(|index| canvas::template::RULE_LINES[index].1);
827 return self.paper_page(self.view.editor.page_color(), lines, None);880 return self.paper_page(self.view.editor.page_color(), lines, None);
828 }881 }
882 Choice::Pen(place) => {
883 self.toolbar.pen = place;
884 self.save_settings();
885 Id::Pen
886 }
829 };887 };
830 let field = self.ui.focused().filter(|focus| *focus != page());888 let field = self.ui.focused().filter(|focus| *focus != page());
831 let response = match id {889 let response = match id {
...@@ -980,14 +1038,33 @@ impl State {...@@ -980,14 +1038,33 @@ impl State {
980 self.save_settings();1038 self.save_settings();
981 return Ok(());1039 return Ok(());
982 }1040 }
983 Id::RecordAudio | Id::RecordVideo => {1041 Id::RecordAudio => return self.record_audio(),
984 return Ok(());1042 Id::RecordVideo => return Ok(()),
985 }
986 Id::InsertSpace => {1043 Id::InsertSpace => {
987 let response = self.view.insert_space();1044 let response = self.view.insert_space();
988 self.respond(response);1045 self.respond(response);
989 return Ok(());1046 return Ok(());
990 }1047 }
1048 Id::SelectType | Id::Pen | Id::Eraser | Id::Lasso | Id::Shape(_) => {
1049 let pens = self.pens();
1050 let tool = match id {
1051 Id::Pen => Tool::Pen(pens[self.toolbar.pen.min(pens.len() - 1)]),
1052 Id::Eraser => Tool::Eraser,
1053 Id::Lasso => Tool::Lasso,
1054 // Shapes draw with a pen a little thicker, as OneNote's do.
1055 Id::Shape(kind) => Tool::Shape(
1056 kind,
1057 Pen {
1058 width: 50.0,
1059 ..pens[0]
1060 },
1061 ),
1062 _ => Tool::Select,
1063 };
1064 let response = self.view.set_tool(tool);
1065 self.respond(response);
1066 return Ok(());
1067 }
991 Id::Link => {1068 Id::Link => {
992 self.open_link_dialog();1069 self.open_link_dialog();
993 return Ok(());1070 return Ok(());
crates/snowbound/src/library.rs+36-18
...@@ -167,7 +167,7 @@ pub struct Library {...@@ -167,7 +167,7 @@ pub struct Library {
167 /// Syncs the notebook's sections while no tab shows them, as OneNote syncs every167 /// Syncs the notebook's sections while no tab shows them, as OneNote syncs every
168 /// section of an open notebook.168 /// section of an open notebook.
169 pub background: Option<Arc<Background>>,169 pub background: Option<Arc<Background>>,
170 /// Reports the changes to a folder on this computer to `background`.170 /// Reports the changes to a mounted notebook's folder to `background`.
171 watch: Option<Arc<crate::watch::Watch>>,171 watch: Option<Arc<crate::watch::Watch>>,
172}172}
173173
...@@ -255,6 +255,16 @@ impl Library {...@@ -255,6 +255,16 @@ impl Library {
255 if let Some(background) = &self.background {255 if let Some(background) = &self.background {
256 background.watch(notebook.replicas());256 background.watch(notebook.replicas());
257 }257 }
258 // A network volume's watch covers each folder by name, so a new one needs a new watch.
259 let watch = match (&self.watch, &self.background) {
260 (Some(_), Some(background))
261 if !crate::watch::on_this_computer(Path::new(&self.location)) =>
262 {
263 let reports = OnceLock::from(Arc::downgrade(background));
264 watcher(&self.location, Arc::new(reports)).or_else(|| self.watch.clone())
265 }
266 _ => self.watch.clone(),
267 };
258 Self {268 Self {
259 location: self.location.clone(),269 location: self.location.clone(),
260 name: self.name.clone(),270 name: self.name.clone(),
...@@ -263,7 +273,7 @@ impl Library {...@@ -263,7 +273,7 @@ impl Library {
263 notice: self.notice.clone(),273 notice: self.notice.clone(),
264 cache: self.cache.clone(),274 cache: self.cache.clone(),
265 background: self.background.clone(),275 background: self.background.clone(),
266 watch: self.watch.clone(),276 watch,
267 }277 }
268 }278 }
269279
...@@ -355,6 +365,9 @@ impl Library {...@@ -355,6 +365,9 @@ impl Library {
355 }365 }
356 };366 };
357 section.set_offline(offline());367 section.set_offline(offline());
368 if let Some(background) = &self.background {
369 background.hold(path, &section);
370 }
358 Ok(section)371 Ok(section)
359 }372 }
360373
...@@ -460,26 +473,17 @@ pub fn recycle_bin(path: &str) -> bool {...@@ -460,26 +473,17 @@ pub fn recycle_bin(path: &str) -> bool {
460}473}
461474
462/// A mounted notebook's background sync, following Work Offline, and the watch that reports475/// A mounted notebook's background sync, following Work Offline, and the watch that reports
463/// the folder's changes to it. Only a folder on this computer reports every change; one on a476/// the folder's changes to it. A folder on a network volume is kept in offline copies, as on a
464/// network volume is checked more often and kept in offline copies, as on a share.477/// share, and where the system does not report its server's changes, checked more often.
465fn local_background(478fn local_background(
466 notebook: &Notebook,479 notebook: &Notebook,
467 location: &str,480 location: &str,
468) -> (Option<Arc<Background>>, Option<Arc<crate::watch::Watch>>) {481) -> (Option<Arc<Background>>, Option<Arc<crate::watch::Watch>>) {
469 let reports = Arc::new(OnceLock::<std::sync::Weak<Background>>::new());482 let reports = Arc::new(OnceLock::new());
470 let report = Arc::clone(&reports);483 let watch = watcher(location, Arc::clone(&reports));
471 let watch = crate::watch::watch(Path::new(location), move |paths| {484 let copies = !crate::watch::on_this_computer(Path::new(location));
472 if let Some(background) = report.get().and_then(std::sync::Weak::upgrade) {
473 background.touched(&paths);
474 }
475 });
476 let remote = !crate::watch::on_this_computer(Path::new(location));
477 let interval = match watch {
478 Some(_) if !remote => Background::BACKSTOP,
479 _ => Background::UNWATCHED,
480 };
481 let Some(background) = notebook485 let Some(background) = notebook
482 .background(interval, remote, notify_background)486 .background(watch.is_some(), copies, notify_background)
483 .inspect_err(|error| eprintln!("Background sync did not start: {error}"))487 .inspect_err(|error| eprintln!("Background sync did not start: {error}"))
484 .ok()488 .ok()
485 else {489 else {
...@@ -489,7 +493,21 @@ fn local_background(...@@ -489,7 +493,21 @@ fn local_background(
489 background.watch(notebook.replicas());493 background.watch(notebook.replicas());
490 let background = Arc::new(background);494 let background = Arc::new(background);
491 let _ = reports.set(Arc::downgrade(&background));495 let _ = reports.set(Arc::downgrade(&background));
492 (Some(background), watch.map(Arc::new))496 (Some(background), watch)
497}
498
499/// Reports the changes to the folder at `location` to the background `reports` names, once
500/// set.
501fn watcher(
502 location: &str,
503 reports: Arc<OnceLock<std::sync::Weak<Background>>>,
504) -> Option<Arc<crate::watch::Watch>> {
505 crate::watch::watch(Path::new(location), move |paths| {
506 if let Some(background) = reports.get().and_then(std::sync::Weak::upgrade) {
507 background.touched(&paths);
508 }
509 })
510 .map(Arc::new)
493}511}
494512
495pub fn file_name(path: &Path) -> String {513pub fn file_name(path: &Path) -> String {
crates/snowbound/src/main.rs+163-22
...@@ -10,6 +10,9 @@ mod history;...@@ -10,6 +10,9 @@ mod history;
10mod library;10mod library;
11mod link;11mod link;
12mod manage;12mod manage;
13#[cfg_attr(target_os = "linux", path = "media_linux.rs")]
14#[cfg_attr(target_os = "macos", path = "media_macos.rs")]
15mod media;
13mod meeting;16mod meeting;
14#[cfg(target_os = "macos")]17#[cfg(target_os = "macos")]
15mod menubar;18mod menubar;
...@@ -21,6 +24,7 @@ mod pane;...@@ -21,6 +24,7 @@ mod pane;
21#[cfg_attr(target_os = "linux", path = "linux.rs")]24#[cfg_attr(target_os = "linux", path = "linux.rs")]
22#[cfg_attr(target_os = "macos", path = "macos.rs")]25#[cfg_attr(target_os = "macos", path = "macos.rs")]
23mod platform;26mod platform;
27mod recording;
24mod rename;28mod rename;
25mod screenshot;29mod screenshot;
26mod search;30mod search;
...@@ -50,6 +54,7 @@ use library::Library;...@@ -50,6 +54,7 @@ use library::Library;
50use onestore::ExGuid;54use onestore::ExGuid;
51use onestore::document::Format;55use onestore::document::Format;
52use onestore::page::Page;56use onestore::page::Page;
57use onestore::page::ink::ShapeKind;
53use onestore::page::text::Paragraph;58use onestore::page::text::Paragraph;
54use std::{59use std::{
55 collections::{HashMap, HashSet},60 collections::{HashMap, HashSet},
...@@ -202,6 +207,7 @@ fn cursor_icon(cursor: Cursor) -> CursorIcon {...@@ -202,6 +207,7 @@ fn cursor_icon(cursor: Cursor) -> CursorIcon {
202 Cursor::NeswResize => CursorIcon::NeswResize,207 Cursor::NeswResize => CursorIcon::NeswResize,
203 Cursor::RowResize => CursorIcon::RowResize,208 Cursor::RowResize => CursorIcon::RowResize,
204 Cursor::ColResize => CursorIcon::ColResize,209 Cursor::ColResize => CursorIcon::ColResize,
210 Cursor::Crosshair => CursorIcon::Crosshair,
205 }211 }
206}212}
207213
...@@ -550,6 +556,7 @@ struct State {...@@ -550,6 +556,7 @@ struct State {
550 title_focus: Option<ExGuid>,556 title_focus: Option<ExGuid>,
551 /// What the template strip shows over a blank page.557 /// What the template strip shows over a blank page.
552 templates: templates::View,558 templates: templates::View,
559 media: recording::Media,
553 thumbnails: templates::Thumbnails,560 thumbnails: templates::Thumbnails,
554 search: search::Search,561 search: search::Search,
555 /// Whether the page list is shown beside the page.562 /// Whether the page list is shown beside the page.
...@@ -835,6 +842,7 @@ impl State {...@@ -835,6 +842,7 @@ impl State {
835 sectionless,842 sectionless,
836 drag: None,843 drag: None,
837 templates: templates::View::Strip,844 templates: templates::View::Strip,
845 media: Default::default(),
838 thumbnails: templates::Thumbnails::default(),846 thumbnails: templates::Thumbnails::default(),
839 search,847 search,
840 pages_open: true,848 pages_open: true,
...@@ -912,7 +920,7 @@ impl State {...@@ -912,7 +920,7 @@ impl State {
912 }920 }
913 self.draw()?;921 self.draw()?;
914 lap("drawn", start);922 lap("drawn", start);
915 self.sync_index(false);923 self.sync_index(false, Vec::new());
916 let commands = std::mem::take(&mut self.commands);924 let commands = std::mem::take(&mut self.commands);
917 let follow = !commands.is_empty() || self.ui.wants_frame() || self.opening.is_some();925 let follow = !commands.is_empty() || self.ui.wants_frame() || self.opening.is_some();
918 for command in commands {926 for command in commands {
...@@ -1260,6 +1268,9 @@ impl State {...@@ -1260,6 +1268,9 @@ impl State {
1260 },1268 },
1261 );1269 );
1262 self.template_strip(&theme);1270 self.template_strip(&theme);
1271 if let Some(rect) = self.ui.rect(page()) {
1272 self.transport(&theme, rect)?;
1273 }
1263 let scroll = self.view.scroll();1274 let scroll = self.view.scroll();
1264 for (index, axis) in [Axis::X, Axis::Y].into_iter().enumerate() {1275 for (index, axis) in [Axis::X, Axis::Y].into_iter().enumerate() {
1265 if let Some(offset) = ui::scrollbar(1276 if let Some(offset) = ui::scrollbar(
...@@ -1539,13 +1550,14 @@ impl State {...@@ -1539,13 +1550,14 @@ impl State {
1539 let engine = &self.view.engine;1550 let engine = &self.view.engine;
1540 let session = self.session.as_ref();1551 let session = self.session.as_ref();
1541 let label = format!("{:.0}%", self.view.zoom() * 100.0);1552 let label = format!("{:.0}%", self.view.zoom() * 100.0);
1553 let drawing_pens = self.pens();
1542 let ui = &mut self.ui;1554 let ui = &mut self.ui;
1543 let text = theme.text;1555 let text = theme.text;
1544 let tags: Vec<_> = self.tags.iter().enumerate().collect();1556 let tags: Vec<_> = self.tags.iter().enumerate().collect();
1545 let mut choice = group(1557 let mut choice = group(
1546 ui,1558 ui,
1547 "navigate",1559 "navigate",
1548 3,1560 4,
1549 |ui| {1561 |ui| {
1550 let mut choice = None;1562 let mut choice = None;
1551 for id in [Cmd::Back, Cmd::Forward, Cmd::Undo, Cmd::Redo] {1563 for id in [Cmd::Back, Cmd::Forward, Cmd::Undo, Cmd::Redo] {
...@@ -1561,7 +1573,7 @@ impl State {...@@ -1561,7 +1573,7 @@ impl State {
1561 choice = group(1573 choice = group(
1562 ui,1574 ui,
1563 "clipboard",1575 "clipboard",
1564 2,1576 3,
1565 |ui| {1577 |ui| {
1566 divider(ui, theme);1578 divider(ui, theme);
1567 let mut choice = None;1579 let mut choice = None;
...@@ -1721,7 +1733,7 @@ impl State {...@@ -1721,7 +1733,7 @@ impl State {
1721 choice = group(1733 choice = group(
1722 ui,1734 ui,
1723 "character",1735 "character",
1724 5,1736 6,
1725 |ui| {1737 |ui| {
1726 divider(ui, theme);1738 divider(ui, theme);
1727 let mut choice = None;1739 let mut choice = None;
...@@ -1816,7 +1828,7 @@ impl State {...@@ -1816,7 +1828,7 @@ impl State {
1816 choice = group(1828 choice = group(
1817 ui,1829 ui,
1818 "paragraph",1830 "paragraph",
1819 4,1831 5,
1820 |ui| {1832 |ui| {
1821 divider(ui, theme);1833 divider(ui, theme);
1822 let mut choice = None;1834 let mut choice = None;
...@@ -1946,7 +1958,7 @@ impl State {...@@ -1946,7 +1958,7 @@ impl State {
1946 choice = group(1958 choice = group(
1947 ui,1959 ui,
1948 "tags",1960 "tags",
1949 6,1961 7,
1950 |ui| {1962 |ui| {
1951 divider(ui, theme);1963 divider(ui, theme);
1952 let mut choice = None;1964 let mut choice = None;
...@@ -1964,6 +1976,23 @@ impl State {...@@ -1964,6 +1976,23 @@ impl State {
1964 },1976 },
1965 )1977 )
1966 .or(choice);1978 .or(choice);
1979 // The Draw tab's tools, which Insert's menus list for when they fold, before Insert.
1980 let shapes = [
1981 ShapeKind::Line,
1982 ShapeKind::Arrow,
1983 ShapeKind::Rectangle,
1984 ShapeKind::Ellipse,
1985 ]
1986 .map(|kind| Run(Cmd::Shape(kind)));
1987 let drawing = [
1988 Run(Cmd::SelectType),
1989 Open("pen", Cmd::Pen),
1990 Run(Cmd::Eraser),
1991 Run(Cmd::Lasso),
1992 Rule,
1993 ]
1994 .into_iter()
1995 .chain(shapes);
1967 let inserted = [Cmd::Picture, Cmd::Link, Cmd::Date, Cmd::Equation];1996 let inserted = [Cmd::Picture, Cmd::Link, Cmd::Date, Cmd::Equation];
1968 let more = [1997 let more = [
1969 Cmd::ScreenClipping,1998 Cmd::ScreenClipping,
...@@ -1979,7 +2008,7 @@ impl State {...@@ -1979,7 +2008,7 @@ impl State {
1979 choice = group(2008 choice = group(
1980 ui,2009 ui,
1981 "insert",2010 "insert",
1982 1,2011 2,
1983 |ui| {2012 |ui| {
1984 divider(ui, theme);2013 divider(ui, theme);
1985 let mut choice = None;2014 let mut choice = None;
...@@ -1998,17 +2027,64 @@ impl State {...@@ -1998,17 +2027,64 @@ impl State {
1998 for id in inserted {2027 for id in inserted {
1999 choice = tool(ui, id, status_of(id)).or(choice);2028 choice = tool(ui, id, status_of(id)).or(choice);
2000 }2029 }
2001 dropdown(ui, "more", Head::More, &more, status_of).or(choice)2030 let mut entries = more.to_vec();
2031 entries.push(Rule);
2032 entries.extend(drawing.clone());
2033 dropdown(ui, "more", Head::More, &entries, status_of).or(choice)
2002 },2034 },
2003 |ui| {2035 |ui| {
2004 let mut entries = vec![Open("table", Cmd::Table)];2036 let mut entries = vec![Open("table", Cmd::Table)];
2005 entries.extend(inserted.map(Run));2037 entries.extend(inserted.map(Run));
2006 entries.push(Rule);2038 entries.push(Rule);
2007 entries.extend(more);2039 entries.extend(more);
2040 entries.push(Rule);
2041 entries.extend(drawing.clone());
2008 dropdown(ui, "menu", Head::Menu(art::PLUS), &entries, status_of)2042 dropdown(ui, "menu", Head::Menu(art::PLUS), &entries, status_of)
2009 },2043 },
2010 )2044 )
2011 .or(choice);2045 .or(choice);
2046 // Pen draws with the gallery's last pick. Folded, the group leaves its tools to
2047 // Insert's menus, so the narrowest row keeps its width.
2048 choice = group(
2049 ui,
2050 "draw",
2051 1,
2052 |ui| {
2053 divider(ui, theme);
2054 let mut choice = tool(ui, Cmd::SelectType, status_of(Cmd::SelectType));
2055 let status = status_of(Cmd::Pen);
2056 if status.enabled {
2057 let pen = drawing_pens[pens.pen.min(drawing_pens.len() - 1)];
2058 let bar = Some(pen.color.map_or(ui.theme.paper_ink, colorref));
2059 let split = ui.id("pen");
2060 if ui::shell::split_button(
2061 ui,
2062 "pen",
2063 art::PEN,
2064 bar,
2065 status.checked,
2066 toolbar_popup("pen"),
2067 )
2068 .clicked
2069 {
2070 choice = Some(Choice::Command(Cmd::Pen));
2071 }
2072 tip(ui, Cmd::Pen);
2073 let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default());
2074 if let Some(place) = pen_gallery(ui, anchor, &drawing_pens, pens.pen) {
2075 choice = Some(Choice::Pen(place));
2076 }
2077 } else {
2078 ui::shell::unavailable(ui, "pen", art::PEN, text, true);
2079 }
2080 for id in [Cmd::Eraser, Cmd::Lasso] {
2081 choice = tool(ui, id, status_of(id)).or(choice);
2082 }
2083 dropdown(ui, "shapes", Head::Menu(art::SHAPES), &shapes, status_of).or(choice)
2084 },
2085 |_| None,
2086 )
2087 .or(choice);
2012 ui.leaf(2088 ui.leaf(
2013 "space",2089 "space",
2014 Spec {2090 Spec {
...@@ -2393,6 +2469,7 @@ impl State {...@@ -2393,6 +2469,7 @@ impl State {
2393 }2469 }
2394 Command::Page(Request::OpenLink(address)) => self.open_link(&address)?,2470 Command::Page(Request::OpenLink(address)) => self.open_link(&address)?,
2395 Command::Page(Request::OpenAttachment(file)) => self.open_attachment(&file)?,2471 Command::Page(Request::OpenAttachment(file)) => self.open_attachment(&file)?,
2472 Command::Page(Request::Play { file, at_ms }) => self.play(&file, at_ms)?,
2396 Command::Choose(choice) => self.run(choice)?,2473 Command::Choose(choice) => self.run(choice)?,
2397 }2474 }
2398 Ok(())2475 Ok(())
...@@ -2459,6 +2536,8 @@ impl State {...@@ -2459,6 +2536,8 @@ impl State {
2459 self.opening = Some(opening);2536 self.opening = Some(opening);
2460 return Ok(());2537 return Ok(());
2461 }2538 }
2539 // A recording goes on the page it started on.
2540 self.stop_recording()?;
2462 // The page shown so far keeps what was typed while the next one loaded.2541 // The page shown so far keeps what was typed while the next one loaded.
2463 self.persist()?;2542 self.persist()?;
2464 if let Some(session) = &self.session {2543 if let Some(session) = &self.session {
...@@ -2486,15 +2565,11 @@ impl State {...@@ -2486,15 +2565,11 @@ impl State {
2486 }2565 }
2487 // The notebook's background sync takes the section over once it is closed.2566 // The notebook's background sync takes the section over once it is closed.
2488 if let Some(previous) = self.session.replace(*session) {2567 if let Some(previous) = self.session.replace(*session) {
2489 let background = previous.library.background.clone();
2490 let section = previous.section;2568 let section = previous.section;
2491 std::thread::spawn(move || {2569 std::thread::spawn(move || {
2492 if let Err(error) = section.close() {2570 if let Err(error) = section.close() {
2493 eprintln!("Synchronization stopped: {error}");2571 eprintln!("Synchronization stopped: {error}");
2494 }2572 }
2495 if let Some(background) = background {
2496 background.wake();
2497 }
2498 });2573 });
2499 }2574 }
2500 self.sectionless = None;2575 self.sectionless = None;
...@@ -2840,14 +2915,19 @@ impl State {...@@ -2840,14 +2915,19 @@ impl State {
2840 /// last poll.2915 /// last poll.
2841 fn synced(&mut self) -> Result<(), Box<dyn Error>> {2916 fn synced(&mut self) -> Result<(), Box<dyn Error>> {
2842 // Every notebook's changes are taken, so none is reported again.2917 // Every notebook's changes are taken, so none is reported again.
2843 let noticed = self2918 let changed: Vec<String> = self
2844 .notebooks2919 .notebooks
2845 .iter()2920 .iter()
2846 .filter_map(|library| library.background.as_ref())2921 .filter_map(|library| Some((library, library.background.as_ref()?)))
2847 .filter(|background| !background.changed().is_empty())2922 .flat_map(|(library, background)| {
2848 .count();2923 background
2849 if noticed > 0 {2924 .changed()
2850 self.sync_index(true);2925 .into_iter()
2926 .map(|path| library.key(&path))
2927 })
2928 .collect();
2929 if !changed.is_empty() {
2930 self.sync_index(false, changed);
2851 }2931 }
2852 // Reports are rare: a status changed, or a section did.2932 // Reports are rare: a status changed, or a section did.
2853 self.window.request_redraw();2933 self.window.request_redraw();
...@@ -3811,6 +3891,50 @@ fn list_gallery(...@@ -3811,6 +3891,50 @@ fn list_gallery(
3811 Some(place(chosen))3891 Some(place(chosen))
3812}3892}
38133893
3894/// The pen gallery below `anchor` while open: each pen's stroke in its colour, as thick as it
3895/// draws, `current` outlined. Returns the place chosen.
3896fn pen_gallery(
3897 ui: &mut Ui,
3898 anchor: ui::Anchor,
3899 pens: &[canvas::editor::Pen],
3900 current: usize,
3901) -> Option<usize> {
3902 const CELL: [f32; 2] = [40.0, 24.0];
3903 let groups = [ui::popup::Group {
3904 heading: "Pens",
3905 cells: pens.len(),
3906 columns: 5,
3907 size: CELL,
3908 }];
3909 let ink = ui.theme.paper_ink;
3910 ui::popup::gallery(
3911 ui,
3912 toolbar_popup("pen"),
3913 anchor,
3914 &groups,
3915 &[current],
3916 |ui, place| {
3917 let pen = pens[place];
3918 let tall = if pen.highlighter {
3919 10.0
3920 } else {
3921 (pen.width / 25.0).clamp(1.5, 4.0)
3922 };
3923 let across = (CELL[1] - tall) / 2.0;
3924 ui.leaf(
3925 "stroke",
3926 Spec {
3927 size: [fill(), fill()],
3928 fill: Some(pen.color.map_or(ink, colorref)),
3929 inset: [6.0, across, 6.0, across],
3930 radius: tall / 2.0,
3931 ..Spec::default()
3932 },
3933 );
3934 },
3935 )
3936}
3937
3814/// A toolbar group that folds by `priority`: `full` builds its full form, `folded` the form3938/// A toolbar group that folds by `priority`: `full` builds its full form, `folded` the form
3815/// it folds to. Returns what either chose.3939/// it folds to. Returns what either chose.
3816fn group(3940fn group(
...@@ -4057,6 +4181,14 @@ fn artwork(id: commands::Id) -> Option<&'static [&'static str]> {...@@ -4057,6 +4181,14 @@ fn artwork(id: commands::Id) -> Option<&'static [&'static str]> {
4057 Cmd::Symbol => art::SYMBOL,4181 Cmd::Symbol => art::SYMBOL,
4058 Cmd::RecordAudio => art::RECORD_AUDIO,4182 Cmd::RecordAudio => art::RECORD_AUDIO,
4059 Cmd::RecordVideo => art::RECORD_VIDEO,4183 Cmd::RecordVideo => art::RECORD_VIDEO,
4184 Cmd::SelectType => art::SELECT,
4185 Cmd::Pen => art::PEN,
4186 Cmd::Eraser => art::ERASER,
4187 Cmd::Lasso => art::LASSO,
4188 Cmd::Shape(ShapeKind::Line) => art::SHAPE_LINE,
4189 Cmd::Shape(ShapeKind::Arrow) => art::SHAPE_ARROW,
4190 Cmd::Shape(ShapeKind::Rectangle) => art::SHAPE_RECTANGLE,
4191 Cmd::Shape(ShapeKind::Ellipse) => art::SHAPE_OVAL,
4060 Cmd::PageColor => art::PAGE_COLOR,4192 Cmd::PageColor => art::PAGE_COLOR,
4061 Cmd::ZoomIn => art::ZOOM_IN,4193 Cmd::ZoomIn => art::ZOOM_IN,
4062 Cmd::ZoomOut => art::ZOOM_OUT,4194 Cmd::ZoomOut => art::ZOOM_OUT,
...@@ -4388,6 +4520,11 @@ impl ApplicationHandler<UserEvent> for App {...@@ -4388,6 +4520,11 @@ impl ApplicationHandler<UserEvent> for App {
4388 state.access_adapter.process_event(&state.window, &event);4520 state.access_adapter.process_event(&state.window, &event);
4389 }4521 }
4390 if matches!(event, WindowEvent::CloseRequested) {4522 if matches!(event, WindowEvent::CloseRequested) {
4523 if let Some(state) = &mut self.state
4524 && let Err(error) = state.stop_recording().and_then(|()| state.persist())
4525 {
4526 eprintln!("{error}");
4527 }
4391 self.close(event_loop);4528 self.close(event_loop);
4392 return;4529 return;
4393 }4530 }
...@@ -4502,10 +4639,14 @@ impl ApplicationHandler<UserEvent> for App {...@@ -4502,10 +4639,14 @@ impl ApplicationHandler<UserEvent> for App {
4502 if repaint || wake.is_some_and(|wake| wake <= now) {4639 if repaint || wake.is_some_and(|wake| wake <= now) {
4503 state.window.request_redraw();4640 state.window.request_redraw();
4504 }4641 }
4505 let next = [blink, wake.filter(|wake| *wake > now)]4642 let next = [
4506 .into_iter()4643 blink,
4507 .flatten()4644 wake.filter(|wake| *wake > now),
4508 .min();4645 state.media_wake(now),
4646 ]
4647 .into_iter()
4648 .flatten()
4649 .min();
4509 event_loop.set_control_flow(next.map_or(ControlFlow::Wait, ControlFlow::WaitUntil));4650 event_loop.set_control_flow(next.map_or(ControlFlow::Wait, ControlFlow::WaitUntil));
4510 }4651 }
4511}4652}
crates/snowbound/src/media_linux.rs created+270
...@@ -0,0 +1,270 @@
1//! Audio through GStreamer, loaded at run time so builds need neither its headers nor its
2//! library: `playbin` plays whatever the installed plugins decode (Windows Media with
3//! gst-libav), and the default source records as PCM WAV.
4
5use std::{
6 cell::Cell,
7 ffi::{CStr, CString, c_char, c_int, c_void},
8 path::Path,
9 sync::OnceLock,
10};
11
12type Element = *mut c_void;
13
14/// GST_STATE_NULL, GST_STATE_PAUSED and GST_STATE_PLAYING.
15const NULL: c_int = 1;
16const PAUSED: c_int = 3;
17const PLAYING: c_int = 4;
18/// GST_FORMAT_TIME, in nanoseconds.
19const TIME: c_int = 3;
20/// GST_SEEK_FLAG_FLUSH | GST_SEEK_FLAG_ACCURATE.
21const SEEK: c_int = 1 | 2;
22/// GST_MESSAGE_EOS, GST_MESSAGE_ERROR and GST_MESSAGE_ASYNC_DONE.
23const EOS: c_int = 1;
24const ERROR: c_int = 2;
25const ASYNC_DONE: c_int = 1 << 21;
26/// Nanoseconds a pipeline has to get ready or to finish.
27const PATIENCE: u64 = 5_000_000_000;
28/// GST_STATE_CHANGE_FAILURE.
29const FAILURE: c_int = 0;
30
31/// The GStreamer entry points used, from `libgstreamer-1.0.so.0`.
32struct Gst {
33 parse_launch: unsafe extern "C" fn(*const c_char, *mut *mut c_void) -> Element,
34 set_state: unsafe extern "C" fn(Element, c_int) -> c_int,
35 query_position: unsafe extern "C" fn(Element, c_int, *mut i64) -> c_int,
36 query_duration: unsafe extern "C" fn(Element, c_int, *mut i64) -> c_int,
37 seek_simple: unsafe extern "C" fn(Element, c_int, c_int, i64) -> c_int,
38 send_event: unsafe extern "C" fn(Element, *mut c_void) -> c_int,
39 new_eos: unsafe extern "C" fn() -> *mut c_void,
40 get_bus: unsafe extern "C" fn(Element) -> *mut c_void,
41 pop: unsafe extern "C" fn(*mut c_void, u64, c_int) -> *mut c_void,
42 unref_message: unsafe extern "C" fn(*mut c_void),
43 unref: unsafe extern "C" fn(*mut c_void),
44}
45
46// SAFETY: GStreamer's entry points are thread-safe once initialized.
47unsafe impl Send for Gst {}
48unsafe impl Sync for Gst {}
49
50/// The function `name` in `library`, of C signature `F`.
51unsafe fn symbol<F: Copy>(library: *mut c_void, name: &CStr) -> Option<F> {
52 let symbol = unsafe { libc::dlsym(library, name.as_ptr()) };
53 (!symbol.is_null()).then(|| unsafe { std::mem::transmute_copy::<*mut c_void, F>(&symbol) })
54}
55
56/// GStreamer, initialized; none where it is not installed.
57fn gst() -> Option<&'static Gst> {
58 static GST: OnceLock<Option<Gst>> = OnceLock::new();
59 GST.get_or_init(|| unsafe {
60 let library = libc::dlopen(c"libgstreamer-1.0.so.0".as_ptr(), libc::RTLD_NOW);
61 if library.is_null() {
62 return None;
63 }
64 let init: unsafe extern "C" fn(*mut c_int, *mut c_void) = symbol(library, c"gst_init")?;
65 init(std::ptr::null_mut(), std::ptr::null_mut());
66 Some(Gst {
67 parse_launch: symbol(library, c"gst_parse_launch")?,
68 set_state: symbol(library, c"gst_element_set_state")?,
69 query_position: symbol(library, c"gst_element_query_position")?,
70 query_duration: symbol(library, c"gst_element_query_duration")?,
71 seek_simple: symbol(library, c"gst_element_seek_simple")?,
72 send_event: symbol(library, c"gst_element_send_event")?,
73 new_eos: symbol(library, c"gst_event_new_eos")?,
74 get_bus: symbol(library, c"gst_element_get_bus")?,
75 pop: symbol(library, c"gst_bus_timed_pop_filtered")?,
76 unref_message: symbol(library, c"gst_mini_object_unref")?,
77 unref: symbol(library, c"gst_object_unref")?,
78 })
79 })
80 .as_ref()
81}
82
83/// `path` as a `file://` URI, every byte outside the unreserved set escaped.
84fn uri(path: &Path) -> String {
85 let mut uri = String::from("file://");
86 for byte in path.as_os_str().as_encoded_bytes() {
87 match byte {
88 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
89 uri.push(char::from(*byte));
90 }
91 _ => uri.push_str(&format!("%{byte:02X}")),
92 }
93 }
94 uri
95}
96
97/// A pipeline described as `gst-launch` takes one, paused.
98struct Pipeline {
99 gst: &'static Gst,
100 element: Element,
101}
102
103impl Pipeline {
104 fn launch(description: &str) -> Option<Self> {
105 let gst = gst()?;
106 let description = CString::new(description).ok()?;
107 let element = unsafe { (gst.parse_launch)(description.as_ptr(), std::ptr::null_mut()) };
108 if element.is_null() {
109 return None;
110 }
111 let pipeline = Self { gst, element };
112 pipeline.set(PAUSED).then_some(pipeline)
113 }
114
115 fn set(&self, state: c_int) -> bool {
116 unsafe { (self.gst.set_state)(self.element, state) != FAILURE }
117 }
118
119 /// The type of the next message of `types` the pipeline posts within `timeout`
120 /// nanoseconds.
121 fn wait(&self, timeout: u64, types: c_int) -> Option<c_int> {
122 unsafe {
123 let bus = (self.gst.get_bus)(self.element);
124 let message = (self.gst.pop)(bus, timeout, types);
125 (self.gst.unref)(bus);
126 if message.is_null() {
127 return None;
128 }
129 // GstMessage's type follows its GstMiniObject header.
130 let kind = *message
131 .cast::<u8>()
132 .add(std::mem::size_of::<MiniObject>())
133 .cast::<c_int>();
134 (self.gst.unref_message)(message);
135 Some(kind)
136 }
137 }
138
139 fn query(&self, query: unsafe extern "C" fn(Element, c_int, *mut i64) -> c_int) -> u32 {
140 let mut nanoseconds = 0_i64;
141 let known = unsafe { query(self.element, TIME, &mut nanoseconds) } != 0;
142 if known {
143 (nanoseconds.max(0) / 1_000_000) as u32
144 } else {
145 0
146 }
147 }
148}
149
150impl Drop for Pipeline {
151 fn drop(&mut self) {
152 self.set(NULL);
153 unsafe { (self.gst.unref)(self.element) };
154 }
155}
156
157/// GstMiniObject's layout, which a message's type follows.
158#[repr(C)]
159struct MiniObject {
160 kind: usize,
161 refcount: c_int,
162 lockstate: c_int,
163 flags: u32,
164 copy: *mut c_void,
165 dispose: *mut c_void,
166 free: *mut c_void,
167 priv_uint: u32,
168 priv_pointer: *mut c_void,
169}
170
171/// A recording or other audio file playing, paused or ended.
172pub struct Player {
173 pipeline: Pipeline,
174 playing: bool,
175 /// Whether the file played to its end, which the pipeline reports once.
176 ended: Cell<bool>,
177}
178
179impl Player {
180 /// The file at `path`, ready to play; none when GStreamer is missing or cannot decode it.
181 pub fn open(path: &Path) -> Option<Self> {
182 let pipeline = Pipeline::launch(&format!(
183 "playbin uri=\"{}\" video-sink=fakesink",
184 uri(path)
185 ))?;
186 // Prerolling reports a file no plugin decodes as an error.
187 if pipeline.wait(PATIENCE, ASYNC_DONE | ERROR) != Some(ASYNC_DONE) {
188 return None;
189 }
190 Some(Self {
191 pipeline,
192 playing: false,
193 ended: Cell::new(false),
194 })
195 }
196
197 pub fn play(&mut self) {
198 self.ended.set(false);
199 self.playing = self.pipeline.set(PLAYING);
200 }
201
202 pub fn pause(&mut self) {
203 self.pipeline.set(PAUSED);
204 self.playing = false;
205 }
206
207 pub fn playing(&self) -> bool {
208 if self.playing && !self.ended.get() && self.pipeline.wait(0, EOS).is_some() {
209 self.ended.set(true);
210 }
211 self.playing && !self.ended.get()
212 }
213
214 pub fn position_ms(&self) -> u32 {
215 if self.ended.get() {
216 return self.duration_ms();
217 }
218 self.pipeline.query(self.pipeline.gst.query_position)
219 }
220
221 pub fn duration_ms(&self) -> u32 {
222 self.pipeline.query(self.pipeline.gst.query_duration)
223 }
224
225 pub fn seek(&mut self, ms: u32) {
226 self.ended.set(false);
227 unsafe {
228 (self.pipeline.gst.seek_simple)(
229 self.pipeline.element,
230 TIME,
231 SEEK,
232 i64::from(ms) * 1_000_000,
233 );
234 }
235 }
236}
237
238/// The default microphone recording to a file.
239pub struct Microphone(Pipeline);
240
241impl Microphone {
242 /// Records mono 16-bit PCM at `rate` into a WAV file at `path`.
243 pub fn start(path: &Path, rate: u32) -> Result<Self, String> {
244 let location = path
245 .to_str()
246 .filter(|location| !location.contains('"'))
247 .ok_or("Try recording again.")?;
248 let pipeline = Pipeline::launch(&format!(
249 "autoaudiosrc ! audioconvert ! audioresample ! \
250 audio/x-raw,format=S16LE,rate={rate},channels=1 ! wavenc ! \
251 filesink location=\"{location}\""
252 ))
253 .ok_or("Install GStreamer and its base plugins to record audio.")?;
254 if !pipeline.set(PLAYING) {
255 return Err("Connect a microphone and try again.".into());
256 }
257 Ok(Self(pipeline))
258 }
259
260 /// Ends the recording, its file complete: the end travels down the pipeline so the WAV
261 /// header is written.
262 pub fn stop(self) -> Result<(), String> {
263 let pipeline = &self.0;
264 unsafe { (pipeline.gst.send_event)(pipeline.element, (pipeline.gst.new_eos)()) };
265 match pipeline.wait(PATIENCE, EOS | ERROR) {
266 Some(EOS) => Ok(()),
267 _ => Err("The recording stopped early. Try recording again.".into()),
268 }
269 }
270}
crates/snowbound/src/media_macos.rs created+182
...@@ -0,0 +1,182 @@
1//! Audio through AVFoundation: `AVAudioPlayer` plays what Core Audio decodes (WAV, MP3, AAC;
2//! not Windows Media), and `AVAudioRecorder` records the default microphone as PCM WAV.
3
4use objc2::{
5 msg_send, msg_send_id,
6 rc::{Allocated, Retained},
7 runtime::{AnyClass, AnyObject},
8};
9use objc2_foundation::{NSString, NSURL};
10use std::path::Path;
11
12#[link(name = "AVFoundation", kind = "framework")]
13unsafe extern "C" {
14 static AVFormatIDKey: &'static NSString;
15 static AVSampleRateKey: &'static NSString;
16 static AVNumberOfChannelsKey: &'static NSString;
17 static AVLinearPCMBitDepthKey: &'static NSString;
18 static AVLinearPCMIsFloatKey: &'static NSString;
19 static AVLinearPCMIsBigEndianKey: &'static NSString;
20 static AVMediaTypeAudio: &'static NSString;
21}
22
23/// kAudioFormatLinearPCM.
24const LINEAR_PCM: u32 = u32::from_be_bytes(*b"lpcm");
25
26fn class(name: &str) -> &'static AnyClass {
27 AnyClass::get(name).expect("AVFoundation is linked")
28}
29
30fn url(path: &Path) -> Option<Retained<NSURL>> {
31 Some(unsafe { NSURL::fileURLWithPath(&NSString::from_str(path.to_str()?)) })
32}
33
34/// A recording or other audio file playing, paused or ended.
35pub struct Player(Retained<AnyObject>);
36
37impl Player {
38 /// The file at `path`, ready to play; none when Core Audio cannot decode it.
39 pub fn open(path: &Path) -> Option<Self> {
40 let url = url(path)?;
41 unsafe {
42 let player: Allocated<AnyObject> = msg_send_id![class("AVAudioPlayer"), alloc];
43 let player: Option<Retained<AnyObject>> = msg_send_id![
44 player,
45 initWithContentsOfURL: &*url,
46 error: std::ptr::null_mut::<*mut AnyObject>()
47 ];
48 let player = player?;
49 let ready: bool = msg_send![&player, prepareToPlay];
50 ready.then_some(Self(player))
51 }
52 }
53
54 pub fn play(&mut self) {
55 let _: bool = unsafe { msg_send![&self.0, play] };
56 }
57
58 pub fn pause(&mut self) {
59 let _: () = unsafe { msg_send![&self.0, pause] };
60 }
61
62 pub fn playing(&self) -> bool {
63 unsafe { msg_send![&self.0, isPlaying] }
64 }
65
66 pub fn position_ms(&self) -> u32 {
67 let seconds: f64 = unsafe { msg_send![&self.0, currentTime] };
68 (seconds * 1000.0) as u32
69 }
70
71 pub fn duration_ms(&self) -> u32 {
72 let seconds: f64 = unsafe { msg_send![&self.0, duration] };
73 (seconds * 1000.0) as u32
74 }
75
76 pub fn seek(&mut self, ms: u32) {
77 let _: () = unsafe { msg_send![&self.0, setCurrentTime: f64::from(ms) / 1000.0] };
78 }
79}
80
81impl Drop for Player {
82 fn drop(&mut self) {
83 let _: () = unsafe { msg_send![&self.0, stop] };
84 }
85}
86
87/// The default microphone recording to a file.
88pub struct Microphone(Retained<AnyObject>);
89
90impl Microphone {
91 /// Records mono 16-bit PCM at `rate` into a WAV file at `path`. The system asks for the
92 /// microphone the first time; refused, recording fails with what to do about it.
93 pub fn start(path: &Path, rate: u32) -> Result<Self, String> {
94 let refused = || {
95 "Allow Snowbound to use the microphone in System Settings, Privacy & Security, \
96 Microphone."
97 .to_owned()
98 };
99 unsafe {
100 // AVAuthorizationStatusRestricted and AVAuthorizationStatusDenied.
101 let status: isize = msg_send![
102 class("AVCaptureDevice"),
103 authorizationStatusForMediaType: AVMediaTypeAudio
104 ];
105 if matches!(status, 1 | 2) {
106 return Err(refused());
107 }
108 let number = |value: u32| -> Retained<AnyObject> {
109 msg_send_id![class("NSNumber"), numberWithUnsignedInt: value]
110 };
111 let settings: Retained<AnyObject> =
112 msg_send_id![class("NSMutableDictionary"), dictionary];
113 for (key, value) in [
114 (AVFormatIDKey, number(LINEAR_PCM)),
115 (AVSampleRateKey, number(rate)),
116 (AVNumberOfChannelsKey, number(1)),
117 (AVLinearPCMBitDepthKey, number(16)),
118 (AVLinearPCMIsFloatKey, number(0)),
119 (AVLinearPCMIsBigEndianKey, number(0)),
120 ] {
121 let _: () = msg_send![&settings, setObject: &*value, forKey: key];
122 }
123 let url = url(path).ok_or("Try recording again.")?;
124 let recorder: Allocated<AnyObject> = msg_send_id![class("AVAudioRecorder"), alloc];
125 let recorder: Option<Retained<AnyObject>> = msg_send_id![
126 recorder,
127 initWithURL: &*url,
128 settings: &*settings,
129 error: std::ptr::null_mut::<*mut AnyObject>()
130 ];
131 let recorder = recorder.ok_or("Connect a microphone and try again.")?;
132 let recording: bool = msg_send![&recorder, record];
133 if !recording {
134 return Err(refused());
135 }
136 Ok(Self(recorder))
137 }
138 }
139
140 /// Ends the recording, its file complete.
141 pub fn stop(self) -> Result<(), String> {
142 let _: () = unsafe { msg_send![&self.0, stop] };
143 Ok(())
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 /// What Snowbound records, compressed, plays back through Core Audio at its length.
152 #[test]
153 fn core_audio_plays_the_compressed_recordings() {
154 let pcm =
155 crate::recording::tests::pcm(crate::recording::RATE, &crate::recording::tests::tone());
156 let (bytes, duration) = crate::recording::compress(&pcm).unwrap();
157 let folder = std::env::temp_dir().join(format!("snowbound-player-{}", std::process::id()));
158 std::fs::create_dir_all(&folder).unwrap();
159 let path = folder.join("tone.wav");
160 std::fs::write(&path, bytes).unwrap();
161 let mut player = Player::open(&path).unwrap();
162 assert!(
163 player.duration_ms().abs_diff(duration) < 50,
164 "{}",
165 player.duration_ms()
166 );
167 player.seek(1500);
168 assert!(player.position_ms().abs_diff(1500) < 50);
169 drop(player);
170 // OneNote's own recording, Windows Media Audio, stays with the system's player.
171 let wma = folder.join("Recorded.wma");
172 std::fs::write(
173 &wma,
174 include_bytes!(
175 "../../../corpus/recording/edit/cold/read/c41bd6bd61277bdc4bf3557b5572be5c08451ba5b4665aba662ac1280d907ca3.attachment"
176 ),
177 )
178 .unwrap();
179 assert!(Player::open(&wma).is_none());
180 std::fs::remove_dir_all(&folder).unwrap();
181 }
182}
crates/snowbound/src/pane.rs+2-2
...@@ -391,7 +391,7 @@ impl State {...@@ -391,7 +391,7 @@ impl State {
391 self.search.finding = false;391 self.search.finding = false;
392 self.search.claim = true;392 self.search.claim = true;
393 self.ui.close_popup(crate::search::results());393 self.ui.close_popup(crate::search::results());
394 self.sync_index(true);394 self.sync_index(true, Vec::new());
395 }395 }
396396
397 /// Opens or closes a pane, as its command does.397 /// Opens or closes a pane, as its command does.
...@@ -406,7 +406,7 @@ impl State {...@@ -406,7 +406,7 @@ impl State {
406 unchecked: false,406 unchecked: false,
407 scope: TagScope::Notebook,407 scope: TagScope::Notebook,
408 });408 });
409 self.sync_index(true);409 self.sync_index(true, Vec::new());
410 }410 }
411 _ => self.open_search_pane(),411 _ => self.open_search_pane(),
412 }412 }
crates/snowbound/src/recording.rs created+687
...@@ -0,0 +1,687 @@
1//! Record Audio and playback, as OneNote 2010 records and plays (`corpus/recording`): a
2//! recording starts at the caret and goes on the page when stopped, as a WAV file OneNote
3//! plays; a recording, or a note linked to a moment in one, plays in a small transport over
4//! the page. What the platform cannot decode opens in the system's player.
5
6use crate::{State, media, platform};
7use onestore::page::{Attachment, Recording};
8use std::{
9 error::Error,
10 path::{Path, PathBuf},
11 time::{Duration, Instant},
12};
13use ui::{Axis, Flags, Spec, Theme, fit, px};
14
15/// The rate recordings are made at: wideband speech, as OneNote's own 8 kHz WMA is narrowband.
16pub(crate) const RATE: u32 = 16_000;
17
18/// What records or plays now.
19#[derive(Default)]
20pub(crate) enum Media {
21 #[default]
22 Idle,
23 Recording {
24 microphone: media::Microphone,
25 id: [u8; 16],
26 path: PathBuf,
27 started: Instant,
28 },
29 Playing {
30 player: media::Player,
31 name: String,
32 },
33}
34
35impl State {
36 /// Record Audio: starts recording at the caret, or stops the recording under way.
37 pub(crate) fn record_audio(&mut self) -> Result<(), Box<dyn Error>> {
38 if matches!(self.media, Media::Recording { .. }) {
39 return self.stop_recording();
40 }
41 self.media = Media::Idle;
42 let folder = std::env::temp_dir().join("Snowbound Recordings");
43 std::fs::create_dir_all(&folder)?;
44 let path = folder.join(format!("{}.wav", std::process::id()));
45 let microphone = match media::Microphone::start(&path, RATE) {
46 Ok(microphone) => microphone,
47 Err(detail) => {
48 platform::alert("Couldn't record audio", &detail);
49 return Ok(());
50 }
51 };
52 let [date, time] = platform::date_text(crate::filetime());
53 let label = format!("Audio recording started: {time} {date}");
54 let (id, response) = self.view.start_recording(&label)?;
55 self.respond(response);
56 let Some(id) = id else {
57 microphone.stop()?;
58 return Ok(());
59 };
60 self.media = Media::Recording {
61 microphone,
62 id,
63 path,
64 started: Instant::now(),
65 };
66 Ok(())
67 }
68
69 /// Stop: the recording under way goes on the page where it started.
70 pub(crate) fn stop_recording(&mut self) -> Result<(), Box<dyn Error>> {
71 let Media::Recording {
72 microphone,
73 id,
74 path,
75 ..
76 } = std::mem::take(&mut self.media)
77 else {
78 return Ok(());
79 };
80 if let Err(detail) = microphone.stop() {
81 platform::alert("Couldn't finish the recording", &detail);
82 return Ok(());
83 }
84 let recorded = std::fs::read(&path)?;
85 let (bytes, duration_ms) = match compress(&recorded) {
86 Some((bytes, duration)) => (bytes, Some(duration)),
87 None => (recorded, None),
88 };
89 let preview = platform::file_icon(&path);
90 std::fs::remove_file(&path)?;
91 let title = self.view.editor.page()?.title;
92 let name: String = title
93 .chars()
94 .map(|c| {
95 if matches!(c, '/' | '\\' | '\0') {
96 '_'
97 } else {
98 c
99 }
100 })
101 .collect();
102 let name = if name.trim().is_empty() {
103 "Audio recording".to_owned()
104 } else {
105 name
106 };
107 let file = Attachment {
108 id: onestore::page::text::new_id()?,
109 filename: format!("{name}.wav"),
110 source_path: None,
111 size: Some(crate::attachment::ICON_SIZE),
112 layout: Default::default(),
113 bytes: Some(bytes.into()),
114 preview: preview.map(Into::into),
115 recording: Some(Recording {
116 id,
117 kind: 1,
118 duration_ms,
119 }),
120 };
121 let response = self.view.finish_recording(file)?;
122 self.respond(response);
123 Ok(())
124 }
125
126 /// Plays recording `file` from `at_ms` in the transport, or opens it in the system's
127 /// player where the platform cannot decode it.
128 pub(crate) fn play(&mut self, file: &Attachment, at_ms: u32) -> Result<(), Box<dyn Error>> {
129 if matches!(self.media, Media::Recording { .. }) {
130 return Ok(());
131 }
132 self.media = Media::Idle;
133 let Some(path) = self.copy_attachment(file)? else {
134 return Ok(());
135 };
136 let Some(mut player) = media::Player::open(&path) else {
137 platform::open_file(&path);
138 return Ok(());
139 };
140 player.seek(at_ms);
141 player.play();
142 let name = Path::new(&file.filename)
143 .file_stem()
144 .map_or_else(String::new, |stem| stem.to_string_lossy().into_owned());
145 self.media = Media::Playing { player, name };
146 Ok(())
147 }
148
149 /// When the transport's clock next changes what it shows.
150 pub(crate) fn media_wake(&self, now: Instant) -> Option<Instant> {
151 match &self.media {
152 Media::Idle => None,
153 Media::Playing { player, .. } if !player.playing() => None,
154 _ => Some(now + Duration::from_millis(250)),
155 }
156 }
157
158 /// The transport over the page's foot while something records or plays: OneNote's
159 /// Recording and Playback tabs, cut to what a note taker reaches for.
160 pub(crate) fn transport(
161 &mut self,
162 theme: &Theme,
163 page: [f32; 4],
164 ) -> Result<(), Box<dyn Error>> {
165 let (clock, playing) = match &self.media {
166 Media::Idle => return Ok(()),
167 Media::Recording { started, .. } => (
168 format!("Recording {}", clock(started.elapsed().as_millis() as u32)),
169 None,
170 ),
171 Media::Playing { player, name } => (
172 format!(
173 "{name} {} / {}",
174 clock(player.position_ms()),
175 clock(player.duration_ms())
176 ),
177 Some(player.playing()),
178 ),
179 };
180 let height = theme.font_size * 2.0 + 16.0;
181 self.ui.open(
182 "transport",
183 Spec {
184 flags: Flags::FLOAT | Flags::CLICKABLE,
185 axis: Axis::X,
186 size: [ui::children(), px(height)],
187 position: [16.0, page[3] - page[1] - height - 16.0],
188 fill: Some(theme.popup),
189 shadow: Some(theme.shadow),
190 radius: 8.0,
191 pad: [8.0, 8.0],
192 gap: 6.0,
193 ..Spec::default()
194 },
195 );
196 let mut chosen = None;
197 if let Some(playing) = playing {
198 if ui::button(&mut self.ui, "play", if playing { "Pause" } else { "Play" }).clicked {
199 chosen = Some(Control::Toggle);
200 }
201 if ui::button(&mut self.ui, "back", "−10 s").clicked {
202 chosen = Some(Control::Skip(-10_000));
203 }
204 if ui::button(&mut self.ui, "forward", "+10 s").clicked {
205 chosen = Some(Control::Skip(10_000));
206 }
207 }
208 if ui::button(&mut self.ui, "stop", "Stop").clicked {
209 chosen = Some(Control::Stop);
210 }
211 self.ui.leaf(
212 "clock",
213 Spec {
214 size: [fit(), px(theme.font_size * 2.0)],
215 text: Some(&clock),
216 pad: [6.0, 0.0],
217 center: true,
218 ..Spec::default()
219 },
220 );
221 self.ui.close();
222 match (chosen, &mut self.media) {
223 (Some(Control::Stop), Media::Recording { .. }) => self.stop_recording()?,
224 (Some(Control::Stop), _) => self.media = Media::Idle,
225 (Some(Control::Toggle), Media::Playing { player, .. }) => {
226 if player.playing() {
227 player.pause();
228 } else {
229 if player.position_ms() >= player.duration_ms() {
230 player.seek(0);
231 }
232 player.play();
233 }
234 }
235 (Some(Control::Skip(delta)), Media::Playing { player, .. }) => {
236 let at = i64::from(player.position_ms()) + delta;
237 player.seek(at.clamp(0, i64::from(player.duration_ms())) as u32);
238 }
239 _ => {}
240 }
241 Ok(())
242 }
243}
244
245enum Control {
246 Toggle,
247 Stop,
248 Skip(i64),
249}
250
251/// `ms` as the transport shows a moment: minutes and seconds, hours when there are some.
252fn clock(ms: u32) -> String {
253 let seconds = ms / 1000;
254 match seconds / 3600 {
255 0 => format!("{}:{:02}", seconds / 60, seconds % 60),
256 hours => format!("{hours}:{:02}:{:02}", seconds / 60 % 60, seconds % 60),
257 }
258}
259
260/// A recording for a file attached under `filename`, as OneNote 2010 makes one of every
261/// audio and video file it attaches, with the length of a WAV file's `bytes`.
262pub(crate) fn attached(filename: &str, bytes: &[u8]) -> Option<Recording> {
263 Some(Recording {
264 id: onestore::page::text::new_guid().ok()?,
265 kind: Recording::kind_of(filename)?,
266 duration_ms: Wave::parse(bytes).map(|wave| wave.duration_ms()),
267 })
268}
269
270/// The chunks of a RIFF WAVE file this module reads.
271struct Wave<'a> {
272 format: u16,
273 channels: u16,
274 rate: u32,
275 bytes_per_second: u32,
276 bits: u16,
277 /// The sample count a compressed file's `fact` chunk states.
278 samples: Option<u32>,
279 data: &'a [u8],
280}
281
282impl<'a> Wave<'a> {
283 fn parse(bytes: &'a [u8]) -> Option<Self> {
284 if bytes.get(..4)? != b"RIFF" || bytes.get(8..12)? != b"WAVE" {
285 return None;
286 }
287 let u16_at = |data: &[u8], at: usize| {
288 Some(u16::from_le_bytes(data.get(at..at + 2)?.try_into().ok()?))
289 };
290 let u32_at = |data: &[u8], at: usize| {
291 Some(u32::from_le_bytes(data.get(at..at + 4)?.try_into().ok()?))
292 };
293 let (mut fmt, mut samples, mut data) = (None, None, None);
294 let mut at = 12;
295 while at + 8 <= bytes.len() {
296 let size = u32_at(bytes, at + 4)? as usize;
297 let body = &bytes[at + 8..(at + 8).saturating_add(size).min(bytes.len())];
298 match &bytes[at..at + 4] {
299 b"fmt " => fmt = Some(body),
300 b"fact" => samples = u32_at(body, 0),
301 b"data" => data = Some(body),
302 _ => {}
303 }
304 at = at + 8 + size + size % 2;
305 }
306 let fmt = fmt?;
307 Some(Self {
308 format: u16_at(fmt, 0)?,
309 channels: u16_at(fmt, 2)?,
310 rate: u32_at(fmt, 4)?,
311 bytes_per_second: u32_at(fmt, 8)?,
312 bits: u16_at(fmt, 14)?,
313 samples,
314 data: data?,
315 })
316 }
317
318 fn duration_ms(&self) -> u32 {
319 let ms = match self.samples.filter(|_| self.format != 1) {
320 Some(samples) => u64::from(samples) * 1000 / u64::from(self.rate.max(1)),
321 None => self.data.len() as u64 * 1000 / u64::from(self.bytes_per_second.max(1)),
322 };
323 u32::try_from(ms).unwrap_or(u32::MAX)
324 }
325}
326
327/// IMA ADPCM's step sizes and how each code moves along them.
328const STEPS: [i32; 89] = [
329 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 25, 28, 31, 34, 37, 41, 45, 50, 55, 60, 66,
330 73, 80, 88, 97, 107, 118, 130, 143, 157, 173, 190, 209, 230, 253, 279, 307, 337, 371, 408, 449,
331 494, 544, 598, 658, 724, 796, 876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066, 2272,
332 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358, 5894, 6484, 7132, 7845, 8630, 9493,
333 10442, 11487, 12635, 13899, 15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767,
334];
335const INDEX_STEPS: [i32; 8] = [-1, -1, -1, -1, 2, 4, 6, 8];
336
337/// Bytes per block: Windows' IMA ADPCM codec's choice for 16 kHz.
338const BLOCK: usize = 512;
339/// Samples per block: the header's, then two per remaining byte.
340const BLOCK_SAMPLES: usize = (BLOCK - 4) * 2 + 1;
341
342/// A mono 16-bit PCM WAV file as 4-bit IMA ADPCM (WAVE_FORMAT_DVI_ADPCM), which Windows
343/// and macOS decode, a quarter of the size; and its length. None for other files.
344pub(crate) fn compress(wav: &[u8]) -> Option<(Vec<u8>, u32)> {
345 let wave = Wave::parse(wav)?;
346 if wave.format != 1 || wave.channels != 1 || wave.bits != 16 {
347 return None;
348 }
349 let samples: Vec<i16> = wave
350 .data
351 .chunks_exact(2)
352 .map(|pair| i16::from_le_bytes([pair[0], pair[1]]))
353 .collect();
354 let mut data = Vec::with_capacity(samples.len() / 2 + BLOCK);
355 let mut index = 0_i32;
356 for block in samples.chunks(BLOCK_SAMPLES) {
357 let mut predicted = i32::from(block[0]);
358 data.extend_from_slice(&block[0].to_le_bytes());
359 data.extend_from_slice(&[index as u8, 0]);
360 // A short last block is padded with its last sample; `fact` gives the true length.
361 let last = *block.last().unwrap();
362 let rest = block[1..]
363 .iter()
364 .copied()
365 .chain(std::iter::repeat(last))
366 .take(BLOCK_SAMPLES - 1)
367 .collect::<Vec<_>>();
368 for pair in rest.chunks_exact(2) {
369 let mut byte = 0;
370 for (shift, sample) in [0, 4].into_iter().zip(pair) {
371 let code = encode(i32::from(*sample), &mut predicted, &mut index);
372 byte |= code << shift;
373 }
374 data.push(byte);
375 }
376 }
377 let count = u32::try_from(samples.len()).ok()?;
378 let bytes_per_second = wave.rate * BLOCK as u32 / BLOCK_SAMPLES as u32;
379 let mut out = Vec::with_capacity(data.len() + 60);
380 let fmt: Vec<u8> = [
381 &0x11_u16.to_le_bytes()[..],
382 &1_u16.to_le_bytes(),
383 &wave.rate.to_le_bytes(),
384 &bytes_per_second.to_le_bytes(),
385 &(BLOCK as u16).to_le_bytes(),
386 &4_u16.to_le_bytes(),
387 &2_u16.to_le_bytes(),
388 &(BLOCK_SAMPLES as u16).to_le_bytes(),
389 ]
390 .concat();
391 let riff = 4 + (8 + fmt.len()) + (8 + 4) + (8 + data.len());
392 out.extend_from_slice(b"RIFF");
393 out.extend_from_slice(&u32::try_from(riff).ok()?.to_le_bytes());
394 out.extend_from_slice(b"WAVEfmt ");
395 out.extend_from_slice(&(fmt.len() as u32).to_le_bytes());
396 out.extend_from_slice(&fmt);
397 out.extend_from_slice(b"fact");
398 out.extend_from_slice(&4_u32.to_le_bytes());
399 out.extend_from_slice(&count.to_le_bytes());
400 out.extend_from_slice(b"data");
401 out.extend_from_slice(&u32::try_from(data.len()).ok()?.to_le_bytes());
402 out.extend_from_slice(&data);
403 let duration = u32::try_from(u64::from(count) * 1000 / u64::from(wave.rate.max(1))).ok()?;
404 Some((out, duration))
405}
406
407/// The 4-bit code nearest `sample` from `predicted`, which it and `index` then follow as a
408/// decoder does.
409fn encode(sample: i32, predicted: &mut i32, index: &mut i32) -> u8 {
410 let step = STEPS[*index as usize];
411 let mut difference = sample - *predicted;
412 let mut code = 0;
413 if difference < 0 {
414 code = 8;
415 difference = -difference;
416 }
417 let mut delta = step >> 3;
418 for (bit, part) in [(4, step), (2, step >> 1), (1, step >> 2)] {
419 if difference >= part {
420 code |= bit;
421 difference -= part;
422 delta += part;
423 }
424 }
425 *predicted = if code & 8 != 0 {
426 *predicted - delta
427 } else {
428 *predicted + delta
429 }
430 .clamp(-32768, 32767);
431 *index = (*index + INDEX_STEPS[usize::from(code & 7)]).clamp(0, 88);
432 code
433}
434
435#[cfg(test)]
436pub(crate) mod tests {
437 use super::*;
438
439 /// A mono 16-bit PCM WAV file of `samples` at `rate`, as the platform recorder writes.
440 pub(crate) fn pcm(rate: u32, samples: &[i16]) -> Vec<u8> {
441 let data: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
442 [
443 &b"RIFF"[..],
444 &(36 + data.len() as u32).to_le_bytes(),
445 b"WAVEfmt ",
446 &16_u32.to_le_bytes(),
447 &1_u16.to_le_bytes(),
448 &1_u16.to_le_bytes(),
449 &rate.to_le_bytes(),
450 &(rate * 2).to_le_bytes(),
451 &2_u16.to_le_bytes(),
452 &16_u16.to_le_bytes(),
453 b"data",
454 &(data.len() as u32).to_le_bytes(),
455 &data,
456 ]
457 .concat()
458 }
459
460 /// Two seconds of a spoken-pitch chord.
461 pub(crate) fn tone() -> Vec<i16> {
462 (0..2 * RATE)
463 .map(|n| {
464 let t = n as f32 / RATE as f32;
465 let wave = (t * 220.0 * std::f32::consts::TAU).sin() * 0.5
466 + (t * 330.0 * std::f32::consts::TAU).sin() * 0.3;
467 (wave * 12_000.0) as i16
468 })
469 .collect()
470 }
471
472 /// The reference decoder: each block's header sample and index, then its codes.
473 fn decode(wave: &Wave<'_>) -> Vec<i16> {
474 let mut out = Vec::new();
475 for block in wave.data.chunks(BLOCK) {
476 let mut predicted = i32::from(i16::from_le_bytes([block[0], block[1]]));
477 let mut index = i32::from(block[2]);
478 out.push(predicted as i16);
479 for byte in &block[4..] {
480 for code in [byte & 15, byte >> 4] {
481 let step = STEPS[index as usize];
482 let mut delta = step >> 3;
483 for (bit, part) in [(4, step), (2, step >> 1), (1, step >> 2)] {
484 if code & bit != 0 {
485 delta += part;
486 }
487 }
488 predicted = if code & 8 != 0 {
489 predicted - delta
490 } else {
491 predicted + delta
492 }
493 .clamp(-32768, 32767);
494 index = (index + INDEX_STEPS[usize::from(code & 7)]).clamp(0, 88);
495 out.push(predicted as i16);
496 }
497 }
498 }
499 out.truncate(wave.samples.unwrap() as usize);
500 out
501 }
502
503 #[test]
504 fn a_recording_compresses_to_ima_adpcm_that_decodes_close_to_what_was_heard() {
505 let samples = tone();
506 let (bytes, duration) = compress(&pcm(RATE, &samples)).unwrap();
507 assert_eq!(duration, 2000);
508 let wave = Wave::parse(&bytes).unwrap();
509 assert_eq!(
510 (wave.format, wave.channels, wave.rate, wave.bits),
511 (0x11, 1, RATE, 4)
512 );
513 assert_eq!(wave.data.len() % BLOCK, 0);
514 assert_eq!(wave.duration_ms(), 2000);
515 assert!(bytes.len() * 3 < samples.len() * 2);
516 let decoded = decode(&wave);
517 assert_eq!(decoded.len(), samples.len());
518 let noise: f64 = samples
519 .iter()
520 .zip(&decoded)
521 .map(|(a, b)| (f64::from(*a) - f64::from(*b)).powi(2))
522 .sum();
523 let signal: f64 = samples.iter().map(|a| f64::from(*a).powi(2)).sum();
524 let snr = 10.0 * (signal / noise).log10();
525 assert!(snr > 20.0, "{snr} dB");
526 }
527
528 #[test]
529 fn only_mono_16_bit_pcm_compresses_and_attached_wav_files_know_their_length() {
530 let stereo = {
531 let mut bytes = pcm(RATE, &[0; 64]);
532 bytes[22] = 2;
533 bytes
534 };
535 assert!(compress(&stereo).is_none());
536 assert!(compress(b"not a wave").is_none());
537 let silence = include_bytes!(
538 "../../../corpus/m6/native-features-01/notebook/fixture-data/silence.wav"
539 );
540 // OneNote 2010 stored 1000 ms for this file when attached (`corpus/recording`).
541 let recording = attached("silence.WAV", silence).unwrap();
542 assert_eq!((recording.kind, recording.duration_ms), (1, Some(1000)));
543 assert_eq!(attached("clip.wmv", b"").unwrap().kind, 2);
544 assert!(attached("notes.txt", b"").is_none());
545 }
546
547 /// A recording made through the editor, stored as the host stores it:
548 /// `SNOWBOUND_RECORDING_EXPORT` names a directory receiving the notebook for a cold
549 /// read in OneNote 2010 (`corpus/recording/candidate`).
550 #[test]
551 fn a_recording_stores_its_file_line_and_linked_notes_for_onenote() {
552 use canvas::{editor::CanvasEditor, layout::TextEngine};
553 use onestore::{
554 Arena, Section, Store,
555 op::{Edit, Op},
556 page::{MediaIndex, PageObject, ParagraphContent},
557 };
558 let source =
559 onestore::create_section("Recorded.one", "Before recording", "Snowbound").unwrap();
560 let arena = Arena::default();
561 let mut section = Section::open(&arena, source.clone()).unwrap();
562 let (space, ..) = section.pages().unwrap()[0].clone();
563 let mut engine = TextEngine::default();
564 let mut editor =
565 CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
566 let body = editor
567 .outlines()
568 .iter()
569 .find(|outline| !outline.title)
570 .unwrap()
571 .id;
572 editor.focus_outline(body).unwrap();
573 editor
574 .move_selection(&mut engine, draw::edit::Movement::DocumentEnd, false)
575 .unwrap();
576 let mut at = 134_000_000_000_000_000;
577 let mut store = |editor: &mut CanvasEditor| {
578 at += 10_000_000;
579 let ops = editor.take_ops().unwrap();
580 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
581 section.apply("Snowbound", &Edit { at, ops }).unwrap();
582 };
583 let id = editor
584 .start_recording(
585 &mut engine,
586 "Audio recording started: 5:18 PM Tuesday, September 29, 2026",
587 )
588 .unwrap();
589 store(&mut editor);
590 std::thread::sleep(Duration::from_millis(40));
591 editor.insert(&mut engine, "First linked note").unwrap();
592 store(&mut editor);
593 editor.insert(&mut engine, "\n").unwrap();
594 std::thread::sleep(Duration::from_millis(40));
595 editor.insert(&mut engine, "Second linked note").unwrap();
596 store(&mut editor);
597 let (bytes, duration) = compress(&pcm(RATE, &tone())).unwrap();
598 let file = Attachment {
599 id: onestore::page::text::new_id().unwrap(),
600 filename: "Recorded.wav".into(),
601 source_path: None,
602 size: Some(crate::attachment::ICON_SIZE),
603 layout: Default::default(),
604 bytes: Some(bytes.clone().into()),
605 preview: Some(canvas::gpu::page::file_icon().into()),
606 recording: Some(Recording {
607 id,
608 kind: 1,
609 duration_ms: Some(duration),
610 }),
611 };
612 editor.finish_recording(&mut engine, file).unwrap();
613 store(&mut editor);
614 let mut image = source;
615 section.seal().unwrap().unwrap().apply(&mut image).unwrap();
616
617 let arena = Arena::default();
618 let page = Section::open(&arena, image.clone())
619 .unwrap()
620 .page(space)
621 .unwrap();
622 let outline = page
623 .objects
624 .iter()
625 .find_map(|object| match object {
626 PageObject::Outline(outline) if !outline.title => Some(outline),
627 _ => None,
628 })
629 .unwrap();
630 let shown: Vec<(String, Option<u32>)> = outline
631 .paragraphs
632 .iter()
633 .map(|paragraph| {
634 let text = match &paragraph.content {
635 ParagraphContent::Text(text) => text.text.text().to_owned(),
636 ParagraphContent::Attachment(file) => {
637 assert_eq!(file.bytes.as_deref(), Some(bytes.as_slice()));
638 format!("[{}]", file.filename)
639 }
640 _ => unreachable!(),
641 };
642 if paragraph.media != MediaIndex::default() {
643 assert_eq!(paragraph.media.recordings, [id]);
644 }
645 (text, paragraph.media.time_ms)
646 })
647 .collect();
648 let texts: Vec<&str> = shown.iter().map(|(text, _)| text.as_str()).collect();
649 assert_eq!(
650 texts,
651 [
652 "Before recording",
653 "[Recorded.wav]",
654 "",
655 "Audio recording started: 5:18 PM Tuesday, September 29, 2026",
656 "First linked note",
657 "Second linked note",
658 ]
659 );
660 assert_eq!(shown[1].1, Some(0));
661 assert_eq!(shown[3].1, Some(0));
662 assert!(shown[4].1.unwrap() >= 40 && shown[5].1.unwrap() > shown[4].1.unwrap());
663
664 if let Some(directory) = std::env::var_os("SNOWBOUND_RECORDING_EXPORT") {
665 let directory = PathBuf::from(directory);
666 std::fs::create_dir_all(&directory).unwrap();
667 std::fs::write(directory.join("Recorded.one"), &image).unwrap();
668 let file_id = Store::parse(&image).unwrap().header.file_id;
669 std::fs::write(
670 directory.join("Open Notebook.onetoc2"),
671 onestore::create_table_of_contents(
672 "Open Notebook.onetoc2",
673 &[("Recorded.one", file_id)],
674 )
675 .unwrap(),
676 )
677 .unwrap();
678 }
679 }
680
681 #[test]
682 fn the_clock_shows_minutes_and_seconds() {
683 assert_eq!(clock(8_400), "0:08");
684 assert_eq!(clock(754_000), "12:34");
685 assert_eq!(clock(3_723_000), "1:02:03");
686 }
687}
crates/snowbound/src/search.rs+39-15
...@@ -82,11 +82,14 @@ pub fn takes_text(focus: Option<Id>) -> bool {...@@ -82,11 +82,14 @@ pub fn takes_text(focus: Option<Id>) -> bool {
8282
83/// Work for the index thread.83/// Work for the index thread.
84enum Job {84enum Job {
85 /// Brings the index to these notebooks: sections whose files changed are read again and85 /// Brings the index to these notebooks: sections not read yet are read, and those no
86 /// those no longer listed dropped. The open section, by key, is read through its replica.86 /// longer listed dropped. Of those read before, a notebook's background sync names the
87 /// changed ones, by key, in `changed`; a section on its own is read again when its file
88 /// changed. The open section, by key, is read through its replica.
87 Notebooks {89 Notebooks {
88 libraries: Vec<Arc<Library>>,90 libraries: Vec<Arc<Library>>,
89 open: Option<(String, Weak<Replica>)>,91 open: Option<(String, Weak<Replica>)>,
92 changed: Vec<String>,
90 },93 },
91 /// Reads pages of the open section again, with its page list.94 /// Reads pages of the open section again, with its page list.
92 Pages {95 Pages {
...@@ -217,10 +220,18 @@ fn run(...@@ -217,10 +220,18 @@ fn run(
217 std::thread::sleep(SETTLE);220 std::thread::sleep(SETTLE);
218 }221 }
219 let mut notebooks = None;222 let mut notebooks = None;
223 let mut changed = HashSet::new();
220 let mut pages: HashMap<String, (Weak<Replica>, HashSet<ExGuid>)> = HashMap::new();224 let mut pages: HashMap<String, (Weak<Replica>, HashSet<ExGuid>)> = HashMap::new();
221 for job in std::iter::once(first).chain(jobs.try_iter()) {225 for job in std::iter::once(first).chain(jobs.try_iter()) {
222 match job {226 match job {
223 Job::Notebooks { libraries, open } => notebooks = Some((libraries, open)),227 Job::Notebooks {
228 libraries,
229 open,
230 changed: keys,
231 } => {
232 notebooks = Some((libraries, open));
233 changed.extend(keys);
234 }
224 Job::Pages {235 Job::Pages {
225 key,236 key,
226 replica,237 replica,
...@@ -234,7 +245,14 @@ fn run(...@@ -234,7 +245,14 @@ fn run(
234 }245 }
235 let start = Instant::now();246 let start = Instant::now();
236 if let Some((libraries, open)) = notebooks {247 if let Some((libraries, open)) = notebooks {
237 sync(&index, &version, &mut stamps, &libraries, open.as_ref());248 sync(
249 &index,
250 &version,
251 &mut stamps,
252 &libraries,
253 open.as_ref(),
254 &changed,
255 );
238 lap("index notebooks", start);256 lap("index notebooks", start);
239 }257 }
240 for (key, (replica, spaces)) in pages {258 for (key, (replica, spaces)) in pages {
...@@ -288,6 +306,7 @@ fn sync(...@@ -288,6 +306,7 @@ fn sync(
288 stamps: &mut HashMap<String, Stamp>,306 stamps: &mut HashMap<String, Stamp>,
289 libraries: &[Arc<Library>],307 libraries: &[Arc<Library>],
290 open: Option<&(String, Weak<Replica>)>,308 open: Option<&(String, Weak<Replica>)>,
309 changed: &HashSet<String>,
291) {310) {
292 let mut listed = HashSet::new();311 let mut listed = HashSet::new();
293 for library in libraries {312 for library in libraries {
...@@ -299,12 +318,16 @@ fn sync(...@@ -299,12 +318,16 @@ fn sync(
299 Some(_) => Path::new(&library.location).join(&path),318 Some(_) => Path::new(&library.location).join(&path),
300 None => PathBuf::from(&library.location),319 None => PathBuf::from(&library.location),
301 };320 };
302 let stamp = stamp(&file);321 // A notebook's background sync names its changed sections, so none is checked here.
303 // A file with no stamp, as on a share not mounted, is read once.322 let reported = library.background.is_some();
304 if stamps323 let stamp = if reported { None } else { stamp(&file) };
305 .get(&key)324 let fresh = match stamps.get(&key) {
306 .is_some_and(|known| known == &stamp || stamp.is_none())325 None => false,
307 {326 Some(_) if reported => !changed.contains(&key),
327 // A file with no stamp, as on a share not mounted, is read once.
328 Some(known) => known == &stamp || stamp.is_none(),
329 };
330 if fresh {
308 continue;331 continue;
309 }332 }
310 let replica = open333 let replica = open
...@@ -458,9 +481,9 @@ fn place(notebooks: &[Arc<Library>], key: &str) -> String {...@@ -458,9 +481,9 @@ fn place(notebooks: &[Arc<Library>], key: &str) -> String {
458}481}
459482
460impl State {483impl State {
461 /// Brings the index to the open notebooks and section when they changed, and to484 /// Brings the index to the open notebooks and section when they changed, and when
462 /// section files changed since, when `always`.485 /// `always` or sections `changed` names, by key, to the section files changed since.
463 pub(crate) fn sync_index(&mut self, always: bool) {486 pub(crate) fn sync_index(&mut self, always: bool, changed: Vec<String>) {
464 let open = self.session.as_ref().map(|session| {487 let open = self.session.as_ref().map(|session| {
465 (488 (
466 session.library.key(&session.tabs[session.tab].path),489 session.library.key(&session.tabs[session.tab].path),
...@@ -473,13 +496,14 @@ impl State {...@@ -473,13 +496,14 @@ impl State {
473 .map(|library| Arc::as_ptr(library) as usize)496 .map(|library| Arc::as_ptr(library) as usize)
474 .chain(open.as_ref().map(|(_, replica)| replica.as_ptr() as usize))497 .chain(open.as_ref().map(|(_, replica)| replica.as_ptr() as usize))
475 .collect();498 .collect();
476 if !always && identity == self.search.synced {499 if !always && changed.is_empty() && identity == self.search.synced {
477 return;500 return;
478 }501 }
479 self.search.synced = identity;502 self.search.synced = identity;
480 let _ = self.search.jobs.send(Job::Notebooks {503 let _ = self.search.jobs.send(Job::Notebooks {
481 libraries: self.notebooks.clone(),504 libraries: self.notebooks.clone(),
482 open,505 open,
506 changed,
483 });507 });
484 }508 }
485509
...@@ -596,7 +620,7 @@ impl State {...@@ -596,7 +620,7 @@ impl State {
596 pub(crate) fn start_search(&mut self) {620 pub(crate) fn start_search(&mut self) {
597 self.search.finding = false;621 self.search.finding = false;
598 if !self.ui.popup_open(results()) {622 if !self.ui.popup_open(results()) {
599 self.sync_index(true);623 self.sync_index(true, Vec::new());
600 self.ui.open_popup(results());624 self.ui.open_popup(results());
601 self.search.open = true;625 self.search.open = true;
602 }626 }
crates/snowbound/src/settings.rs+4
...@@ -39,6 +39,8 @@ pub struct Toolbar {...@@ -39,6 +39,8 @@ pub struct Toolbar {
39 /// Places in OneNote's bullet and numbering libraries picked lately, latest first.39 /// Places in OneNote's bullet and numbering libraries picked lately, latest first.
40 pub bullets: Vec<usize>,40 pub bullets: Vec<usize>,
41 pub numbering: Vec<usize>,41 pub numbering: Vec<usize>,
42 /// The place in the pen gallery Pen draws with: the accent pen first.
43 pub pen: usize,
42}44}
4345
44impl Default for Toolbar {46impl Default for Toolbar {
...@@ -49,6 +51,7 @@ impl Default for Toolbar {...@@ -49,6 +51,7 @@ impl Default for Toolbar {
49 font_color: Some(0x0000ff),51 font_color: Some(0x0000ff),
50 bullets: Vec::new(),52 bullets: Vec::new(),
51 numbering: Vec::new(),53 numbering: Vec::new(),
54 pen: 0,
52 }55 }
53 }56 }
54}57}
...@@ -175,6 +178,7 @@ mod tests {...@@ -175,6 +178,7 @@ mod tests {
175 font_color: Some(0x00ff00),178 font_color: Some(0x00ff00),
176 bullets: vec![3, 0],179 bullets: vec![3, 0],
177 numbering: vec![16],180 numbering: vec![16],
181 pen: 6,
178 },182 },
179 user_name: Some("Snowbound Test".into()),183 user_name: Some("Snowbound Test".into()),
180 color_scheme: ColorScheme::Dark,184 color_scheme: ColorScheme::Dark,
crates/snowbound/src/watch.rs+86-35
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1//! Changes below a notebook folder on this computer, as the system reports them: FSEvents on1//! Changes below a notebook folder, as the system reports them: FSEvents on macOS, inotify on
2//! macOS, inotify on Linux. A notebook on a share learns of its changes from the server2//! Linux. A notebook on a share learns of its changes from the server instead
3//! instead (`notebook::session::Background::smb`).3//! (`notebook::session::Background::smb`).
44
5use std::path::Path;5use std::path::{Path, PathBuf};
66
7/// Reports until dropped.7/// Reports until dropped.
8pub struct Watch {8pub struct Watch {
...@@ -10,12 +10,33 @@ pub struct Watch {...@@ -10,12 +10,33 @@ pub struct Watch {
10}10}
1111
12/// Watches the folder at `root`: `changed` hears the paths that changed below it, relative to12/// Watches the folder at `root`: `changed` hears the paths that changed below it, relative to
13/// it and `/`-separated, or `""` when the system can only say something did. `None` where the13/// it and `/`-separated, or a folder's path when the system can only say something in it did.
14/// system cannot watch it.14/// `None` where the system does not report every change: on a network volume other than a
15/// Mac's SMB mount, another computer's changes go unreported.
15pub fn watch(root: &Path, changed: impl Fn(Vec<String>) + Send + Sync + 'static) -> Option<Watch> {16pub fn watch(root: &Path, changed: impl Fn(Vec<String>) + Send + Sync + 'static) -> Option<Watch> {
16 let root = root.canonicalize().ok()?;17 let root = root.canonicalize().ok()?;
18 let volume = statfs(&root)?;
19 let folders = if local(&volume) {
20 vec![root.clone()]
21 } else if smbfs(&volume) {
22 // smbfs reports another client's change only in a folder watched for itself, and
23 // names just that folder.
24 let mut folders = vec![root.clone()];
25 let mut at = 0;
26 while let Some(folder) = folders.get(at).cloned() {
27 at += 1;
28 for entry in std::fs::read_dir(folder).into_iter().flatten().flatten() {
29 if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
30 folders.push(entry.path());
31 }
32 }
33 }
34 folders
35 } else {
36 return None;
37 };
17 let folder = root.clone();38 let folder = root.clone();
18 let relative = move |paths: Vec<std::path::PathBuf>| {39 let relative = move |paths: Vec<PathBuf>| {
19 changed(40 changed(
20 paths41 paths
21 .iter()42 .iter()
...@@ -26,28 +47,34 @@ pub fn watch(root: &Path, changed: impl Fn(Vec<String>) + Send + Sync + 'static)...@@ -26,28 +47,34 @@ pub fn watch(root: &Path, changed: impl Fn(Vec<String>) + Send + Sync + 'static)
26 .collect(),47 .collect(),
27 )48 )
28 };49 };
29 platform::Stream::start(&root, Box::new(relative))50 platform::Stream::start(&folders, Box::new(relative))
30 .inspect_err(|error| eprintln!("{}: changes go unwatched: {error}", root.display()))51 .inspect_err(|error| eprintln!("{}: changes go unwatched: {error}", root.display()))
31 .ok()52 .ok()
32 .map(|stream| Watch { _stream: stream })53 .map(|stream| Watch { _stream: stream })
33}54}
3455
35/// Whether the folder at `path` is on this computer's own disks, where every change to it is56/// Whether the folder at `path` is on this computer's own disks rather than on a network
36/// one the system reports, rather than on a network volume.57/// volume.
37pub fn on_this_computer(path: &Path) -> bool {58pub fn on_this_computer(path: &Path) -> bool {
59 statfs(path).is_none_or(|volume| local(&volume))
60}
61
62fn statfs(path: &Path) -> Option<libc::statfs> {
38 use std::os::unix::ffi::OsStrExt;63 use std::os::unix::ffi::OsStrExt;
39 let Ok(name) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {64 let name = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
40 return true;
41 };
42 let mut volume: libc::statfs = unsafe { std::mem::zeroed() };65 let mut volume: libc::statfs = unsafe { std::mem::zeroed() };
43 if unsafe { libc::statfs(name.as_ptr(), &mut volume) } != 0 {66 (unsafe { libc::statfs(name.as_ptr(), &mut volume) } == 0).then_some(volume)
44 return true;67}
45 }68
46 #[cfg(target_os = "macos")]69#[cfg(target_os = "macos")]
47 return volume.f_flags & libc::MNT_LOCAL as u32 != 0;70fn local(volume: &libc::statfs) -> bool {
48 // NFS, SMB, CIFS, SMB2, FUSE (sshfs and the like), 9P, Ceph, AFS.71 volume.f_flags & libc::MNT_LOCAL as u32 != 0
49 #[cfg(target_os = "linux")]72}
50 return ![73
74// NFS, SMB, CIFS, SMB2, FUSE (sshfs and the like), 9P, Ceph, AFS.
75#[cfg(target_os = "linux")]
76fn local(volume: &libc::statfs) -> bool {
77 ![
51 0x6969,78 0x6969,
52 0x517b,79 0x517b,
53 0xff53_4d42,80 0xff53_4d42,
...@@ -57,7 +84,19 @@ pub fn on_this_computer(path: &Path) -> bool {...@@ -57,7 +84,19 @@ pub fn on_this_computer(path: &Path) -> bool {
57 0x00c3_6400,84 0x00c3_6400,
58 0x5346_414f,85 0x5346_414f,
59 ]86 ]
60 .contains(&(volume.f_type as u64));87 .contains(&(volume.f_type as u64))
88}
89
90#[cfg(target_os = "macos")]
91fn smbfs(volume: &libc::statfs) -> bool {
92 let name = unsafe { std::ffi::CStr::from_ptr(volume.f_fstypename.as_ptr()) };
93 name.to_bytes() == b"smbfs"
94}
95
96/// A Linux SMB mount reports only this computer's own changes.
97#[cfg(target_os = "linux")]
98fn smbfs(_: &libc::statfs) -> bool {
99 false
61}100}
62101
63type Changed = Box<dyn Fn(Vec<std::path::PathBuf>) + Send + Sync>;102type Changed = Box<dyn Fn(Vec<std::path::PathBuf>) + Send + Sync>;
...@@ -69,7 +108,7 @@ mod platform {...@@ -69,7 +108,7 @@ mod platform {
69 ffi::{CStr, c_char, c_void},108 ffi::{CStr, c_char, c_void},
70 io,109 io,
71 os::unix::ffi::OsStrExt,110 os::unix::ffi::OsStrExt,
72 path::{Path, PathBuf},111 path::PathBuf,
73 };112 };
74113
75 type Stream_ = *mut c_void;114 type Stream_ = *mut c_void;
...@@ -188,19 +227,31 @@ mod platform {...@@ -188,19 +227,31 @@ mod platform {
188 }227 }
189228
190 impl Stream {229 impl Stream {
191 pub fn start(root: &Path, changed: Changed) -> io::Result<Self> {230 pub fn start(folders: &[PathBuf], changed: Changed) -> io::Result<Self> {
192 let root = root.as_os_str().as_bytes();
193 let flags = NO_DEFER | WATCH_ROOT | if file_events() { FILE_EVENTS } else { 0 };231 let flags = NO_DEFER | WATCH_ROOT | if file_events() { FILE_EVENTS } else { 0 };
194 unsafe {232 unsafe {
195 let path = CFStringCreateWithBytes(233 let folders: Vec<_> = folders
234 .iter()
235 .map(|folder| {
236 let folder = folder.as_os_str().as_bytes();
237 CFStringCreateWithBytes(
238 std::ptr::null(),
239 folder.as_ptr(),
240 folder.len() as isize,
241 UTF8,
242 0,
243 )
244 })
245 .collect();
246 let paths = CFArrayCreate(
196 std::ptr::null(),247 std::ptr::null(),
197 root.as_ptr(),248 folders.as_ptr(),
198 root.len() as isize,249 folders.len() as isize,
199 UTF8,250 &kCFTypeArrayCallBacks,
200 0,
201 );251 );
202 let paths = CFArrayCreate(std::ptr::null(), &path, 1, &kCFTypeArrayCallBacks);252 for folder in folders {
203 CFRelease(path);253 CFRelease(folder);
254 }
204 let context = Context {255 let context = Context {
205 version: 0,256 version: 0,
206 info: Box::into_raw(Box::new(changed)).cast(),257 info: Box::into_raw(Box::new(changed)).cast(),
...@@ -299,7 +350,7 @@ mod platform {...@@ -299,7 +350,7 @@ mod platform {
299 }350 }
300351
301 impl Stream {352 impl Stream {
302 pub fn start(root: &Path, changed: Changed) -> io::Result<Self> {353 pub fn start(folders: &[PathBuf], changed: Changed) -> io::Result<Self> {
303 let inotify = check(unsafe { libc::inotify_init1(libc::IN_CLOEXEC) })?;354 let inotify = check(unsafe { libc::inotify_init1(libc::IN_CLOEXEC) })?;
304 let stop = match check(unsafe { libc::eventfd(0, libc::EFD_CLOEXEC) }) {355 let stop = match check(unsafe { libc::eventfd(0, libc::EFD_CLOEXEC) }) {
305 Ok(stop) => stop,356 Ok(stop) => stop,
...@@ -308,9 +359,9 @@ mod platform {...@@ -308,9 +359,9 @@ mod platform {
308 return Err(error);359 return Err(error);
309 }360 }
310 };361 };
362 let root = folders[0].clone();
311 let mut folders = HashMap::new();363 let mut folders = HashMap::new();
312 add(inotify, root, &mut folders);364 add(inotify, &root, &mut folders);
313 let root = root.to_owned();
314 let thread = std::thread::Builder::new()365 let thread = std::thread::Builder::new()
315 .name("snowbound-watch".into())366 .name("snowbound-watch".into())
316 .spawn(move || {367 .spawn(move || {
tools/canvas/build_macos.py+1
...@@ -64,6 +64,7 @@ else:...@@ -64,6 +64,7 @@ else:
64 'NSHighResolutionCapable': True,64 'NSHighResolutionCapable': True,
65 'NSPrincipalClass': 'NSApplication',65 'NSPrincipalClass': 'NSApplication',
66 'CFBundleIconFile': 'Snowbound',66 'CFBundleIconFile': 'Snowbound',
67 'NSMicrophoneUsageDescription': 'Snowbound records audio into your notes when you choose Record Audio.',
67} | ({'LSMinimumSystemVersion': '10.6'} if args.snow_leopard else {})))68} | ({'LSMinimumSystemVersion': '10.6'} if args.snow_leopard else {})))
68# 10.6 runs the bundle unsigned.69# 10.6 runs the bundle unsigned.
69if not args.snow_leopard:70if not args.snow_leopard:
tools/test_ink_tools.py created+82
...@@ -0,0 +1,82 @@
1from pathlib import Path
2import runpy
3import shutil
4from tempfile import TemporaryDirectory
5import unittest
6import xml.etree.ElementTree as ET
7
8from native_xml import ns
9
10ROOT = Path(__file__).resolve().parent.parent
11FIXTURE = ROOT / 'corpus/ink-tools'
12compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
13
14
15def cold_pages(variant):
16 """The cold read of a Rust candidate, after checking it holds what the candidate stored."""
17 with TemporaryDirectory() as temporary:
18 read = Path(temporary) / 'read'
19 shutil.copytree(FIXTURE / variant / 'cold/read', read)
20 compare(FIXTURE / variant / 'candidate', read)
21 return pages(FIXTURE / variant / 'cold/read')
22
23
24def pages(read):
25 return {page.get('name'): page for page in (ET.parse(path).getroot() for path in sorted(read.glob('page-*.xml')))}
26
27
28def drawings(page):
29 return page.findall('one:InkDrawing', ns)
30
31
32def place(drawing):
33 position, size = drawing.find('one:Position', ns), drawing.find('one:Size', ns)
34 return [float(position.get('x')), float(position.get('y')), float(size.get('width')), float(size.get('height'))]
35
36
37class InkToolsTest(unittest.TestCase):
38 def assertPlaced(self, drawing, expected, delta=0.06):
39 for actual, wanted in zip(place(drawing), expected, strict=True):
40 self.assertAlmostEqual(actual, wanted, delta=delta)
41
42 def test_onenote_keeps_each_stroke_and_shape_a_drawing_of_its_own(self):
43 read = pages(FIXTURE / 'native-ui/read')
44 self.assertEqual(len(drawings(read['Gallery'])), 14)
45 shapes = [d.find('one:ShapeInfo', ns) for d in drawings(read['Pens'])]
46 self.assertEqual(sorted(shape.get('isLine') for shape in shapes if shape is not None), ['false', 'false', 'true', 'true'])
47 self.assertTrue((FIXTURE / 'native-ui/read/page-001.png').exists())
48
49 def test_snowbound_pens_highlighters_and_shapes_open_in_onenote(self):
50 page, = cold_pages('drawn').values()
51 found = drawings(page)
52 self.assertEqual(len(found), 6)
53 shapes = {d.get('objectID'): d.find('one:ShapeInfo', ns) for d in found}
54 lines = [s for s in shapes.values() if s is not None and s.get('isLine') == 'true']
55 closed = [s for s in shapes.values() if s is not None and s.get('isLine') == 'false']
56 self.assertEqual((len(lines), len(closed)), (2, 2))
57 rectangle = next(s for s in closed if len(s.findall('one:AnchorPoint', ns)) == 8)
58 corners = [(float(a.get('x')), float(a.get('y'))) for a in rectangle.findall('one:AnchorPoint', ns)]
59 self.assertAlmostEqual(corners[0][0], 252.0, delta=0.01)
60 self.assertAlmostEqual(corners[4][1], 194.4, delta=0.01)
61 # The highlighter's stroke spans its points, one HIMETRIC unit larger, as OneNote reports ink.
62 highlighter = next(d for d in found if abs(place(d)[3]) < 0.1)
63 self.assertPlaced(highlighter, [60.0, 190.0, 160.0, 0.03])
64 self.assertTrue((FIXTURE / 'drawn/cold/read/page-000.png').exists())
65
66 def test_onenote_drawings_keep_their_ink_after_snowbound_erases_moves_and_draws(self):
67 # Object identities differ between clones' reads, so drawings match by where they lie.
68 before = [place(d) for d in drawings(pages(FIXTURE / 'native-ui/read')['Pens'])]
69 after = [place(d) for d in drawings(cold_pages('edited')['Pens'])]
70 self.assertEqual(len(after), len(before) - 1 + 6)
71 near = lambda a, b: all(abs(x - y) < 0.001 for x, y in zip(a, b))
72 zigzag = next(p for p in before if abs(p[2] - 90.03) < 0.01 and abs(p[3] - 22.51) < 0.01)
73 line = next(p for p in before if p[2] < 0.1 and abs(p[3] - 60.04) < 0.01)
74 self.assertFalse(any(near(p, zigzag) for p in after))
75 self.assertTrue(any(near(p, [line[0] + 36.0, line[1] + 18.0, line[2], line[3]]) for p in after))
76 for kept in before:
77 if kept not in (zigzag, line):
78 self.assertTrue(any(near(p, kept) for p in after), kept)
79
80
81if __name__ == '__main__':
82 unittest.main()
tools/test_recording.py created+88
...@@ -0,0 +1,88 @@
1from pathlib import Path
2import hashlib
3import json
4import runpy
5import shutil
6from tempfile import TemporaryDirectory
7import unittest
8import xml.etree.ElementTree as ET
9
10from native_xml import ns, texts
11
12ROOT = Path(__file__).resolve().parent.parent
13FIXTURE = ROOT / 'corpus/recording'
14compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
15
16
17def recorded(read):
18 """The page holding a recording, its playlist, and each paragraph's text or file with
19 the moment it links to and the recording it holds."""
20 page = next(page for page in (ET.parse(path).getroot() for path in sorted(read.glob('page-*.xml')))
21 if page.find('one:MediaPlaylist', ns) is not None)
22 playlist = [reference.get('mediaID') for reference in
23 page.findall('one:MediaPlaylist/one:MediaReference', ns)]
24 links = []
25 for element in page.findall('.//one:OE', ns):
26 index = element.find('one:MediaIndex', ns)
27 media = element.find('one:MediaFile', ns)
28 text = element.find('one:T', ns)
29 shown = '[%s]' % media.get('preferredName') if media is not None else texts(element)[0] if text is not None else ''
30 link = None
31 if index is not None:
32 link = (index.find('one:MediaReference', ns).get('mediaID'), int(index.get('timeIndex')))
33 links.append((shown, link, media.find('one:MediaReference', ns).get('mediaID') if media is not None else None))
34 return page, playlist, links
35
36
37def payload(read, name):
38 payloads = json.loads((read / 'payloads.json').read_text(encoding='utf-8-sig'))
39 entry, = (entry for entry in payloads if entry['name'] == name)
40 data = (read / (entry['sha256'] + '.attachment')).read_bytes()
41 assert hashlib.sha256(data).hexdigest() == entry['sha256']
42 return entry, data
43
44
45class RecordingTest(unittest.TestCase):
46 def cold(self, row):
47 read = FIXTURE / row / 'cold/read'
48 with TemporaryDirectory() as temporary:
49 copy = Path(temporary) / 'read'
50 shutil.copytree(read, copy)
51 compare((FIXTURE / row / 'candidate').resolve(), copy)
52 return read
53
54 def test_snowbound_s_recording_opens_in_onenote_with_its_line_links_and_audio(self):
55 read = self.cold('.')
56 _, playlist, links = recorded(read)
57 recording, = playlist
58 shown = [text for text, _, _ in links]
59 self.assertEqual(shown, ['Before recording', '[Recorded.wav]', '',
60 'Audio recording started: 5:18 PM Tuesday, September 29, 2026',
61 'First linked note', 'Second linked note'])
62 self.assertEqual(links[1][2], recording)
63 self.assertEqual([link for _, link, _ in links[1:4]], [(recording, 0), None, (recording, 0)])
64 first, second = (link[1] for _, link, _ in links[4:])
65 self.assertTrue(40 <= first < second)
66 _, data = payload(read, 'Recorded.wav')
67 # 4-bit IMA ADPCM (WAVE_FORMAT_DVI_ADPCM), mono 16 kHz, which OneNote plays
68 # (`played-from-note.png`).
69 self.assertEqual(data[:4] + data[8:16], b'RIFFWAVEfmt ')
70 self.assertEqual(int.from_bytes(data[20:22], 'little'), 0x11)
71 self.assertEqual(int.from_bytes(data[24:28], 'little'), 16000)
72
73 def test_onenote_s_recording_opens_as_recorded_after_snowbound_deletes_restores_and_links(self):
74 read = self.cold('edit')
75 _, playlist, links = recorded(read)
76 native = FIXTURE / 'native/read'
77 _, native_playlist, native_links = recorded(native)
78 self.assertEqual(playlist, native_playlist)
79 self.assertEqual(links[:len(native_links)], native_links)
80 recording, = playlist
81 self.assertEqual(links[len(native_links):], [('Linked by Snowbound', (recording, 25000), None)])
82 entry, data = payload(read, 'Recorded.wma')
83 self.assertEqual(entry['kind'], 'MediaFile')
84 self.assertTrue(data.startswith(bytes.fromhex('3026b2758e66cf11')))
85
86
87if __name__ == '__main__':
88 unittest.main()
tools/verify-document.py+3
...@@ -369,6 +369,9 @@ def compare(notebook, native, versions=None, password_file=None):...@@ -369,6 +369,9 @@ def compare(notebook, native, versions=None, password_file=None):
369 # Stroke coordinates are absolute; left-to-right pages report them369 # Stroke coordinates are absolute; left-to-right pages report them
370 # relative to the canonical margin origin, right-to-left pages as stored.370 # relative to the canonical margin origin, right-to-left pages as stored.
371 extent = ink_extent(space, source)371 extent = ink_extent(space, source)
372 # A moved drawing keeps its strokes and takes an offset.
373 extent[0] += source['layout']['x'] or 0.0
374 extent[1] += source['layout']['y'] or 0.0
372 if not source_page['kind']['rtl']:375 if not source_page['kind']['rtl']:
373 for index, (axis, canonical_origin) in enumerate((('x', 36.0), ('y', 14.4))):376 for index, (axis, canonical_origin) in enumerate((('x', 36.0), ('y', 14.4))):
374 origin = source_page['kind']['margin_origin_' + axis]377 origin = source_page['kind']['margin_origin_' + axis]