| author | |
| committer | |
| log | a861b59985b1920526baa1ea2be635a760965f52 |
| tree | c4f311ac4712c74a65dec0c1d645717bd679f363 |
| parent | 3b58d8f8a9667e9578deab17b4bcd96e05515ad8 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
- 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.5190 files changed, 7478 insertions(+), 848 deletions(-)
apps/ios/Snowbound/Notebooks.swift+75-10| ... | ... | @@ -159,6 +159,8 @@ final class Notebook { |
| 159 | 159 | private(set) var problem: String? |
| 160 | 160 | /// The Files location being read, held open while the notebook is. |
| 161 | 161 | private var accessed: URL? |
| 162 | /// Reports other apps' changes to a folder on this device to the library. | |
| 163 | private var presenter: FolderPresenter? | |
| 162 | 164 | |
| 163 | 165 | init(id: UUID = UUID(), name: String, source: Source) { |
| 164 | 166 | self.id = id |
| ... | ... | @@ -167,6 +169,7 @@ final class Notebook { |
| 167 | 169 | } |
| 168 | 170 | |
| 169 | 171 | deinit { |
| 172 | presenter?.stop() | |
| 170 | 173 | if let handle { sb_library_free(handle) } |
| 171 | 174 | accessed?.stopAccessingSecurityScopedResource() |
| 172 | 175 | } |
| ... | ... | @@ -175,39 +178,46 @@ final class Notebook { |
| 175 | 178 | /// thread either way. |
| 176 | 179 | func open(done: @escaping () -> Void) { |
| 177 | 180 | let source = source |
| 178 | background({ () -> (OpaquePointer?, URL?, String?) in | |
| 181 | // The library, the Files location held open, why it failed, and a local folder to watch. | |
| 182 | background({ () -> (OpaquePointer?, URL?, String?, URL?) in | |
| 179 | 183 | var error: UnsafeMutablePointer<CChar>? |
| 180 | 184 | switch source { |
| 181 | 185 | case .server(let server): |
| 182 | 186 | let library = sb_library_server( |
| 183 | 187 | server.host, server.share, server.user, Keychain.password(server) ?? "", "", server.root, |
| 184 | 188 | cacheDirectory.path, &error) |
| 185 | return (library, nil, take(error)) | |
| 189 | return (library, nil, take(error), nil) | |
| 186 | 190 | case .files(let bookmark): |
| 187 | 191 | var stale = false |
| 188 | 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 | 195 | let accessed = url.startAccessingSecurityScopedResource() ? url : nil |
| 196 | let local = onThisDevice(url) | |
| 192 | 197 | var library: OpaquePointer? |
| 193 | 198 | // A coordinated read brings a cloud folder's files down before they are read. |
| 194 | 199 | var coordination: NSError? |
| 195 | 200 | 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 | 204 | case .documents(let path): |
| 200 | let library = sb_library_open( | |
| 201 | documentsDirectory.appendingPathComponent(path).path, cacheDirectory.path, &error) | |
| 202 | return (library, nil, take(error)) | |
| 205 | let url = documentsDirectory.appendingPathComponent(path) | |
| 206 | let library = sb_library_open(url.path, cacheDirectory.path, true, &error) | |
| 207 | return (library, nil, take(error), url) | |
| 203 | 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) in | |
| 213 | }) { [self] (library, accessed, problem, watched) in | |
| 207 | 214 | self.accessed?.stopAccessingSecurityScopedResource() |
| 208 | 215 | self.accessed = accessed |
| 216 | presenter?.stop() | |
| 217 | presenter = nil | |
| 209 | 218 | if let handle { sb_library_free(handle) } |
| 210 | 219 | handle = library |
| 220 | if let library, let watched { presenter = FolderPresenter(watching: watched, for: library) } | |
| 211 | 221 | self.problem = library == nil ? (problem ?? "The notebook can’t be read.") : nil |
| 212 | 222 | follow() |
| 213 | 223 | reload(done: done) |
| ... | ... | @@ -244,6 +254,61 @@ final class Notebook { |
| 244 | 254 | } |
| 245 | 255 | } |
| 246 | 256 | |
| 257 | /// Whether `url` is kept on this device, rather than by a file provider that keeps it elsewhere | |
| 258 | /// too, as iCloud Drive does. | |
| 259 | private 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. | |
| 266 | final 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 | 312 | /// The notebooks the list shows, kept across launches. |
| 248 | 313 | enum Notebooks { |
| 249 | 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 | 106 | grid point, outside any outline. It selects, drags on the grid, deletes and |
| 107 | 107 | moves with Insert Space as a picture does, and opens and saves as above. |
| 108 | 108 | |
| 109 | ## Recordings | |
| 110 | ||
| 111 | Record Audio works as OneNote 2010's does. The caret's paragraph splits around | |
| 112 | a line saying when recording started, in OneNote's grey `cite` style, and the | |
| 113 | caret goes on below it. Text written while recording links to the moment it | |
| 114 | was written. On Stop, the file goes in above that line, named after the page. | |
| 115 | Hovering a linked note or a recording shows OneNote's blue play button in the | |
| 116 | margin, and a click asks the host to play from that moment. The host records a | |
| 117 | 16 kHz WAV and stores it as IMA ADPCM, which OneNote plays. | |
| 118 | ||
| 119 | ## Drawing | |
| 120 | ||
| 121 | The Draw tab's tools work as OneNote 2010's do with a mouse (`corpus/ink-tools`). | |
| 122 | Every stroke and every shape is a drawing of its own, added on top of the page | |
| 123 | when the pen lifts: one edit and one undo step, published as one appended | |
| 124 | revision, or in one with the strokes queued behind it during a sync round trip, | |
| 125 | as keystrokes are. Shapes are ink too. Their corners snap to the placement grid, and the | |
| 126 | drawing keeps the shape's kind and anchors beside its strokes, which OneNote | |
| 127 | edits it by. A highlighter's rectangular tip multiplies what lies beneath, so | |
| 128 | text under it stays dark. The stroke eraser takes whole drawings, or only the | |
| 129 | strokes it touches in an older drawing of several. The lasso picks the drawings | |
| 130 | with most of their points inside it. They move by their offset, as OneNote | |
| 131 | moves ink, and Delete removes them. Escape returns to Select & Type, where a | |
| 132 | click on ink picks it. The default pen draws in the open section's accent at | |
| 133 | the light theme's shade, so a stroke stores one real colour and shows it in both | |
| 134 | themes. | |
| 135 | ||
| 109 | 136 | ## Content the editor doesn't understand |
| 110 | 137 | |
| 111 | 138 | Nothing 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 | 165 | stores beside every author name. Snowbound writes them too. |
| 166 | 166 | - Some things the spec requires aren't needed by OneNote to open a file. |
| 167 | 167 | Snowbound writes them anyway, because other readers exist. |
| 168 | - Ink, equations and media recordings are absent from MS-ONE entirely. Ink and | |
| 169 | math were reverse-engineered, each against an independent oracle. For ink, | |
| 170 | the stroke extents from OneNote's own export. For math, the MathML it exports, | |
| 171 | matched byte for byte. Recordings and embedded objects are read and kept, but | |
| 172 | never authored. | |
| 168 | - Ink and equations are absent from MS-ONE entirely, and so is the link from a | |
| 169 | note to a moment in a recording. Ink and math were reverse-engineered, each | |
| 170 | against an independent oracle. For ink, the stroke extents from OneNote's own | |
| 171 | export, and for the Draw tab's pens, highlighters and shapes, the objects | |
| 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. | |
| 173 | 176 | |
| 174 | 177 | Readers stay tolerant (files in the wild are older, odder, or written by other |
| 175 | 178 | tools), while the writer stays strict. |
arc/sync.md+36-18| ... | ... | @@ -16,7 +16,7 @@ happens when the share is busy, changed or gone. |
| 16 | 16 | ```text |
| 17 | 17 | UI thread (winit, UIKit) section thread sync thread |
| 18 | 18 | ──────────────────────── ────────────────────────────── ────────────────────────────── |
| 19 | editor emits Edit (ops) ───â–º apply to the parsed Section poll the stamp (header + length) | |
| 19 | editor emits Edit (ops) ───â–º apply to the parsed Section read the stamp (header + length) | |
| 20 | 20 | and returns at once write each burst to SQLite unchanged: ask for a seal, |
| 21 | 21 | page reads â—„──────────────── answered from the Section publish it |
| 22 | 22 | events â—„──────────────────── Changed, Rejected, published changed: read once, ask |
| ... | ... | @@ -33,8 +33,9 @@ events â—„──────────────────── Changed, |
| 33 | 33 | fresh thread that builds its own section while the old one keeps answering |
| 34 | 34 | reads, so opening a page never waits on the network. |
| 35 | 35 | - **The sync thread** (`notebook::worker`, stepping `notebook::sync`) does all |
| 36 | network I/O. It polls, publishes and rereads. When the queue is idle it | |
| 37 | reads nothing but the header. | |
| 36 | network I/O. It reads the stamp when a local edit waits or the notebook's watch | |
| 37 | reports the file (every two seconds where nothing watches), then publishes or | |
| 38 | rereads. Idle, it reads nothing. | |
| 38 | 39 | |
| 39 | 40 | `notebook::session` wraps this up for apps. `Notebook` covers discovery and |
| 40 | 41 | structure, `Section` covers one section's pages, edits, events and conflict |
| ... | ... | @@ -88,7 +89,7 @@ else: |
| 88 | 89 | ``` |
| 89 | 90 | |
| 90 | 91 | A batch stays open while the sync thread is busy, so keystrokes that arrive |
| 91 | during one round trip publish together in the next. Polling costs a | |
| 92 | during one round trip publish together in the next. A check costs a | |
| 92 | 93 | kilobyte-sized read. A publication costs one appended revision, not the |
| 93 | 94 | section. |
| 94 | 95 | |
| ... | ... | @@ -159,7 +160,7 @@ client with the account the system keeps for that mount. It falls back to the |
| 159 | 160 | mount only when it can't sign in that way. |
| 160 | 161 | |
| 161 | 162 | A file's identity is its root object space, not its server file ID, which |
| 162 | changes when maintenance replaces the file. Polling opens by path each time | |
| 163 | changes when maintenance replaces the file. A check opens by path each time | |
| 163 | 164 | for the same reason. |
| 164 | 165 | |
| 165 | 166 | ## Offline |
| ... | ... | @@ -180,31 +181,48 @@ OneNote keeps every section of an open notebook in sync and on this computer, no |
| 180 | 181 | the one on screen. Watched in the lab with a notebook of 200 sections on Samba, OneNote |
| 181 | 182 | 2010: |
| 182 | 183 | |
| 183 | - reads every section file once when the notebook opens (all 200 within 15 seconds), so a | |
| 184 | section never shown before opens with the share gone; | |
| 184 | - reads every section file once when it first opens the notebook (all 200 within 15 | |
| 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 | 187 | - arms one CHANGE_NOTIFY on the notebook's folder, with WATCH_TREE and a filter of names, |
| 186 | 188 | 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 | 190 | - lists the folder once when the server reports a change, and reads only the section that |
| 189 | 191 | changed, some seconds later; |
| 190 | 192 | - tries a section it could not reach again about every 31 seconds. |
| 191 | 193 | |
| 192 | Snowbound's `session::Background` does the same, with one thread per notebook: | |
| 194 | Snowbound does the same. Opening a notebook lists its folders and reads only the files whose | |
| 195 | listed size or last write time changed since it last read them, keeping what it took from | |
| 196 | each, with the file's stamp, in the cache (`discover::Cache`). On a share, a changed section | |
| 197 | whose replica already holds it as it stands, as after its own edits published, costs just a | |
| 198 | stamp read. `session::Background` then keeps the sections in sync, one thread per notebook: | |
| 193 | 199 | |
| 194 | 200 | ```text |
| 195 | share ─ CHANGE_NOTIFY (tree) ─â–º the sections it names are due in 1 s ─â–º stamp ─â–º moved: rebase | |
| 196 | first pass: one section every 100 ms; a section without a replica gets its offline copy | |
| 197 | failing: again in 31 s otherwise: again in an hour, against a lost notification | |
| 201 | connect: arm the watch, list every folder | |
| 202 | listed as before: compare the known stamp with the replica, locally | |
| 203 | listed otherwise: check, one section every 100 ms | |
| 204 | share ─ 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 | |
| 206 | failing: again in 31 s otherwise: again in an hour, against a lost notification | |
| 198 | 207 | ``` |
| 199 | 208 | |
| 200 | 209 | A check costs one stamp read. Only a section whose replica has edits waiting, or whose file |
| 201 | 210 | moved past the replica's base, has its replica opened for the usual sync steps, and it is |
| 202 | closed again afterwards; the open section is left to its own session. A connection that | |
| 203 | drops takes its watch with it, so the next one checks every section again. A notebook in a | |
| 204 | folder on this computer has no copies and learns of changes from FSEvents or inotify; one on | |
| 205 | a network volume the system mounted, whose server's changes those never report, keeps copies | |
| 206 | and is checked every 15 seconds. Closing a notebook deletes its copies, except any with | |
| 207 | edits still waiting. | |
| 211 | closed again afterwards; a section without one gets its offline copy. The section open on | |
| 212 | screen is left to its session, whose worker no longer polls: the watch wakes it, and once the | |
| 213 | session lets go of the replica the background checks the file at once. A connection that drops | |
| 214 | takes its watch with it, so the next one lists every folder again, and so does working online | |
| 215 | again. | |
| 216 | ||
| 217 | A notebook in a folder on this computer has no copies and learns of changes from FSEvents or | |
| 218 | inotify. One on a network volume the system mounted keeps copies. A Mac's SMB mount reports | |
| 219 | another client's change to FSEvents only for a folder watched in its own right, and names just | |
| 220 | that folder, so every folder of the notebook is watched and a report lists it. A Linux SMB | |
| 221 | mount reports none of another client's changes to inotify, so there each section's stamp is | |
| 222 | read every 15 seconds. On iOS, a folder on the device (Snowbound's own, or On My iPhone) has no | |
| 223 | copies and learns of other apps' writes through file coordination (`NSFilePresenter`); one a | |
| 224 | file provider keeps elsewhere, as iCloud Drive does, keeps copies and is checked every 15 | |
| 225 | seconds. Closing a notebook deletes its copies, except any with edits still waiting. | |
| 208 | 226 | |
| 209 | 227 | ## Notebook structure |
| 210 | 228 |
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 | |
| 4 | mouse from the Draw tab (screenshots of the tab, the pen gallery and Color & Thickness | |
| 5 | were 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 | ||
| 16 | What 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 | |
| 42 | colour, a yellow highlighter, and a rectangle, oval, arrow and line dragged out with the | |
| 43 | shape 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 | |
| 45 | them (`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 | |
| 50 | below. `edited/cold` shows OneNote reading OneNote's own drawings where they were, the | |
| 51 | moved one offset, and Snowbound's beside them. | |
| 52 | ||
| 53 | `tools/test_ink_tools.py` checks all three without a VM. Regenerate the candidates with | |
| 54 | the 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 | |
| 3 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREn7Tq | |
| 4 | A0UjRiMFCAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQAqDASmC/j27+OVQJQEo | |
| 5 | CUBKAIfwuaeF+mIRaPSKUSaUSKRRqIQyAQeGxmNyWSyuSymPx2KwuBwSGRaNSKTSaUSaQR6JAAUU | |
| 6 | RgBAAAAJBAUURgAAAAASABEgx3HwO0nsdkiiPkWOgG81fAoAESCAFrmby4LYAQMVRgBACgARIGia | |
| 7 | 0z8rfHRA | |
| 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 | |
| 9 | BvwEr+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 | |
| 10 | mWpl8vl8rk8ljsbicPhMBgEIh0SjUckkmlUul0umUul0ulUmkkcjUSh0IgEBhMPicbjslk8rl8vl | |
| 11 | 4Ibw5xeG/WDhYuNj5eTm5mbnZ2dnZuZm5OXj42LhYOBhISKjI6WkpqYmpycnJyamJqSlo6MioSEA | |
| 12 | AxVGAEA=</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 | |
| 13 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBRE//8D | |
| 14 | RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQApWFYL+ | |
| 15 | Pbv407JRKSiUgv7NC/s0MAAABRRGAIAAAAkEBRRGAIAAABIAESDYrR159N2MQ7BkvoW/LnuoCgAR | |
| 16 | IACtUZzLgtgBAxVGAEAKABEg4dbRP+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 | |
| 17 | P4CS2AMVRgBAChgDh/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 | |
| 18 | fwH8AAMVRgBA | |
| 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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($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)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-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 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([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' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($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 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 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 | |
| 3 | G8kN4AMVRgBAChgDh/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 | |
| 4 | X0LRAAMVRgBAChcDh/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 | |
| 5 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj | |
| 6 | BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKngE4h+1g7FmATebTWAzGAoBA | |
| 7 | ZnNJvACZzKazSawCAwCAwCAwCazKATWZTOZTOaTVMpnNJqmUzmk1TKZppNSH8NXXhq7mk1mUzmUz | |
| 8 | mk1TKZppNUymZNJqmUzTSagAAAAFFEYAQAAACQQFFEYAAAAAEgARIH7bL5LspZlKvDhZGnSznPUK | |
| 9 | P0Ai5AG7fQcwO6ADFUYAQAo/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 | |
| 12 | FUYAQA==</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 | |
| 13 | FUYAQA==</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 | |
| 14 | FUYAQA==</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 | |
| 15 | 4AMVRgBA | |
| 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 | |
| 17 | FUYAQA==</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 | |
| 18 | JAMVRgBA | |
| 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 | |
| 20 | BAMVRgBA | |
| 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 | |
| 22 | JAMVRgBA | |
| 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 | |
| 24 | uogDFUYAQA==</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 | |
| 3 | URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBACEQZIEEUj | |
| 4 | RiMJSBBE/wFFI0YjBQgLZU9mZ2hkaRkUMggAgB4CIeLiQTMIAPAQAovF4kFkAxVGAEAKXAWH8gln | |
| 5 | jCj7wnvCe8IAh/B8h4Pke8I7wnvCQAUURgBAAAAJBAUURgAAAAASABEg7Ks5v90DzE+YKNcBjx6b | |
| 6 | FwoAESCA5B1tFkPdAQMVRgBACj9AIm9GCC70WAqACQEKTAIPUq7GcA5CJYzgBRRGAIAAAAkEBRRG | |
| 7 | AAAAABIAESBSpMe3m/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 | |
| 3 | JX8BKwCH7GLsY+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 | |
| 5 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj | |
| 6 | BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKkAFBh+k+5y+YwGazGApnAZjA | |
| 7 | UzE1QCAzOZTMmcygMxmU1gEBgEBgEBgE1mSZzSazKZzSaplM5pNUymaaTWZTNNJqgv484/jzkAAA | |
| 8 | AAAAAAAABRRGAEAAAAkEBRRGAAAAABIAESBoVDskhZV+SLo2P5B9KRgDCgARIAD8ywlyUN0BAxVG | |
| 9 | AEAKABEgV/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 | |
| 10 | FUYAQA==</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 | |
| 11 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj | |
| 12 | BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKkAFBh+k+5y+YwGazGApnAZjA | |
| 13 | UzE1QCAzOZTMmcygMxmU1gEBgEBgEBgE1mSZzSazKZzSaplM5pNUymaaTWZTNNJqgv6Pk/o+UAAA | |
| 14 | AAAAAAAABRRGAMAAAAkEBRRGAAAAABIAESCTBvW9+tcASYS183z19nlfCgARIKCijAxyUN0BAxVG | |
| 15 | AEAKABEgV/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 | |
| 3 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj | |
| 4 | BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKggEyh+k+5R202e1zOazOzwGa | |
| 5 | zGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv5Vs/lW0AAAAAAAAAUURgBAAAAJ | |
| 6 | BAUURgAAAAASABEg5wN6/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 | |
| 9 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBRE7bmQ | |
| 10 | AUUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5 | |
| 11 | rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/mST+ZJQAAAAAAAABRRG | |
| 12 | AIAAAAkEBRRGAAAAABIAESBGQZxts4QQTbV43z4KBKVpCgARIHD5K9hxUN0BAxVGAEAKABEgV/iI | |
| 13 | P1V3KEA=</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 | |
| 14 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREscrt | |
| 15 | BUUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5 | |
| 16 | rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/nNz+c3QAAAAAAAABRRG | |
| 17 | AMAAAAkEBRRGAAAAABIAESAY7KQuf+viTJV1jzfsq3ctCgARIPA2NdpxUN0BAxVGAEAKABEgV/iI | |
| 18 | Pxt0QEA=</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 | |
| 19 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREl/rZ | |
| 20 | AUUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5 | |
| 21 | rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/oJb+glwAAAAAAAABRRG | |
| 22 | AQAAAAkEBRRGAAAAABIAESAm+zoEY2rUTJ5/d6gaBVuMCgARIICbPtxxUN0BAxVGAEAKABEgV/iI | |
| 23 | P8h9WEA=</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 | |
| 24 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREgIGC | |
| 25 | BEUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5 | |
| 26 | rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/pE7+kTwAAAAAAAABRRG | |
| 27 | AUAAAAkEBRRGAAAAABIAESCnZ/4+38wKT4pCwgl9y8fMCgARIABoRd5xUN0BAxVGAEAKABEgV/iI | |
| 28 | P496cEA=</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 | |
| 29 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBRE//8D | |
| 30 | RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH | |
| 31 | 6T7lHbTZ7XM5rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/qAb+oBw | |
| 32 | AAAAAAAABRRGAYAAAAkEBRRGAIAAABIAESDuqWuD5PkcQbZoQPucPr1dCgARIJBbTOBxUN0BAxVG | |
| 33 | AEAKP0Ain6X8O+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 | |
| 34 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMVSBREgP7/ | |
| 35 | B0VGRwFGkANXCQAAAFB/BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKggEy | |
| 36 | h+k+5R202e1zOazOzwGazGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv6u+/q7 | |
| 37 | 8AAAAAAAAAUURgHAAAAJBAUURgCAAAASABEgULb3v276vU+yZJBxbMv26goAESAgT1PicVDdAQMV | |
| 38 | RgBACgARIOknhz+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 | |
| 40 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFMkYy | |
| 41 | BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKggEyh+k+5R202e1zOazOzwGa | |
| 42 | zGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv694/r3kAAAAAAAAAUURgIAAAAJ | |
| 43 | BAUURgAAAAASABEg598ld/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 | |
| 46 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBRE7bmQ | |
| 47 | AUUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5 | |
| 48 | rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/szD+zMQAAAAAAAABRRG | |
| 49 | AkAAAAkEBRRGAAAAABIAESA7WYhg62teTJWERTyI4+4BCgARIAAidOZxUN0BAxVGAEAKP0Aip7X8 | |
| 50 | QJrYFQA=</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 | |
| 51 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREscrt | |
| 52 | BUUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5 | |
| 53 | rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/tuj+26QAAAAAAAABRRG | |
| 54 | AoAAAAkEBRRGAAAAABIAESCzAtv9BGjgRKJG+TDcSpijCgARIICyhOhxUN0BAxVGAEAKP0Aip7X8 | |
| 55 | QIEIFoA=</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 | |
| 56 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREl/rZ | |
| 57 | AUUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5 | |
| 58 | rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/uqD+6oQAAAAAAAABRRG | |
| 59 | AsAAAAkEBRRGAAAAABIAESAU7/fpyXd0SJkgxjH5MyTjCgARIOBll+pxUN0BAxVGAEAKP0Aip7X8 | |
| 60 | QGc4GAA=</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 | |
| 61 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREgIGC | |
| 62 | BEUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5 | |
| 63 | rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/vlr++WwAAAAAAAABRRG | |
| 64 | AwAAAAkEBRRGAAAAABIAESAMVgQMFYRlS7E8QjeztIbhCgARIFCxrOxxUN0BAxVGAEAKP0Aip7X8 | |
| 65 | QLSoGYA=</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 | |
| 66 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBREgP4D | |
| 67 | RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqEATKH | |
| 68 | 6T7lHbTZ7XM5rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/wAEJf4A | |
| 69 | CEwAAAAAAAAFFEYDQAAACQQFFEYAgAAAEgARIJkREIJaMrZPpp8CdhQXBbYKABEgwBq97nFQ3QED | |
| 70 | FUYAQAoAESDpJ4c/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 | |
| 71 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMVSBRE/4H8 | |
| 72 | B0VGRwFGkANXCQAAAFB/BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKhAEy | |
| 73 | h+k+5R202e1zOazOzwGazGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv8AC5X+ | |
| 74 | ABcsAAAAAAAABRRGA4AAAAkEBRRGAIAAABIAESCoZ3Z0oKXFSKIRRxukb8jmCgARIEA6y/BxUN0B | |
| 75 | AxVGAEAKABEg6SeHPz6f30A=</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 | |
| 3 | fwErAIfwMneBk/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 | |
| 4 | FUYAQA==</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 | |
| 5 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj | |
| 6 | BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKnQFQh+k+580mZAZjAUzEBmMy | |
| 7 | mqATWZTOZTMJrNJnMprNJnAUAgMAgMAgMzmkBgE1mUzmkBmaZTOaTWZTNNJqmUzmk1TKZzSaplM5 | |
| 8 | pNSC/lxT+XFQAAAAAAAAAAAAAAUURgBAAAAJBAUURgAAAAASABEg43IZXxePZEqSIvXVtQWmUwo/ | |
| 9 | QCMjAkbdBxA7oAMVRgBACgARIFf4iD+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 | |
| 11 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj | |
| 12 | BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKlgFKgv6q2/qrcAAAAAAAAAAA | |
| 13 | AIfwFFeAeuZJrNJqmc0gMzAgMxmU1mkzgMAgMAgKAQGAQGZzSAzOaQGZzSazKZplM5pNUymZNJqm | |
| 14 | UzJpNUymaaTUmUzABRRGAIAAAAkEBRRGAAAAABIAESCdJ4KnPMDWT4AyoS4EsfmDCj9AIkfCUi0H | |
| 15 | EDugAxVGAEAKABEgmuaMQBt0AEA=</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 | |
| 16 | FUYAQAoYA4fyMAeRDfjRQIfxDYeH0/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 | |
| 18 | fkcbjcTh8Lg8DgUGhUOiUajUij0ik0mk0mk0ij0ijUaiUOhUGgUDg8Lh8Tjcbkcfkcnk4IX4fTPh | |
| 19 | 6tjjnppvtvweBweDweDwODvtvppnjjRRTUUXWXYLAYLBYLBYDBXWXUUTRRADFUYAQA==</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 | |
| 20 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBRE//8D | |
| 21 | RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqrAVaH | |
| 22 | 6T7nL5jAU1mMwmM3mkzmcwmMBmcymaAzGZTVAIDM5lM00mqazRNZpM5lNUAgM0mc1mUAgMAgMzmk | |
| 23 | Bmcymc0msymc0mqZTNNJrMpmmk1mUzTSazKZplMwgv646/rjsAAAAAAAAAAAAAAFFEYBAAAACQQF | |
| 24 | FEYAgAAAEgARIIxv3sLIAtJLsNVUUmVe0nIKABEgwIKVWXFQ3QEDFUYAQAoAESDpJ4c/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 | |
| 25 | v+xxzMwisUGJDrsTLO56oVjPVIrml8VPjwb4utLhmyLEZm2nDbJYSZodTkA0BmwQAwZIFEUjRiMF | |
| 26 | CAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqyATeH8RSniIrmcyJrNJrAIDNJ | |
| 27 | nAYBAYBAUzmkBmc0gMzmk1mUzmk1mUzTSazKZppNZlM0ymc0mqZTNNJqh/GDd4wOYDNJrAIDNJnA | |
| 28 | ZnNIDAIDAJrMkzmk1mUzmk1mUzTSaplM00mpMpmmk1JlM00mpMpmBRRGAUAAAAkEBRRGAAAAABIA | |
| 29 | ESAy8nFzf6jGRYlYDFxmSkMdCj9AI42C380HEDugAxVGAEAKABEgHo+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 | |
| 30 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREn7Tq | |
| 31 | A0UjRiMFCAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQAqDASmC/j27+OVQJQEo | |
| 32 | CUBKAIfycqeTwmIRaPSKUSaUSKRRqIQyAQeGxmNyWSyuSymPx2KwuBwSGRaNSKTSaUSaQR6JAAUU | |
| 33 | RgHAAAAJBAUURgAAAAASABEgqcqAxLBes0uG2h8ws7R+rwoAESCAHvbDd1DdAQMVRgBACgARIGia | |
| 34 | 0z8IBBZB | |
| 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 | |
| 36 | BvwEr+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 | |
| 37 | mWpl8vl8rk8ljsbicPhMBgEIh0SjUckkmlUul0umUul0ulUmkkcjUSh0IgEBhMPicbjslk8rl8vl | |
| 38 | 4IbyoBeUxWDhYuNj5eTm5mbnZ2dnZuZm5OXj42LhYOBhISKjI6WkpqYmpycnJyamJqSlo6MioSEA | |
| 39 | AxVGAEA=</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 | |
| 40 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBRE//8D | |
| 41 | RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQApMAg4h | |
| 42 | FeUQD2t+1vwFFEYBgAAACQQFFEYAgAAAEgARIKX0Fhytz+FHincjDAF4OcwKABEgAIhdw3dQ3QED | |
| 43 | FUYAQAoAESDh1tE/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 | |
| 44 | O1BARgMVRgBAChgDh/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 | |
| 45 | dqDtQAMVRgBA | |
| 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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($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)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-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 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([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' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($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 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 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 | |
| 3 | G8kN4AMVRgBAChgDh/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 | |
| 4 | X0LRAAMVRgBAChcDh/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 | |
| 5 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj | |
| 6 | BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKngE4h+1g7FmATebTWAzGAoBA | |
| 7 | ZnNJvACZzKazSawCAwCAwCAwCazKATWZTOZTOaTVMpnNJqmUzmk1TKZppNSH8NXXhq7mk1mUzmUz | |
| 8 | mk1TKZppNUymZNJqmUzTSagAAAAFFEYAQAAACQQFFEYAAAAAEgARIH7bL5LspZlKvDhZGnSznPUK | |
| 9 | P0Ai5AG7fQcwO6ADFUYAQAo/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 | |
| 12 | FUYAQA==</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 | |
| 13 | FUYAQA==</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 | |
| 14 | FUYAQA==</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 | |
| 15 | 4AMVRgBA | |
| 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 | |
| 17 | FUYAQA==</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 | |
| 18 | JAMVRgBA | |
| 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 | |
| 20 | BAMVRgBA | |
| 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 | |
| 22 | JAMVRgBA | |
| 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 | |
| 24 | uogDFUYAQA==</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 | |
| 3 | JX8BKwCH7GLsY+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 | |
| 5 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj | |
| 6 | BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKkAFBh+k+5y+YwGazGApnAZjA | |
| 7 | UzE1QCAzOZTMmcygMxmU1gEBgEBgEBgE1mSZzSazKZzSaplM5pNUymaaTWZTNNJqgv484/jzkAAA | |
| 8 | AAAAAAAABRRGAEAAAAkEBRRGAAAAABIAESBoVDskhZV+SLo2P5B9KRgDCgARIAD8ywlyUN0BAxVG | |
| 9 | AEAKABEgV/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 | |
| 10 | FUYAQA==</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 | |
| 11 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj | |
| 12 | BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKkAFBh+k+5y+YwGazGApnAZjA | |
| 13 | UzE1QCAzOZTMmcygMxmU1gEBgEBgEBgE1mSZzSazKZzSaplM5pNUymaaTWZTNNJqgv6Pk/o+UAAA | |
| 14 | AAAAAAAABRRGAMAAAAkEBRRGAAAAABIAESCTBvW9+tcASYS183z19nlfCgARIKCijAxyUN0BAxVG | |
| 15 | AEAKABEgV/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 | |
| 3 | URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBACEQZIEEUj | |
| 4 | RiMJSBBE/wFFI0YjBQgLZU9mZ2hkaRkUMggAgB4CIeLiQTMIAPAQAovF4kFkAxVGAEAKXAWH8gln | |
| 5 | jCj7wnvCe8IAh/B8h4Pke8I7wnvCQAUURgBAAAAJBAUURgAAAAASABEg7Ks5v90DzE+YKNcBjx6b | |
| 6 | FwoAESCA5B1tFkPdAQMVRgBACj9AIm9GCC70WAqACQEKTAIPUq7GcA5CJYzgBRRGAIAAAAkEBRRG | |
| 7 | AAAAABIAESBSpMe3m/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 | |
| 3 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj | |
| 4 | BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKggEyh+k+5R202e1zOazOzwGa | |
| 5 | zGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv5Vs/lW0AAAAAAAAAUURgBAAAAJ | |
| 6 | BAUURgAAAAASABEg5wN6/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 | |
| 9 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBRE7bmQ | |
| 10 | AUUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5 | |
| 11 | rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/mST+ZJQAAAAAAAABRRG | |
| 12 | AIAAAAkEBRRGAAAAABIAESBGQZxts4QQTbV43z4KBKVpCgARIHD5K9hxUN0BAxVGAEAKABEgV/iI | |
| 13 | P1V3KEA=</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 | |
| 14 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREscrt | |
| 15 | BUUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5 | |
| 16 | rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/nNz+c3QAAAAAAAABRRG | |
| 17 | AMAAAAkEBRRGAAAAABIAESAY7KQuf+viTJV1jzfsq3ctCgARIPA2NdpxUN0BAxVGAEAKABEgV/iI | |
| 18 | Pxt0QEA=</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 | |
| 19 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREl/rZ | |
| 20 | AUUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5 | |
| 21 | rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/oJb+glwAAAAAAAABRRG | |
| 22 | AQAAAAkEBRRGAAAAABIAESAm+zoEY2rUTJ5/d6gaBVuMCgARIICbPtxxUN0BAxVGAEAKABEgV/iI | |
| 23 | P8h9WEA=</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 | |
| 24 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREgIGC | |
| 25 | BEUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5 | |
| 26 | rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/pE7+kTwAAAAAAAABRRG | |
| 27 | AUAAAAkEBRRGAAAAABIAESCnZ/4+38wKT4pCwgl9y8fMCgARIABoRd5xUN0BAxVGAEAKABEgV/iI | |
| 28 | P496cEA=</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 | |
| 29 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBRE//8D | |
| 30 | RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH | |
| 31 | 6T7lHbTZ7XM5rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/qAb+oBw | |
| 32 | AAAAAAAABRRGAYAAAAkEBRRGAIAAABIAESDuqWuD5PkcQbZoQPucPr1dCgARIJBbTOBxUN0BAxVG | |
| 33 | AEAKP0Ain6X8O+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 | |
| 34 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMVSBREgP7/ | |
| 35 | B0VGRwFGkANXCQAAAFB/BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKggEy | |
| 36 | h+k+5R202e1zOazOzwGazGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv6u+/q7 | |
| 37 | 8AAAAAAAAAUURgHAAAAJBAUURgCAAAASABEgULb3v276vU+yZJBxbMv26goAESAgT1PicVDdAQMV | |
| 38 | RgBACgARIOknhz+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 | |
| 40 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFMkYy | |
| 41 | BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKggEyh+k+5R202e1zOazOzwGa | |
| 42 | zGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv694/r3kAAAAAAAAAUURgIAAAAJ | |
| 43 | BAUURgAAAAASABEg598ld/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 | |
| 46 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBRE7bmQ | |
| 47 | AUUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5 | |
| 48 | rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/szD+zMQAAAAAAAABRRG | |
| 49 | AkAAAAkEBRRGAAAAABIAESA7WYhg62teTJWERTyI4+4BCgARIAAidOZxUN0BAxVGAEAKP0Aip7X8 | |
| 50 | QJrYFQA=</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 | |
| 51 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREscrt | |
| 52 | BUUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5 | |
| 53 | rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/tuj+26QAAAAAAAABRRG | |
| 54 | AoAAAAkEBRRGAAAAABIAESCzAtv9BGjgRKJG+TDcSpijCgARIICyhOhxUN0BAxVGAEAKP0Aip7X8 | |
| 55 | QIEIFoA=</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 | |
| 56 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREl/rZ | |
| 57 | AUUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5 | |
| 58 | rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/uqD+6oQAAAAAAAABRRG | |
| 59 | AsAAAAkEBRRGAAAAABIAESAU7/fpyXd0SJkgxjH5MyTjCgARIOBll+pxUN0BAxVGAEAKP0Aip7X8 | |
| 60 | QGc4GAA=</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 | |
| 61 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREgIGC | |
| 62 | BEUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5 | |
| 63 | rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/vlr++WwAAAAAAAABRRG | |
| 64 | AwAAAAkEBRRGAAAAABIAESAMVgQMFYRlS7E8QjeztIbhCgARIFCxrOxxUN0BAxVGAEAKP0Aip7X8 | |
| 65 | QLSoGYA=</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 | |
| 66 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBREgP4D | |
| 67 | RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqEATKH | |
| 68 | 6T7lHbTZ7XM5rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/wAEJf4A | |
| 69 | CEwAAAAAAAAFFEYDQAAACQQFFEYAgAAAEgARIJkREIJaMrZPpp8CdhQXBbYKABEgwBq97nFQ3QED | |
| 70 | FUYAQAoAESDpJ4c/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 | |
| 71 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMVSBRE/4H8 | |
| 72 | B0VGRwFGkANXCQAAAFB/BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKhAEy | |
| 73 | h+k+5R202e1zOazOzwGazGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv8AC5X+ | |
| 74 | ABcsAAAAAAAABRRGA4AAAAkEBRRGAIAAABIAESCoZ3Z0oKXFSKIRRxukb8jmCgARIEA6y/BxUN0B | |
| 75 | AxVGAEAKABEg6SeHPz6f30A=</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 | |
| 3 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj | |
| 4 | BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKlgFKgv6q2/qrcAAAAAAAAAAA | |
| 5 | AIfwFFeAeuZJrNJqmc0gMzAgMxmU1mkzgMAgMAgKAQGAQGZzSAzOaQGZzSazKZplM5pNUymZNJqm | |
| 6 | UzJpNUymaaTUmUzABRRGAIAAAAkEBRRGAAAAABIAESCdJ4KnPMDWT4AyoS4EsfmDCj9AIkfCUi0H | |
| 7 | EDugAxVGAEAKABEgmuaMQBt0AEA=</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 | |
| 8 | fwErAIfwMneBk/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 | |
| 9 | FUYAQA==</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 | |
| 10 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj | |
| 11 | BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKnQFQh+k+580mZAZjAUzEBmMy | |
| 12 | mqATWZTOZTMJrNJnMprNJnAUAgMAgMAgMzmkBgE1mUzmkBmaZTOaTWZTNNJqmUzmk1TKZzSaplM5 | |
| 13 | pNSC/lxT+XFQAAAAAAAAAAAAAAUURgBAAAAJBAUURgAAAAASABEg43IZXxePZEqSIvXVtQWmUwo/ | |
| 14 | QCMjAkbdBxA7oAMVRgBACgARIFf4iD+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 | |
| 16 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBRE7bmQ | |
| 17 | AUUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQArLA9UBh+k+6QuATKaw | |
| 18 | CAzSawCazKAQGAQGZzSAzOaTWZTOZTOaTVMpmmk1mUzTSazKZppNZlM00moJkmkzgMAmU1gE1mUA | |
| 19 | gKATWZQCazKZzSazKZzSaplM00msymaZTOaTVMpnNJqmUzBNIDNJnAYBMJjAZnNIDAIDAJrMoBNZ | |
| 20 | lM5lM5pNZlM00mqZTOaTVMpnNJqmUzmk1TKZgmkBmk1gEBmkzgMzmkBQCAzOaQGZzSazKZzSazKZ | |
| 21 | ppNUymc0mqZTOZTNNJrMpmmk1ACH8GpXg21mk1mkBgE1mUAmU1gEBgEBmkzTKazSZzKazSZzJNZo | |
| 22 | mcyJrNEzmSazQmcyTOZCZJpM4DAJlNYBNZkgEBgE1mUAmsymc0msymc0mqZTNNJqTKZplM00mpMp | |
| 23 | mmk1Jlh2aTWaQGATWZQCZTWAQGAQGaTNMprNJnMprNJnMk1miZzIms0TOZJrNCZzJM5kJkmkzgMA | |
| 24 | mU1gE1mSAQGATWZQCazKZzSazKZzSaplM00mpMpmmUzTSakymaaTUmQFFEYAwAAACQQFFEYAAAAA | |
| 25 | EgARICQJs8oEpxxMtZFlXTzuPzUKABEgQNoRVnFQ3QEDFUYAQAo/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 | |
| 26 | FUYAQAoYA4fyMAeRDfjRQIfxDYeH0/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 | |
| 28 | fkcbjcTh8Lg8DgUGhUOiUajUij0ik0mk0mk0ij0ijUaiUOhUGgUDg8Lh8Tjcbkcfkcnk4IX4fTPh | |
| 29 | 6tjjnppvtvweBweDweDwODvtvppnjjRRTUUXWXYLAYLBYLBYDBXWXUUTRRADFUYAQA==</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 | |
| 30 | Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBRE//8D | |
| 31 | RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqrAVaH | |
| 32 | 6T7nL5jAU1mMwmM3mkzmcwmMBmcymaAzGZTVAIDM5lM00mqazRNZpM5lNUAgM0mc1mUAgMAgMzmk | |
| 33 | Bmcymc0msymc0mqZTNNJrMpmmk1mUzTSazKZplMwgv646/rjsAAAAAAAAAAAAAAFFEYBAAAACQQF | |
| 34 | FEYAgAAAEgARIIxv3sLIAtJLsNVUUmVe0nIKABEgwIKVWXFQ3QEDFUYAQAoAESDpJ4c/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 | |
| 35 | v+xxzMwisUGJDrsTLO56oVjPVIrml8VPjwb4utLhmyLEZm2nDbJYSZodTkA0BmwQAwZIFEUjRiMF | |
| 36 | CAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqyATeH8RSniIrmcyJrNJrAIDNJ | |
| 37 | nAYBAYBAUzmkBmc0gMzmk1mUzmk1mUzTSazKZppNZlM0ymc0mqZTNNJqh/GDd4wOYDNJrAIDNJnA | |
| 38 | ZnNIDAIDAJrMkzmk1mUzmk1mUzTSaplM00mpMpmmk1JlM00mpMpmBRRGAUAAAAkEBRRGAAAAABIA | |
| 39 | ESAy8nFzf6jGRYlYDFxmSkMdCj9AI42C380HEDugAxVGAEAKABEgHo+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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($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)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-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 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([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' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($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 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 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 | 8 | annotated a paragraph with the moment 500 ms. The recording attachment |
| 9 | 9 | (`page::Attachment::recording`) and the annotation |
| 10 | 10 | (`PageParagraph::media`) are read from the page and kept through the edit; |
| 11 | a model that adds an annotation or drops the recording refuses to write. | |
| 11 | a model that drops the recording refuses to write. `corpus/recording` covers | |
| 12 | writing recordings and links. | |
| 12 | 13 | |
| 13 | 14 | `cold/` is a fresh OneNote 2010 read: `MediaFile`, `MediaReference` and |
| 14 | 15 | `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 | ||
| 5 | Observed in a lab clone on 2026-09-29, with a QEMU HD Audio device on a null | |
| 6 | audio backend (it records silence; the clone has no camera, so Record Video | |
| 7 | was 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 | |
| 44 | recording (`deleted/`) or attached a .wav (`attached/`). | |
| 45 | ||
| 46 | `candidate/` is `a_recording_stores_its_file_line_and_linked_notes_for_onenote` | |
| 47 | in `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 | |
| 49 | two 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, | |
| 51 | the line and both notes linked to their moments. Opened in a lab clone, the | |
| 52 | note'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. | |
| 54 | Regenerate with `SNOWBOUND_RECORDING_EXPORT`, then `tools/native_runner.py DIR | |
| 55 | COLD --expected-pages 1 --collect-notebook --screenshots`. | |
| 56 | ||
| 57 | `edit/` is `onenote_s_recording_round_trips_through_deleting_restoring_and_linking` | |
| 58 | in `crates/onestore/tests/page_media.rs`: OneNote's recording deleted, put back | |
| 59 | as undo does, and a paragraph linked at 25 s, each as its own edit. `edit/cold` | |
| 60 | is a fresh OneNote 2010 read: the same playlist, links, WMA bytes and the new | |
| 61 | link. 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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($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)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/recording/cold/scripts/read.ps1 created+144| ... | ... | @@ -0,0 +1,144 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([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' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($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 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 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 @@ |
| 1 | Rust 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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($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)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-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 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([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' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($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 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 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 | 28 | #[cfg(test)] |
| 29 | 29 | mod evidence; |
| 30 | 30 | mod format; |
| 31 | mod ink; | |
| 31 | 32 | mod link; |
| 32 | 33 | mod ops; |
| 33 | 34 | pub(crate) mod page; |
| 35 | mod recording; | |
| 34 | 36 | mod space; |
| 35 | 37 | mod table; |
| 36 | 38 | pub use format::{ |
| 37 | 39 | Alignment, BULLET_LIBRARY, FormatState, Formatting, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle, |
| 38 | 40 | }; |
| 41 | pub use ink::{FAVORITES, Pen}; | |
| 39 | 42 | pub use link::{Link, shown_urls}; |
| 40 | 43 | pub use page::ReadOnlyObject; |
| 41 | 44 | |
| ... | ... | @@ -120,6 +123,9 @@ pub struct CanvasEditor { |
| 120 | 123 | /// The outline, caret and undo depth a run of typing left, which typing there joins as |
| 121 | 124 | /// OneNote 2010 does until the caret moves or another kind of edit comes between. |
| 122 | 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 | } |
| 124 | 130 | |
| 125 | 131 | /// Imported page state the editable content does not carry. |
| ... | ... | @@ -813,6 +819,17 @@ enum History { |
| 813 | 819 | index: usize, |
| 814 | 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 | 833 | Remove { |
| 817 | 834 | outline: onestore::ExGuid, |
| 818 | 835 | focus: RestoreFocus, |
| ... | ... | @@ -870,6 +887,8 @@ impl CanvasEditor { |
| 870 | 887 | stored: None, |
| 871 | 888 | whole: None, |
| 872 | 889 | typing: None, |
| 890 | recording: None, | |
| 891 | ink_drag: None, | |
| 873 | 892 | }) |
| 874 | 893 | } |
| 875 | 894 | |
| ... | ... | @@ -1058,9 +1077,11 @@ impl CanvasEditor { |
| 1058 | 1077 | let reaches_one = |history: &History| match history { |
| 1059 | 1078 | History::Date(_) => date_changed, |
| 1060 | 1079 | History::Paper { .. } => false, |
| 1061 | History::Image { .. } | History::Picture { .. } | History::File { .. } => { | |
| 1062 | objects_changed | |
| 1063 | } | |
| 1080 | History::Image { .. } | |
| 1081 | | History::Picture { .. } | |
| 1082 | | History::File { .. } | |
| 1083 | | History::Ink { .. } | |
| 1084 | | History::Strokes { .. } => objects_changed, | |
| 1064 | 1085 | History::Draft { outlines, .. } => changed.contains(&outlines[0].id), |
| 1065 | 1086 | History::Text { outline, change } => { |
| 1066 | 1087 | changed.contains(outline) |
| ... | ... | @@ -1446,6 +1467,8 @@ impl CanvasEditor { |
| 1446 | 1467 | stored: None, |
| 1447 | 1468 | whole: None, |
| 1448 | 1469 | typing: None, |
| 1470 | recording: None, | |
| 1471 | ink_drag: None, | |
| 1449 | 1472 | }) |
| 1450 | 1473 | } |
| 1451 | 1474 | |
| ... | ... | @@ -3683,6 +3706,14 @@ impl CanvasEditor { |
| 3683 | 3706 | .get(*index) |
| 3684 | 3707 | .and_then(page::Content::file) |
| 3685 | 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 | 3717 | History::Remove { outline, focus } => { |
| 3687 | 3718 | self.outlines.iter().any(|item| item.id == *outline) |
| 3688 | 3719 | && match focus { |
| ... | ... | @@ -3917,6 +3948,17 @@ impl CanvasEditor { |
| 3917 | 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 | 3962 | History::Remove { outline, focus } => { |
| 3921 | 3963 | let index = self |
| 3922 | 3964 | .outlines |
| ... | ... | @@ -4065,6 +4107,7 @@ impl CanvasEditor { |
| 4065 | 4107 | .document |
| 4066 | 4108 | .replace(range.clone(), replacement)?; |
| 4067 | 4109 | self.own_lists(&mut edit)?; |
| 4110 | self.link_notes(&mut edit)?; | |
| 4068 | 4111 | let inverse = self.apply( |
| 4069 | 4112 | engine, |
| 4070 | 4113 | edit, |
| ... | ... | @@ -4163,6 +4206,7 @@ impl CanvasEditor { |
| 4163 | 4206 | selection: Selection, |
| 4164 | 4207 | ) -> Result<(), EditorError> { |
| 4165 | 4208 | self.own_lists(&mut edit)?; |
| 4209 | self.link_notes(&mut edit)?; | |
| 4166 | 4210 | let inverse = self.apply(engine, edit, selection, true, None)?; |
| 4167 | 4211 | self.record_change(inverse); |
| 4168 | 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 | ||
| 5 | use super::{CanvasEditor, EditError, EditorError, History, Placement, page}; | |
| 6 | use 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)] | |
| 14 | pub 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. | |
| 23 | const HIGHLIGHTER_HEIGHT: f32 = 400.0; | |
| 24 | ||
| 25 | impl 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. | |
| 63 | pub 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 | ||
| 86 | impl 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 | ||
| 394 | fn 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 | ||
| 398 | fn 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). | |
| 407 | fn 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`. | |
| 427 | fn 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 | ||
| 6 | use super::*; | |
| 7 | use onestore::page::{Attachment, MediaIndex}; | |
| 8 | use std::time::Instant; | |
| 9 | ||
| 10 | /// A recording in progress. | |
| 11 | pub(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. | |
| 19 | fn 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 | ||
| 43 | impl 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%. | |
| 217 | const BUTTON: [f32; 2] = [10.5, 24.0]; | |
| 218 | ||
| 219 | impl 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 | 96 | Ok(origin) |
| 97 | 97 | } |
| 98 | 98 | |
| 99 | fn 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. | |
| 102 | pub(crate) fn append_ink( | |
| 103 | ink: &onestore::page::Ink, | |
| 104 | offset: [f32; 2], | |
| 105 | automatic: [f32; 4], | |
| 106 | primitives: &mut Vec<Primitive<'_>>, | |
| 107 | ) { | |
| 100 | 108 | for stroke in &ink.strokes { |
| 101 | let mut color = colorref(stroke.color.unwrap_or(0)); | |
| 102 | color[3] = 1.0 - f32::from(stroke.transparency.unwrap_or(0)) / 255.0; | |
| 103 | let width = stroke.width.max(stroke.height); | |
| 109 | let highlighter = stroke.raster_operation == Some(9); | |
| 110 | let mut color = stroke.color.map_or(automatic, colorref); | |
| 111 | if !highlighter { | |
| 112 | color[3] = 1.0 - f32::from(stroke.transparency.unwrap_or(0)) / 255.0; | |
| 113 | } | |
| 104 | 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 | 139 | let mut points = stroke |
| 106 | 140 | .points |
| 107 | 141 | .iter() |
| ... | ... | @@ -109,37 +143,35 @@ fn append_ink(ink: &onestore::page::Ink, offset: [f32; 2], primitives: &mut Vec< |
| 109 | 143 | let Some(mut from) = points.next() else { |
| 110 | 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 | 149 | for to in points { |
| 114 | // Pen samples far closer than a pixel add vertices without changing the stroke. | |
| 115 | if (to[0] - from[0]).hypot(to[1] - from[1]) < 0.2 { | |
| 116 | continue; | |
| 150 | if !between(from, end, to) { | |
| 151 | primitives.push(segment(from, end)); | |
| 152 | from = end; | |
| 117 | 153 | } |
| 118 | primitives.push(Primitive::Segment { | |
| 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 | }); | |
| 154 | end = to; | |
| 136 | 155 | } |
| 156 | primitives.push(segment(from, end)); | |
| 137 | 157 | } |
| 138 | 158 | for group in &ink.groups { |
| 139 | append_ink(group, offset, primitives); | |
| 159 | append_ink(group, offset, automatic, primitives); | |
| 140 | 160 | } |
| 141 | 161 | } |
| 142 | 162 | |
| 163 | /// Whether `middle` lies within a tenth of a point of the segment from `from` to `to`. | |
| 164 | fn 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 | ||
| 143 | 175 | const FILE_ICON: &str = include_str!("../../assets/file.svg"); |
| 144 | 176 | |
| 145 | 177 | /// 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 | 533 | primitives.push(Primitive::Image { image, rect }); |
| 502 | 534 | } |
| 503 | 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 | 539 | crate::outline::ObjectKind::Unsupported(_) => { |
| 506 | 540 | append_placeholder(rect, paper, primitives) |
| 507 | 541 | } |
| ... | ... | @@ -801,7 +835,9 @@ impl PageScene { |
| 801 | 835 | } |
| 802 | 836 | Content::Ink(ink) => { |
| 803 | 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 | 841 | continue; |
| 806 | 842 | } |
| 807 | 843 | Content::File { source, layout } => { |
| ... | ... | @@ -2217,6 +2253,7 @@ mod tests { |
| 2217 | 2253 | color, |
| 2218 | 2254 | transparency: Some(51), |
| 2219 | 2255 | pen_tip: None, |
| 2256 | raster_operation: None, | |
| 2220 | 2257 | }; |
| 2221 | 2258 | let ink = Ink { |
| 2222 | 2259 | id: ExGuid::default(), |
| ... | ... | @@ -2228,7 +2265,9 @@ mod tests { |
| 2228 | 2265 | layout: Layout::default(), |
| 2229 | 2266 | strokes: vec![stroke(vec![[40.0, 30.0]], Some(0x0000ff))], |
| 2230 | 2267 | groups: Vec::new(), |
| 2268 | shape: None, | |
| 2231 | 2269 | }], |
| 2270 | shape: None, | |
| 2232 | 2271 | }; |
| 2233 | 2272 | let page = Page { |
| 2234 | 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 | ||
| 5 | use super::{PageView, Response, Result, snap_to_grid}; | |
| 6 | use crate::editor::Pen; | |
| 7 | use crate::gpu::colorref; | |
| 8 | use draw::{ | |
| 9 | Primitive, Stroke, | |
| 10 | edit::{Key, NamedKey}, | |
| 11 | }; | |
| 12 | use 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)] | |
| 19 | pub 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)] | |
| 33 | pub(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 | ||
| 40 | enum 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. | |
| 49 | const ERASER_REACH: f32 = 4.0; | |
| 50 | ||
| 51 | impl 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 | ||
| 318 | fn 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 | 3 | //! translate their platform's events into these calls and carry out the returned requests. |
| 4 | 4 | |
| 5 | 5 | pub mod accessibility; |
| 6 | pub mod ink; | |
| 6 | 7 | #[cfg(test)] |
| 7 | 8 | mod profile; |
| 8 | 9 | mod scroll; |
| ... | ... | @@ -47,6 +48,8 @@ pub enum Cursor { |
| 47 | 48 | RowResize, |
| 48 | 49 | /// Insert Space's line down the page. |
| 49 | 50 | ColResize, |
| 51 | /// A drawing tool's. | |
| 52 | Crosshair, | |
| 50 | 53 | } |
| 51 | 54 | |
| 52 | 55 | /// Work only the host can do, asked for by an event. |
| ... | ... | @@ -62,6 +65,11 @@ pub enum Request { |
| 62 | 65 | OpenLink(String), |
| 63 | 66 | /// Open a copy of a file with the system's application for it, as a double click does. |
| 64 | 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 | } |
| 66 | 74 | |
| 67 | 75 | /// What a secondary press lands on, which its context menu offers commands for. |
| ... | ... | @@ -235,6 +243,7 @@ pub struct PageView { |
| 235 | 243 | caret: f32, |
| 236 | 244 | /// When the caret last moved, which restarts its blink. |
| 237 | 245 | blink_from: Instant, |
| 246 | ink: ink::State, | |
| 238 | 247 | } |
| 239 | 248 | |
| 240 | 249 | /// Room, in OneNote pixels, the view leaves beyond content it scrolls to. |
| ... | ... | @@ -293,6 +302,7 @@ impl PageView { |
| 293 | 302 | found: Vec::new(), |
| 294 | 303 | caret: 1.0, |
| 295 | 304 | blink_from: Instant::now(), |
| 305 | ink: Default::default(), | |
| 296 | 306 | }; |
| 297 | 307 | view.place_opened(); |
| 298 | 308 | view |
| ... | ... | @@ -306,6 +316,7 @@ impl PageView { |
| 306 | 316 | self.space = false; |
| 307 | 317 | self.object_focus = None; |
| 308 | 318 | self.found.clear(); |
| 319 | self.leave_ink(); | |
| 309 | 320 | } |
| 310 | 321 | |
| 311 | 322 | /// Shows the stored page after a change made elsewhere in place of the one shown, |
| ... | ... | @@ -320,6 +331,7 @@ impl PageView { |
| 320 | 331 | } |
| 321 | 332 | self.drag = None; |
| 322 | 333 | self.object_focus = None; |
| 334 | self.refresh_ink(); | |
| 323 | 335 | self.moved() |
| 324 | 336 | } |
| 325 | 337 | |
| ... | ... | @@ -858,6 +870,15 @@ impl PageView { |
| 858 | 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 | 882 | if let Some(axis) = self |
| 862 | 883 | .space_axis() |
| 863 | 884 | .filter(|_| self.pointer_inside || self.drag.is_some()) |
| ... | ... | @@ -879,6 +900,7 @@ impl PageView { |
| 879 | 900 | &mut primitives, |
| 880 | 901 | ); |
| 881 | 902 | } |
| 903 | self.append_ink_feedback(colors.paper.ink, &mut primitives); | |
| 882 | 904 | Ok(primitives) |
| 883 | 905 | } |
| 884 | 906 | |
| ... | ... | @@ -887,7 +909,24 @@ impl PageView { |
| 887 | 909 | if let Some(axis) = self.space_axis() { |
| 888 | 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 | 930 | match (&self.drag, hit) { |
| 892 | 931 | (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle), |
| 893 | 932 | (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle), |
| ... | ... | @@ -939,6 +978,7 @@ impl PageView { |
| 939 | 978 | if !focused { |
| 940 | 979 | self.drag = None; |
| 941 | 980 | self.space = false; |
| 981 | self.end_ink(); | |
| 942 | 982 | } |
| 943 | 983 | self.changed() |
| 944 | 984 | } |
| ... | ... | @@ -960,6 +1000,9 @@ impl PageView { |
| 960 | 1000 | pub fn pointer_moved(&mut self, position: [f32; 2]) -> Result<Response> { |
| 961 | 1001 | self.pointer_inside = true; |
| 962 | 1002 | self.pointer = position; |
| 1003 | if self.inking() { | |
| 1004 | return self.ink_moved(self.viewport.document_point(position)); | |
| 1005 | } | |
| 963 | 1006 | match &mut self.drag { |
| 964 | 1007 | Some(Drag::Text { anchor, unit }) => { |
| 965 | 1008 | let (anchor, unit) = (*anchor, *unit); |
| ... | ... | @@ -1019,6 +1062,13 @@ impl PageView { |
| 1019 | 1062 | }); |
| 1020 | 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 | 1072 | let unit = self |
| 1023 | 1073 | .clicks |
| 1024 | 1074 | .press(now, self.pointer, 4.0 * self.display_scale); |
| ... | ... | @@ -1143,6 +1193,9 @@ impl PageView { |
| 1143 | 1193 | } |
| 1144 | 1194 | |
| 1145 | 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 | 1199 | if let Some(Drag::Space { axis, line }) = self.drag { |
| 1147 | 1200 | self.drag = None; |
| 1148 | 1201 | let delta = self.viewport.document_point(self.pointer)[axis] - line; |
| ... | ... | @@ -1436,6 +1489,39 @@ impl PageView { |
| 1436 | 1489 | self.edited() |
| 1437 | 1490 | } |
| 1438 | 1491 | |
| 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 | 1525 | /// Insert, Attach File: see [`CanvasEditor::insert_attachment`]. A file without an icon |
| 1440 | 1526 | /// takes the page's blank one. |
| 1441 | 1527 | pub fn insert_attachment(&mut self, mut file: onestore::page::Attachment) -> Result<Response> { |
| ... | ... | @@ -1562,6 +1648,9 @@ impl PageView { |
| 1562 | 1648 | if key == &Key::Named(NamedKey::Modifier) { |
| 1563 | 1649 | return Ok(Response::default()); |
| 1564 | 1650 | } |
| 1651 | if let Some(response) = self.ink_key(key)? { | |
| 1652 | return Ok(response); | |
| 1653 | } | |
| 1565 | 1654 | if let Some(ObjectFocus::Image(id) | ObjectFocus::File(id)) = self.object_focus |
| 1566 | 1655 | && matches!(key, Key::Named(NamedKey::Backspace | NamedKey::Delete)) |
| 1567 | 1656 | { |
| ... | ... | @@ -1808,6 +1897,22 @@ pub fn page_hit_test( |
| 1808 | 1897 | page_hit(editor, scene, point, pixel, None) |
| 1809 | 1898 | } |
| 1810 | 1899 | |
| 1900 | /// OneNote 2010's play button: a blue disc with a white triangle, in `rect`. | |
| 1901 | fn 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 | 1916 | /// Logical pixels a finger's grip targets extend beyond the drawn grips. |
| 1812 | 1917 | const TOUCH_REACH: f32 = 16.0; |
| 1813 | 1918 |
crates/canvas/src/outline.rs+2| ... | ... | @@ -2613,8 +2613,10 @@ mod tests { |
| 2613 | 2613 | color: None, |
| 2614 | 2614 | transparency: None, |
| 2615 | 2615 | pen_tip: None, |
| 2616 | raster_operation: None, | |
| 2616 | 2617 | }], |
| 2617 | 2618 | groups: Vec::new(), |
| 2619 | shape: None, | |
| 2618 | 2620 | }); |
| 2619 | 2621 | let after = paragraph(4, "After", 1, None); |
| 2620 | 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 | ||
| 8 | use canvas::{ | |
| 9 | editor::{FAVORITES, Pen}, | |
| 10 | gpu::page::PageScene, | |
| 11 | interaction::{PageView, TextColors, ink::Tool}, | |
| 12 | layout::TextEngine, | |
| 13 | }; | |
| 14 | use draw::edit::{Key, NamedKey}; | |
| 15 | use onestore::{ | |
| 16 | Arena, ExGuid, Section, Store, | |
| 17 | op::{Edit, Op, PageOp}, | |
| 18 | page::{Page, PageObject, ink::ShapeKind}, | |
| 19 | }; | |
| 20 | use std::time::{Duration, Instant}; | |
| 21 | ||
| 22 | const 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 | ||
| 28 | struct Lab<'a> { | |
| 29 | section: Section<'a>, | |
| 30 | space: ExGuid, | |
| 31 | view: PageView, | |
| 32 | at: u64, | |
| 33 | } | |
| 34 | ||
| 35 | impl 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 | ||
| 86 | fn 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 | ||
| 110 | fn 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] | |
| 123 | fn 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 | |
| 8 | 8 | use canvas::{ |
| 9 | 9 | document::TextPosition, |
| 10 | editor::{Alignment, CanvasEditor, Formatting, NoteTag, Toggle, Whole}, | |
| 10 | editor::{Alignment, CanvasEditor, FAVORITES, Formatting, NoteTag, Pen, Toggle, Whole}, | |
| 11 | 11 | layout::TextEngine, |
| 12 | 12 | }; |
| 13 | 13 | use draw::edit::Movement; |
| ... | ... | @@ -31,7 +31,7 @@ const ART: [u8; 67] = [ |
| 31 | 31 | #[path = "../../onestore/tests/support/sweep.rs"] |
| 32 | 32 | mod sweep; |
| 33 | 33 | |
| 34 | const SECTIONS: [&str; 18] = [ | |
| 34 | const SECTIONS: [&str; 19] = [ | |
| 35 | 35 | "corpus/outline-edit/before/notebook/synthetic.one", |
| 36 | 36 | "corpus/paragraph-edit/before/notebook/synthetic.one", |
| 37 | 37 | "corpus/outline-edit/tree/before/notebook/synthetic.one", |
| ... | ... | @@ -43,6 +43,7 @@ const SECTIONS: [&str; 18] = [ |
| 43 | 43 | "corpus/picture-edit/native-page-level/notebook/pictures.one", |
| 44 | 44 | "corpus/attachment-edit/plain/cold/notebook/files.one", |
| 45 | 45 | "corpus/ink-edit/drawing/cold/notebook/ink.one", |
| 46 | "corpus/ink-tools/native-ui/notebook/ink.one", | |
| 46 | 47 | "corpus/math-edit/native-editor/notebook/links.one", |
| 47 | 48 | "corpus/link-edit/native-links/notebook/links.one", |
| 48 | 49 | "corpus/link-edit/native-typed/notebook/links.one", |
| ... | ... | @@ -110,6 +111,8 @@ enum Kind { |
| 110 | 111 | Paper, |
| 111 | 112 | Art, |
| 112 | 113 | Attach, |
| 114 | /// Record Audio at a random caret, a note taken meanwhile, then Stop. | |
| 115 | Record, | |
| 113 | 116 | /// A click on blank page, then Attach File: a file on the page. |
| 114 | 117 | AttachPage, |
| 115 | 118 | MoveFile, |
| ... | ... | @@ -118,6 +121,15 @@ enum Kind { |
| 118 | 121 | TypeRun, |
| 119 | 122 | /// More typing where the caret is, joining the run before it. |
| 120 | 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 | 133 | Undo, |
| 122 | 134 | Redo, |
| 123 | 135 | } |
| ... | ... | @@ -202,6 +214,13 @@ const ATTACH_PLANS: [&[Kind]; 2] = [ |
| 202 | 214 | &[Kind::Attach, Kind::Type, Kind::Attach, Kind::Undo], |
| 203 | 215 | ]; |
| 204 | 216 | |
| 217 | /// Recordings at a random caret, in plans of their own so the random edits' sequences stay | |
| 218 | /// as they were. | |
| 219 | const RECORD_PLANS: [&[Kind]; 2] = [ | |
| 220 | &[Kind::Record, Kind::Undo, Kind::Redo], | |
| 221 | &[Kind::Record, Kind::PageDelete, Kind::Undo], | |
| 222 | ]; | |
| 223 | ||
| 205 | 224 | /// Files attached on blank page, moved, spaced and removed, in plans of their own so the |
| 206 | 225 | /// random edits' sequences stay as they were. |
| 207 | 226 | const FLOATING_PLANS: [&[Kind]; 4] = [ |
| ... | ... | @@ -224,6 +243,30 @@ const FLOATING_PLANS: [&[Kind]; 4] = [ |
| 224 | 243 | ], |
| 225 | 244 | ]; |
| 226 | 245 | |
| 246 | /// The Draw tab's tools, in plans of their own so the random edits' sequences stay as they | |
| 247 | /// were. | |
| 248 | const 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 | 270 | /// Page colour, rule lines and art undone and redone across each other. |
| 228 | 271 | const PAPER_PLANS: [&[Kind]; 3] = [ |
| 229 | 272 | &[Kind::Art, Kind::Art, Kind::Undo, Kind::Undo, Kind::Redo], |
| ... | ... | @@ -290,6 +333,12 @@ fn perform( |
| 290 | 333 | | Kind::Date |
| 291 | 334 | | Kind::Paper |
| 292 | 335 | | Kind::Art |
| 336 | | Kind::Pen | |
| 337 | | Kind::Marker | |
| 338 | | Kind::Shape | |
| 339 | | Kind::Erase | |
| 340 | | Kind::LassoMove | |
| 341 | | Kind::LassoDelete | |
| 293 | 342 | ) { |
| 294 | 343 | if outlines.is_empty() { |
| 295 | 344 | return false; |
| ... | ... | @@ -561,6 +610,29 @@ fn perform( |
| 561 | 610 | }; |
| 562 | 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 | 636 | Kind::Attach => { |
| 565 | 637 | let file = onestore::page::Attachment { |
| 566 | 638 | id: onestore::page::text::new_id().unwrap(), |
| ... | ... | @@ -624,8 +696,79 @@ fn perform( |
| 624 | 696 | && editor.delete_to(engine, Movement::WordLeft).is_ok() |
| 625 | 697 | && editor.commit_text(engine, "ef".into()).is_ok() |
| 626 | 698 | } |
| 627 | Kind::TypeOn => { | |
| 628 | editor.insert(engine, "gh").is_ok() && editor.delete(engine, true).is_ok() | |
| 699 | Kind::TypeOn => 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 | 773 | Kind::Undo => editor.undo(engine).unwrap_or(false), |
| 631 | 774 | Kind::Redo => editor.redo(engine).unwrap_or(false), |
| ... | ... | @@ -915,7 +1058,9 @@ fn sweep( |
| 915 | 1058 | .collect() |
| 916 | 1059 | })) |
| 917 | 1060 | .chain(PAPER_PLANS.map(<[Kind]>::to_vec)) |
| 1061 | .chain(INK_PLANS.map(<[Kind]>::to_vec)) | |
| 918 | 1062 | .chain(ATTACH_PLANS.map(<[Kind]>::to_vec)) |
| 1063 | .chain(RECORD_PLANS.map(<[Kind]>::to_vec)) | |
| 919 | 1064 | .chain(FLOATING_PLANS.map(<[Kind]>::to_vec)) |
| 920 | 1065 | .chain(TYPING_PLANS.map(<[Kind]>::to_vec)) |
| 921 | 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 | ||
| 6 | use canvas::{editor::CanvasEditor, layout::TextEngine}; | |
| 7 | use draw::edit::Movement; | |
| 8 | use onestore::{ | |
| 9 | Arena, Section, | |
| 10 | op::{Edit, Op}, | |
| 11 | page::{Attachment, MediaIndex, Page, PageObject, ParagraphContent, Recording}, | |
| 12 | }; | |
| 13 | ||
| 14 | fn 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] | |
| 36 | fn 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] | |
| 131 | fn 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 | 188 | Image(u64), |
| 189 | 189 | /// Clearing it by the batch's coverage. |
| 190 | 190 | Erase, |
| 191 | /// Multiplying it by the batch's colour where covered. | |
| 192 | Multiply, | |
| 191 | 193 | } |
| 192 | 194 | |
| 193 | 195 | #[repr(C)] |
| ... | ... | @@ -427,6 +429,14 @@ pub enum Primitive<'a> { |
| 427 | 429 | round: bool, |
| 428 | 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 | } |
| 431 | 441 | |
| 432 | 442 | #[derive(Debug)] |
| ... | ... | @@ -851,6 +861,15 @@ impl Renderer { |
| 851 | 861 | round, |
| 852 | 862 | color, |
| 853 | 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 | 873 | Primitive::Image { image, rect } => { |
| 855 | 874 | if let Some(rect) = space.visible_rect(*rect)? { |
| 856 | 875 | self.image(image, active_images)?; |
| ... | ... | @@ -1600,6 +1619,11 @@ pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] { |
| 1600 | 1619 | [red, green, blue, 1.0] |
| 1601 | 1620 | } |
| 1602 | 1621 | |
| 1622 | /// The sRGB bytes of linear RGBA `color`, as `srgb` takes them. | |
| 1623 | pub fn srgb_bytes([red, green, blue, _]: [f32; 4]) -> [u8; 3] { | |
| 1624 | [red, green, blue].map(srgb_byte) | |
| 1625 | } | |
| 1626 | ||
| 1603 | 1627 | #[cfg(all(test, feature = "wgpu"))] |
| 1604 | 1628 | mod tests { |
| 1605 | 1629 | use super::*; |
crates/draw/src/render/gl.rs+4| ... | ... | @@ -21,6 +21,7 @@ const ZERO: GLenum = 0; |
| 21 | 21 | const ONE: GLenum = 1; |
| 22 | 22 | const SRC_ALPHA: GLenum = 0x0302; |
| 23 | 23 | const ONE_MINUS_SRC_ALPHA: GLenum = 0x0303; |
| 24 | const DST_COLOR: GLenum = 0x0306; | |
| 24 | 25 | const BLEND: GLenum = 0x0BE2; |
| 25 | 26 | const SCISSOR_TEST: GLenum = 0x0C11; |
| 26 | 27 | const MAX_TEXTURE_SIZE: GLenum = 0x0D33; |
| ... | ... | @@ -463,6 +464,9 @@ impl Renderer { |
| 463 | 464 | gpu.atlas.name, |
| 464 | 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 | 471 | glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha); |
| 468 | 472 | glBindTexture(TEXTURE_2D, texture); |
crates/draw/src/render/webgpu.rs+17| ... | ... | @@ -13,6 +13,7 @@ pub(super) struct Gpu { |
| 13 | 13 | pipeline: wgpu::RenderPipeline, |
| 14 | 14 | image_pipeline: wgpu::RenderPipeline, |
| 15 | 15 | erase_pipeline: wgpu::RenderPipeline, |
| 16 | multiply_pipeline: wgpu::RenderPipeline, | |
| 16 | 17 | atlas: wgpu::Texture, |
| 17 | 18 | bind_group: wgpu::BindGroup, |
| 18 | 19 | image_sampler: wgpu::Sampler, |
| ... | ... | @@ -98,6 +99,20 @@ impl Renderer { |
| 98 | 99 | color: erase, |
| 99 | 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 | 116 | let (atlas, bind_group) = atlas(&device, &pipeline, ATLAS_SIZE); |
| 102 | 117 | let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { |
| 103 | 118 | label: Some("Draw vertices"), |
| ... | ... | @@ -118,6 +133,7 @@ impl Renderer { |
| 118 | 133 | pipeline, |
| 119 | 134 | image_pipeline, |
| 120 | 135 | erase_pipeline, |
| 136 | multiply_pipeline, | |
| 121 | 137 | atlas, |
| 122 | 138 | bind_group, |
| 123 | 139 | image_sampler, |
| ... | ... | @@ -268,6 +284,7 @@ impl Renderer { |
| 268 | 284 | Blend::Over => (&gpu.pipeline, &gpu.bind_group), |
| 269 | 285 | Blend::Image(id) => (&gpu.image_pipeline, &self.images[&id].texture), |
| 270 | 286 | Blend::Erase => (&gpu.erase_pipeline, &gpu.bind_group), |
| 287 | Blend::Multiply => (&gpu.multiply_pipeline, &gpu.bind_group), | |
| 271 | 288 | }; |
| 272 | 289 | pass.set_pipeline(pipeline); |
| 273 | 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 | 16 | void sb_set_coordinator(sb_coordinator coordinator); |
| 17 | 17 | void sb_set_sync_wake(void (*wake)(void)); |
| 18 | 18 | char *sb_tags(void); |
| 19 | char *sb_pens(void); | |
| 19 | 20 | bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba); |
| 20 | 21 | |
| 21 | const Library *sb_library_open(const char *path, const char *cache, char **error); | |
| 22 | const Library *sb_library_open(const char *path, const char *cache, bool local, char **error); | |
| 22 | 23 | const Library *sb_library_server(const char *address, const char *share, const char *user, const char *password, |
| 23 | 24 | const char *domain, const char *root, const char *cache, char **error); |
| 24 | 25 | char *sb_library_sections(const Library *library); |
| ... | ... | @@ -28,6 +29,7 @@ char *sb_library_search(const Library *library, const Section *open, const char |
| 28 | 29 | char *sb_library_tagged(const Library *library, const Section *open, const char *path); |
| 29 | 30 | char *sb_library_sync_status(const Library *library); |
| 30 | 31 | void sb_library_set_offline(const Library *library, bool offline); |
| 32 | void sb_library_touched(const Library *library, const char *path); | |
| 31 | 33 | void sb_library_sync_now(const Library *library); |
| 32 | 34 | void sb_library_free(const Library *library); |
| 33 | 35 | |
| ... | ... | @@ -73,6 +75,8 @@ bool sb_view_find(View *view, const char *query); |
| 73 | 75 | bool sb_view_select_paragraph(View *view, const char *id); |
| 74 | 76 | char *sb_view_copy(View *view, bool cut); |
| 75 | 77 | void sb_view_insert_space(View *view); |
| 78 | void sb_view_set_tool(View *view, uint8_t tool, uint8_t pen); | |
| 79 | bool sb_view_ink_selection(const View *view, float rect[4]); | |
| 76 | 80 | char *sb_view_paper(const View *view); |
| 77 | 81 | bool sb_view_set_paper(View *view, int16_t red, uint8_t green, uint8_t blue, int8_t ruled); |
| 78 | 82 | bool 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 | 15 | use canvas::{ |
| 16 | 16 | date::DateField, |
| 17 | 17 | document::TextPosition, |
| 18 | editor::{Formatting, NoteTag, Selection, TextOutline, Toggle}, | |
| 18 | editor::{FAVORITES, Formatting, NoteTag, Selection, TextOutline, Toggle}, | |
| 19 | 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 | 21 | layout::TextEngine, |
| 22 | 22 | }; |
| 23 | 23 | use draw::edit::{Key, NamedKey, SelectionUnit}; |
| ... | ... | @@ -74,7 +74,8 @@ enum Target { |
| 74 | 74 | /// Another outline's text, which a touch focuses before the platform's text interaction |
| 75 | 75 | /// takes over. |
| 76 | 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 | 79 | Grip = 3, |
| 79 | 80 | } |
| 80 | 81 | |
| ... | ... | @@ -210,10 +211,17 @@ impl Canvas { |
| 210 | 211 | } |
| 211 | 212 | |
| 212 | 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 | 222 | return Target::Grip; |
| 215 | 223 | } |
| 216 | match self.page.hit(self.device(point)) { | |
| 224 | match self.page.hit(device) { | |
| 217 | 225 | Some(Hit::Handle { .. } | Hit::Resize { .. }) => Target::Grip, |
| 218 | 226 | Some(Hit::Image { id, .. }) |
| 219 | 227 | if self.page.object_focus() == Some(ObjectFocus::Image(id)) => |
| ... | ... | @@ -512,17 +520,43 @@ impl Canvas { |
| 512 | 520 | Ok(moved(self.page.change_date(timestamp, text)?)) |
| 513 | 521 | } |
| 514 | 522 | |
| 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 | 544 | /// A rectangle in the active outline's coordinates, in the view's points as `[x, y, |
| 516 | 545 | /// width, height]`. |
| 517 | 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 | 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, | |
| 523 | (y * viewport.scale + viewport.origin[1]) / self.scale, | |
| 524 | rect.width() as f32 * viewport.scale / self.scale, | |
| 525 | rect.height() as f32 * viewport.scale / self.scale, | |
| 556 | (x0 * viewport.scale + viewport.origin[0]) / self.scale, | |
| 557 | (y0 * viewport.scale + viewport.origin[1]) / self.scale, | |
| 558 | (x1 - x0) * viewport.scale / self.scale, | |
| 559 | (y1 - y0) * viewport.scale / self.scale, | |
| 526 | 560 | ] |
| 527 | 561 | } |
| 528 | 562 | |
| ... | ... | @@ -848,7 +882,7 @@ pub extern "C" fn sb_view_set_transform(view: &mut View, zoom: f32, x: f32, y: f |
| 848 | 882 | |
| 849 | 883 | /// What the view point `x`, `y` lands on: 0 for the page, where the canvas takes a tap; 1 |
| 850 | 884 | /// 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 | 886 | #[unsafe(no_mangle)] |
| 853 | 887 | pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 { |
| 854 | 888 | 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 | 991 | copied.map_or(std::ptr::null_mut(), owned) |
| 958 | 992 | } |
| 959 | 993 | |
| 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)] | |
| 1000 | pub 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)] | |
| 1007 | pub 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)] | |
| 1017 | pub 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 | 1024 | /// Insert Space: the next drag on the page moves what lies below or right of where it |
| 961 | 1025 | /// starts, as far as it goes. |
| 962 | 1026 | #[unsafe(no_mangle)] |
crates/mobile/src/library.rs+31-9| ... | ... | @@ -7,7 +7,7 @@ use canvas::search::{Entry, Index, Query, Tagged}; |
| 7 | 7 | use notebook::{ |
| 8 | 8 | Remote, Replica, |
| 9 | 9 | discover::{Folder, SectionState}, |
| 10 | session::{self, Background, Event, Notebook, SyncStatus}, | |
| 10 | session::{self, Background, Event, Known, Notebook, SyncStatus}, | |
| 11 | 11 | smb::{Client, Credentials}, |
| 12 | 12 | }; |
| 13 | 13 | use onestore::{ |
| ... | ... | @@ -265,15 +265,17 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) { |
| 265 | 265 | } |
| 266 | 266 | |
| 267 | 267 | impl 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 | 272 | let (notebook, place, background) = if path.is_file() { |
| 270 | 273 | (None, Place::File(path.to_owned()), None) |
| 271 | 274 | } else { |
| 272 | 275 | let notebook = Notebook::open(path, cache)?; |
| 273 | // Nothing here tells a file provider's folder on this device from one elsewhere. | |
| 274 | 276 | let background = notebook.background_with( |
| 275 | Background::UNWATCHED, | |
| 276 | false, | |
| 277 | local, | |
| 278 | !local, | |
| 277 | 279 | |file| Coordinated(file.to_owned()), |
| 278 | 280 | sync_woken, |
| 279 | 281 | )?; |
| ... | ... | @@ -331,8 +333,10 @@ impl Library { |
| 331 | 333 | background.watch( |
| 332 | 334 | files |
| 333 | 335 | .iter() |
| 334 | .map(|(path, identity)| { | |
| 335 | (path.clone(), Some(smb.join(format!("{identity}.sqlite")))) | |
| 336 | .map(|(path, identity)| Known { | |
| 337 | path: path.clone(), | |
| 338 | replica: Some(smb.join(format!("{identity}.sqlite"))), | |
| 339 | found: None, | |
| 336 | 340 | }) |
| 337 | 341 | .collect(), |
| 338 | 342 | ); |
| ... | ... | @@ -467,6 +471,9 @@ impl Library { |
| 467 | 471 | } |
| 468 | 472 | }; |
| 469 | 473 | section.set_offline(self.offline.load(Ordering::Relaxed)); |
| 474 | if let Some(background) = &self.background { | |
| 475 | background.hold(path, &section); | |
| 476 | } | |
| 470 | 477 | Ok(section) |
| 471 | 478 | } |
| 472 | 479 | |
| ... | ... | @@ -1033,7 +1040,9 @@ fn space(text: *const c_char) -> Result<ExGuid> { |
| 1033 | 1040 | } |
| 1034 | 1041 | |
| 1035 | 1042 | /// 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 | 1047 | /// # Safety |
| 1039 | 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 | 1050 | pub unsafe extern "C" fn sb_library_open( |
| 1042 | 1051 | path: *const c_char, |
| 1043 | 1052 | cache: *const c_char, |
| 1053 | local: bool, | |
| 1044 | 1054 | error: *mut *mut c_char, |
| 1045 | 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 | 1057 | Ok(library) => Arc::into_raw(Arc::new(library)), |
| 1048 | 1058 | Err(cause) => { |
| 1049 | 1059 | failed(cause, error); |
| ... | ... | @@ -1168,6 +1178,18 @@ pub extern "C" fn sb_library_set_offline(library: &Library, offline: bool) { |
| 1168 | 1178 | } |
| 1169 | 1179 | } |
| 1170 | 1180 | |
| 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)] | |
| 1187 | pub 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 | 1193 | /// Syncs every section of the notebook now, working offline included. |
| 1172 | 1194 | #[unsafe(no_mangle)] |
| 1173 | 1195 | pub 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 | 53 | |
| 54 | 54 | fn features() -> (tempfile::TempDir, Section) { |
| 55 | 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 | 57 | let section = open(&library, &file.to_string_lossy()); |
| 58 | 58 | (directory, section) |
| 59 | 59 | } |
| ... | ... | @@ -82,8 +82,12 @@ fn focus(canvas: &mut Canvas, text: &str) { |
| 82 | 82 | #[test] |
| 83 | 83 | fn a_notebook_folder_lists_sections_in_order_with_groups_and_colours() { |
| 84 | 84 | let directory = tempfile::tempdir().unwrap(); |
| 85 | let library = | |
| 86 | Library::open(&corpus("m6/native-features-01/notebook"), directory.path()).unwrap(); | |
| 85 | let library = Library::open( | |
| 86 | &corpus("m6/native-features-01/notebook"), | |
| 87 | directory.path(), | |
| 88 | true, | |
| 89 | ) | |
| 90 | .unwrap(); | |
| 87 | 91 | let tabs = library.tabs().unwrap(); |
| 88 | 92 | let names: Vec<_> = tabs |
| 89 | 93 | .iter() |
| ... | ... | @@ -94,6 +98,7 @@ fn a_notebook_folder_lists_sections_in_order_with_groups_and_colours() { |
| 94 | 98 | let lone = Library::open( |
| 95 | 99 | &corpus("media-edit/candidate/Features.one"), |
| 96 | 100 | directory.path(), |
| 101 | true, | |
| 97 | 102 | ) |
| 98 | 103 | .unwrap(); |
| 99 | 104 | assert_eq!(lone.tabs().unwrap().len(), 1); |
| ... | ... | @@ -255,7 +260,7 @@ fn stored_text(file: &Path, space: ExGuid) -> String { |
| 255 | 260 | fn typing_is_stored_in_the_cache_and_published_to_the_file() { |
| 256 | 261 | let (directory, file) = copy("media-edit/candidate/Features.one"); |
| 257 | 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 | 264 | let section = open(&library, &file.to_string_lossy()); |
| 260 | 265 | let mut canvas = canvas(&section, "Paragraph controls"); |
| 261 | 266 | focus(&mut canvas, "Collapsed parent"); |
| ... | ... | @@ -285,7 +290,7 @@ fn typing_is_stored_in_the_cache_and_published_to_the_file() { |
| 285 | 290 | #[test] |
| 286 | 291 | fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin() { |
| 287 | 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 | 294 | let section = open(&library, "Features.one"); |
| 290 | 295 | let first = section.rows().unwrap()[0].id.parse().unwrap(); |
| 291 | 296 | let page = section |
| ... | ... | @@ -338,7 +343,7 @@ fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin( |
| 338 | 343 | #[test] |
| 339 | 344 | fn conflict_pages_list_under_their_page_and_take_no_edits() { |
| 340 | 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 | 347 | let section = open(&library, "synthetic.one"); |
| 343 | 348 | let rows = section.rows().unwrap(); |
| 344 | 349 | let version = rows |
| ... | ... | @@ -362,8 +367,12 @@ fn conflict_pages_list_under_their_page_and_take_no_edits() { |
| 362 | 367 | #[test] |
| 363 | 368 | fn search_finds_pages_by_title_and_text_across_sections() { |
| 364 | 369 | let directory = tempfile::tempdir().unwrap(); |
| 365 | let library = | |
| 366 | Library::open(&corpus("m6/native-features-01/notebook"), directory.path()).unwrap(); | |
| 370 | let library = Library::open( | |
| 371 | &corpus("m6/native-features-01/notebook"), | |
| 372 | directory.path(), | |
| 373 | true, | |
| 374 | ) | |
| 375 | .unwrap(); | |
| 367 | 376 | let found = library.search(None, "collapsed PARENT").unwrap(); |
| 368 | 377 | assert!(!found.is_empty()); |
| 369 | 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 | 455 | #[test] |
| 447 | 456 | fn to_do_marks_a_new_pages_body() { |
| 448 | 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 | 459 | let section = open(&library, "Features.one"); |
| 451 | 460 | let page = section |
| 452 | 461 | .new_page(None, "Sunday, September 27, 2026", "3:04 AM") |
| ... | ... | @@ -571,7 +580,7 @@ extern "C" fn coordinator( |
| 571 | 580 | fn a_local_sections_reads_and_publications_go_through_the_hosts_coordination() { |
| 572 | 581 | library::sb_set_coordinator(coordinator); |
| 573 | 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 | 584 | let section = open(&library, "Features.one"); |
| 576 | 585 | let mut canvas = canvas(&section, "Paragraph controls"); |
| 577 | 586 | focus(&mut canvas, "Collapsed parent"); |
| ... | ... | @@ -640,7 +649,7 @@ fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() { |
| 640 | 649 | /// A notebook copy with its library, and a section of it open for editing. |
| 641 | 650 | fn notebook_open(path: &str) -> (tempfile::TempDir, PathBuf, Arc<Library>, Section) { |
| 642 | 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 | 653 | let section = open(&library, path); |
| 645 | 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 | 712 | #[test] |
| 704 | 713 | fn sections_open_the_first_time_while_the_background_syncs() { |
| 705 | 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 | 716 | let rounds = { |
| 708 | 717 | let library = Arc::clone(&library); |
| 709 | 718 | std::thread::spawn(move || { |
| ... | ... | @@ -735,7 +744,7 @@ fn sections_open_the_first_time_while_the_background_syncs() { |
| 735 | 744 | #[test] |
| 736 | 745 | fn the_tags_summary_lists_tagged_paragraphs_and_opens_on_them() { |
| 737 | 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 | 748 | let path = file.to_string_lossy().into_owned(); |
| 740 | 749 | let section = open(&library, &path); |
| 741 | 750 | let tagged = library.tagged(Some((&path, &section))).unwrap(); |
| ... | ... | @@ -825,3 +834,97 @@ fn insert_space_turns_the_next_touch_into_a_drag() { |
| 825 | 834 | let _ = canvas.page.insert_space(); |
| 826 | 835 | assert_eq!(canvas.target([300.0, 1500.0]), Target::Grip); |
| 827 | 836 | } |
| 837 | ||
| 838 | /// The page's drawings as the section stores them. | |
| 839 | fn 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. | |
| 851 | fn 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 | ||
| 862 | fn 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] | |
| 872 | fn 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 | 49 | |
| 50 | 50 | ## Sessions |
| 51 | 51 | |
| 52 | `session::Notebook::open(root, cache_dir)` discovers a notebook directory and | |
| 52 | `session::Notebook::open(root, cache_dir)` discovers a notebook directory, reading only | |
| 53 | the files its folders list otherwise than when last opened (`discover::Cache`, kept under | |
| 54 | `cache_dir/listings`), and | |
| 53 | 55 | `session::Section::open(file, cache_dir, notify)` opens one section file through |
| 54 | 56 | a replica named by the section's document identity, so the same file reopens the |
| 55 | 57 | same 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 | 68 | |
| 67 | 69 | `session::Background` keeps the sections no session holds in sync, as OneNote 2010 keeps |
| 68 | 70 | every section of an open notebook: `Background::smb(root, limit, connect, notify)` on a |
| 69 | share, `Notebook::background(interval, copies, notify)` for a mounted notebook, then | |
| 71 | share, `Notebook::background(watched, copies, notify)` for a mounted notebook, then | |
| 70 | 72 | `watch(notebook.replicas())`. On a share, one CHANGE_NOTIFY on the notebook's folder |
| 71 | 73 | (`smb::Client::watch`) reports what changed; a host watching a mounted folder passes the |
| 72 | changed paths to `touched`. A reported section is checked a second later, a failing one | |
| 73 | every 31 seconds, and any other every `interval` (`Background::BACKSTOP` while a watch | |
| 74 | reports). Newly watched sections, and every section after a reconnect, are checked one at a | |
| 75 | time 100 ms apart. A check reads the file's stamp; a section whose replica has edits waiting, | |
| 76 | or whose file moved past the replica's base, has the replica opened for the synchronization | |
| 77 | steps that publish or rebase it and closed again, and a section without a replica gets one | |
| 78 | from the file, its offline copy, on a share or with `copies`. A replica a session holds is | |
| 79 | skipped (`Error::busy`), so opening a section may wait out one step. `status()` gives each | |
| 80 | section's `SyncStatus`, `changed()` the sections another client changed, `wake` and | |
| 81 | `set_offline` follow Sync Now and Work Offline, and `discard` stops the thread and deletes the | |
| 82 | replicas holding nothing unpublished, as when the notebook closes. | |
| 74 | changed paths to `touched`, and says whether it reports every change as `watched`. A | |
| 75 | reported section is checked a second later; a reported folder is listed a second later and | |
| 76 | only its sections listed otherwise are checked. A failing section is tried every 31 seconds, | |
| 77 | and any other every `Background::BACKSTOP` while a watch reports, `UNWATCHED` otherwise. On | |
| 78 | connecting, and on working online again, every folder is listed: a section whose file lists as | |
| 79 | discovery or the last check found it needs no reading, the others are checked one at a time | |
| 80 | 100 ms apart. A check reads the file's stamp; a section whose replica has edits waiting, or | |
| 81 | whose file moved past the replica's base, has the replica opened for the synchronization steps | |
| 82 | that publish or rebase it and closed again, and a section without a replica gets one from the | |
| 83 | file, its offline copy, on a share or with `copies`. `hold(path, section)` leaves an open | |
| 84 | section to its session: its worker no longer polls while a watch reports, the watch wakes it | |
| 85 | instead, and once its replica is released the background checks the file at once. A replica | |
| 86 | a session holds is otherwise skipped (`Error::busy`), so opening a section may wait out one | |
| 87 | step. `status()` gives each section's `SyncStatus`, `changed()` the sections another client | |
| 88 | changed, `wake` and `set_offline` follow Sync Now and Work Offline, and `discard` stops the | |
| 89 | thread and deletes the replicas holding nothing unpublished, as when the notebook closes. | |
| 83 | 90 | `Notebook::replica_path(path)` names a section's replica for either kind of notebook. |
| 84 | 91 | |
| 85 | 92 | `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 | 137 | interval and observer; successful publications drain immediately, durable local |
| 131 | 138 | edits wake the worker, and `wake()` requests an immediate reachability retry. |
| 132 | 139 | While nothing is queued, or the queue waits on a remote that has not changed since, |
| 133 | a remote whose `Remote::stamp` holds is not read again. | |
| 140 | a remote whose `Remote::stamp` holds is not read again; a worker a `Background` holds reads | |
| 141 | the stamp only when the notebook's watch wakes it. | |
| 134 | 142 | Transport failures discard the old connection and retry through the factory; |
| 135 | 143 | Read contention and `NotCommitted` operations with `WouldBlock` or `ResourceBusy` |
| 136 | 144 | reuse 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 | 260 | tried again on the next discovery; the root itself, malformed storage, other failed |
| 253 | 261 | reads and ambiguous identities reject the discovery. |
| 254 | 262 | |
| 263 | `Cache::discover` keeps each file's listing (size and last write time), stamp and what | |
| 264 | discovery took from it; the next discovery reads only the files listed otherwise, taking one | |
| 265 | from a copy where `Source::copy` has it as it stands (on a share, a replica whose base has the | |
| 266 | file's stamp), and `found(path)` gives a section's listing and stamp for `Background::watch`. A file written | |
| 267 | during discovery is read again next time; a failed discovery leaves the cache as it was. | |
| 268 | ||
| 255 | 269 | Each file read must be a consistent, bounded snapshot. The result is an |
| 256 | 270 | observation across multiple files, not an atomic notebook transaction or |
| 257 | 271 | authorization 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 | 388 | `Client::read_dir(path, entry_limit)` enumerates a directory, including the share |
| 375 | 389 | root with an empty path. It follows every response page and returns no partial |
| 376 | 390 | list on interruption, entry-limit overflow or close failure. Entries retain exact |
| 377 | Unicode names, observed sizes and MS-FSCC attributes, including directory/reparse | |
| 378 | flags. Concurrent directory changes are not an atomic snapshot; repeated names | |
| 391 | Unicode names, observed sizes, last write times and MS-FSCC attributes, including | |
| 392 | directory/reparse flags. Concurrent directory changes are not an atomic snapshot; repeated names | |
| 379 | 393 | are rejected with `ResourceBusy`. Notebook identities come from the files, not |
| 380 | 394 | directory names or sizes. Missing paths, denied access and non-directory paths |
| 381 | 395 | have 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 | ||
| 5 | use notebook::{ | |
| 6 | discover::{Entry, Limits, Local, Source, discover}, | |
| 7 | session::{Background, Notebook}, | |
| 8 | }; | |
| 9 | use 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 | }; | |
| 17 | use std::{ | |
| 18 | io, | |
| 19 | path::Path, | |
| 20 | time::{Duration, Instant}, | |
| 21 | }; | |
| 22 | ||
| 23 | type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>; | |
| 24 | ||
| 25 | struct Counting<S>(S, u64, u64); | |
| 26 | ||
| 27 | impl<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 | ||
| 39 | const LIMITS: Limits = Limits { | |
| 40 | entries: 100_000, | |
| 41 | bytes_per_file: 256 * 1024 * 1024, | |
| 42 | depth: 64, | |
| 43 | }; | |
| 44 | ||
| 45 | fn 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 | ||
| 90 | fn 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 | ||
| 119 | fn 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")] | |
| 132 | fn 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")] | |
| 146 | fn 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 | ||
| 161 | fn 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 | 4 | |
| 5 | 5 | use crate::{ |
| 6 | 6 | EditStatus, Error, Remote, Replica, Result, |
| 7 | session::{SyncStatus, reached}, | |
| 7 | discover::{Entry, Listed}, | |
| 8 | session::{Section, SyncStatus, reached}, | |
| 8 | 9 | worker::Signal, |
| 9 | 10 | }; |
| 10 | 11 | use onestore::Stamp; |
| 11 | 12 | use std::{ |
| 13 | collections::{BTreeMap, BTreeSet}, | |
| 12 | 14 | io, |
| 13 | 15 | path::{Path, PathBuf}, |
| 14 | 16 | sync::{ |
| ... | ... | @@ -28,16 +30,30 @@ const STAGGER: Duration = Duration::from_millis(100); |
| 28 | 30 | /// How long a reported change settles before its section is checked, so the writes of one |
| 29 | 31 | /// commit cost one check. |
| 30 | 32 | const SETTLE: Duration = Duration::from_secs(1); |
| 33 | /// The most entries a folder listing takes. | |
| 34 | const ENTRIES: usize = 100_000; | |
| 31 | 35 | |
| 32 | 36 | /// Keeps each watched section in sync: a section is checked when a watch on the notebook's |
| 33 | 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 file | |
| 35 | /// moved past the replica's base, has its replica opened for the synchronization steps that | |
| 36 | /// publish or rebase it, then closed again. A section without a replica gets one, its offline | |
| 37 | /// copy, where the notebook keeps them. A replica a session holds is left to that session's | |
| 38 | /// worker. Dropping requests cancellation without waiting for the step in flight. | |
| 38 | /// On connecting, and when a watch reports a folder rather than a file, the folders are | |
| 39 | /// listed and only the sections listed otherwise than before are checked, as OneNote 2010 | |
| 40 | /// reopens a notebook. A check costs one stamp read; a section whose replica has edits waiting, | |
| 41 | /// or whose file moved past the replica's base, has its replica opened for the synchronization | |
| 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. | |
| 39 | 46 | pub struct Background(Arc<Shared>); |
| 40 | 47 | |
| 48 | /// A section for `Background::watch`, as its notebook knows it. | |
| 49 | pub 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 | 57 | /// What the background thread and whatever reports changes to it share. |
| 42 | 58 | pub(crate) struct Shared { |
| 43 | 59 | signal: Arc<Signal>, |
| ... | ... | @@ -57,20 +73,35 @@ struct Watched { |
| 57 | 73 | sections: Vec<Watch>, |
| 58 | 74 | /// Sections whose file changed since `changed` was last asked. |
| 59 | 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 | 79 | /// Counts connections, so a watch that ended with an earlier one is not this one's. |
| 61 | 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 | 83 | lost: bool, |
| 84 | /// The current connection's watch reports the folder's changes. | |
| 85 | reported: bool, | |
| 64 | 86 | } |
| 65 | 87 | |
| 66 | 88 | struct Watch { |
| 67 | 89 | path: String, |
| 68 | 90 | replica: Option<PathBuf>, |
| 69 | /// The file's stamp when last reached. | |
| 91 | /// The file's stamp when last read. | |
| 70 | 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 | 100 | status: SyncStatus, |
| 72 | 101 | /// When the section is next checked. |
| 73 | 102 | due: Instant, |
| 103 | /// The worker of the session that holds the section. | |
| 104 | held: Option<Weak<Signal>>, | |
| 74 | 105 | } |
| 75 | 106 | |
| 76 | 107 | impl Background { |
| ... | ... | @@ -80,18 +111,19 @@ impl Background { |
| 80 | 111 | /// How often each section is checked where nothing reports its folder's changes. |
| 81 | 112 | pub const UNWATCHED: Duration = Duration::from_secs(15); |
| 82 | 113 | |
| 83 | /// Starts the thread. `connect` binds each catalog path to its file, arming any watch | |
| 84 | /// that reports the folder's changes through `Reports`, and answers how long a section | |
| 85 | /// may go unchecked while nothing reports it; it runs again after a transport failure. | |
| 86 | /// `copies` keeps an offline copy of every section. | |
| 87 | pub(crate) fn start<R, B>( | |
| 114 | /// Starts the thread. `connect` answers how the connection reaches each section file and | |
| 115 | /// lists each folder, both by catalog path, arming any watch that reports the folder's | |
| 116 | /// changes through `Reports`, and whether one does; it runs again after a transport | |
| 117 | /// failure. `copies` keeps an offline copy of every section. | |
| 118 | pub(crate) fn start<R, B, L>( | |
| 88 | 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 | 121 | notify: impl Fn() + Send + 'static, |
| 91 | 122 | ) -> Result<Self> |
| 92 | 123 | where |
| 93 | 124 | R: Remote, |
| 94 | 125 | B: FnMut(&str) -> R, |
| 126 | L: FnMut(&str) -> io::Result<Vec<Entry>>, | |
| 95 | 127 | { |
| 96 | 128 | let (signal, receiver) = Signal::new(); |
| 97 | 129 | let shared = Arc::new(Shared { |
| ... | ... | @@ -105,51 +137,48 @@ impl Background { |
| 105 | 137 | .name("onestore-background".into()) |
| 106 | 138 | .spawn(move || { |
| 107 | 139 | let signal = &owner.signal; |
| 108 | let mut bound: Option<(B, Duration)> = None; | |
| 140 | let mut bound: Option<(B, L)> = None; | |
| 109 | 141 | let mut news = false; |
| 110 | 142 | while !signal.stopped.load(Ordering::Acquire) { |
| 111 | 143 | if signal.offline.load(Ordering::Acquire) |
| 112 | 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 | 151 | bound = None; |
| 115 | 152 | let _ = receiver.recv(); |
| 116 | 153 | continue; |
| 117 | 154 | } |
| 118 | 155 | let now = Instant::now(); |
| 119 | let (path, replica, seen, connection) = { | |
| 156 | let next = { | |
| 120 | 157 | let Ok(mut watched) = owner.watched.lock() else { |
| 121 | 158 | return; |
| 122 | 159 | }; |
| 123 | 160 | if std::mem::take(&mut watched.lost) { |
| 124 | 161 | bound = None; |
| 125 | watched.rescan(now); | |
| 126 | } | |
| 127 | let due = watched.next().map(|index| &watched.sections[index]); | |
| 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; | |
| 162 | watched.watching(false); | |
| 163 | for watch in &mut watched.sections { | |
| 164 | watch.due = now; | |
| 147 | 165 | } |
| 148 | 166 | } |
| 167 | watched.next(now, bound.is_some()) | |
| 149 | 168 | }; |
| 150 | let (bind, interval) = match &mut bound { | |
| 151 | Some(bound) => bound, | |
| 152 | None => { | |
| 169 | let (path, replica, seen, current) = match next { | |
| 170 | Next::Wait(wait) => { | |
| 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 | 182 | let reports = Reports { |
| 154 | 183 | shared: weak.clone(), |
| 155 | 184 | connection: connection + 1, |
| ... | ... | @@ -162,31 +191,64 @@ impl Background { |
| 162 | 191 | watched.connection += 1; |
| 163 | 192 | watched.lost = false; |
| 164 | 193 | match connected { |
| 165 | // Changes while unwatched went unreported. | |
| 166 | Ok(connected) => { | |
| 167 | watched.rescan(now); | |
| 168 | bound = Some(connected); | |
| 194 | Ok((mut files, reported)) => { | |
| 195 | watched.watching(reported); | |
| 196 | let folders = watched.rescanning(); | |
| 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 | 205 | Err(error) => { |
| 171 | 206 | let error = Error::RemoteIo(error); |
| 207 | let retry = now + RETRY; | |
| 172 | 208 | for watch in &mut watched.sections { |
| 173 | 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 | 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) = | |
| 182 | step(&mut bind(&path), replica.as_deref(), seen.as_ref(), copies); | |
| 183 | let interval = *interval; | |
| 235 | let Some((bind, _)) = &mut bound else { | |
| 236 | continue; | |
| 237 | }; | |
| 238 | let (queued, outcome) = step( | |
| 239 | &mut bind(&path), | |
| 240 | replica.as_deref(), | |
| 241 | seen.as_deref(), | |
| 242 | current, | |
| 243 | copies, | |
| 244 | ); | |
| 184 | 245 | if outcome.as_ref().is_err_and(disconnected) { |
| 185 | 246 | bound = None; |
| 186 | 247 | } |
| 187 | 248 | let Ok(mut watched) = owner.watched.lock() else { |
| 188 | 249 | return; |
| 189 | 250 | }; |
| 251 | let interval = watched.interval(); | |
| 190 | 252 | let Watched { |
| 191 | 253 | sections, changed, .. |
| 192 | 254 | } = &mut *watched; |
| ... | ... | @@ -194,6 +256,7 @@ impl Background { |
| 194 | 256 | continue; |
| 195 | 257 | }; |
| 196 | 258 | let now = Instant::now(); |
| 259 | watch.current = false; | |
| 197 | 260 | news |= match outcome { |
| 198 | 261 | Ok((stamp, moved)) => { |
| 199 | 262 | watch.stamp = Some(stamp); |
| ... | ... | @@ -217,15 +280,16 @@ impl Background { |
| 217 | 280 | } |
| 218 | 281 | }; |
| 219 | 282 | } |
| 220 | if owner.discard.load(Ordering::Acquire) | |
| 221 | && let Ok(watched) = owner.watched.lock() | |
| 222 | { | |
| 223 | for replica in watched | |
| 224 | .sections | |
| 225 | .iter() | |
| 226 | .filter_map(|watch| watch.replica.as_ref()) | |
| 227 | { | |
| 228 | discard(replica); | |
| 283 | if let Ok(mut watched) = owner.watched.lock() { | |
| 284 | watched.watching(false); | |
| 285 | if owner.discard.load(Ordering::Acquire) { | |
| 286 | for replica in watched | |
| 287 | .sections | |
| 288 | .iter() | |
| 289 | .filter_map(|watch| watch.replica.as_ref()) | |
| 290 | { | |
| 291 | discard(replica); | |
| 292 | } | |
| 229 | 293 | } |
| 230 | 294 | } |
| 231 | 295 | })?; |
| ... | ... | @@ -248,39 +312,75 @@ impl Background { |
| 248 | 312 | true, |
| 249 | 313 | move |reports| { |
| 250 | 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 | 316 | Ok(paths) => reports.touched(&paths), |
| 253 | 317 | Err(_) => reports.lost(), |
| 254 | 318 | }) { |
| 255 | Ok(()) => Self::BACKSTOP, | |
| 256 | Err(error) if error.kind() == io::ErrorKind::Unsupported => Self::UNWATCHED, | |
| 319 | Ok(()) => true, | |
| 320 | Err(error) if error.kind() == io::ErrorKind::Unsupported => false, | |
| 257 | 321 | Err(error) => return Err(error), |
| 258 | 322 | }; |
| 259 | let root = root.clone(); | |
| 323 | let (bound, folder) = (Arc::clone(&client), root.clone()); | |
| 260 | 324 | let bind = move |path: &str| { |
| 261 | let file = match root.as_str() { | |
| 325 | let file = match folder.as_str() { | |
| 262 | 326 | "" => path.to_owned(), |
| 263 | 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 | 338 | notify, |
| 270 | 339 | ) |
| 271 | 340 | } |
| 272 | 341 | |
| 273 | /// Watches these sections, by catalog path and replica (`Notebook::replicas`); a section | |
| 274 | /// not watched before is first checked soon after, the next one a little later. | |
| 275 | pub fn watch(&self, sections: Vec<(String, Option<PathBuf>)>) { | |
| 342 | /// Watches these sections (`Notebook::replicas`); a section not watched before is first | |
| 343 | /// checked soon after, the next one a little later. | |
| 344 | pub fn watch(&self, sections: Vec<Known>) { | |
| 276 | 345 | if let Ok(mut watched) = self.0.watched.lock() { |
| 277 | 346 | watched.watch(sections, Instant::now()); |
| 278 | 347 | } |
| 279 | 348 | self.0.signal.wake(); |
| 280 | 349 | } |
| 281 | 350 | |
| 282 | /// Checks the sections at or below these catalog paths soon, as a watch on the | |
| 283 | /// notebook's folder reports them changed; `""` names every section. | |
| 351 | /// Leaves the section at catalog `path` to `section`'s worker, which from now on the watch | |
| 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 | 384 | pub fn touched(&self, paths: &[String]) { |
| 285 | 385 | self.0.touched(paths); |
| 286 | 386 | } |
| ... | ... | @@ -322,12 +422,13 @@ impl Background { |
| 322 | 422 | .unwrap_or_default() |
| 323 | 423 | } |
| 324 | 424 | |
| 325 | /// Checks every section now, working offline included (Sync Now). | |
| 425 | /// Reads every section's stamp now, working offline included (Sync Now). | |
| 326 | 426 | pub fn wake(&self) { |
| 327 | 427 | if let Ok(mut watched) = self.0.watched.lock() { |
| 328 | 428 | let now = Instant::now(); |
| 329 | 429 | for watch in &mut watched.sections { |
| 330 | 430 | watch.due = now; |
| 431 | watch.current = false; | |
| 331 | 432 | } |
| 332 | 433 | } |
| 333 | 434 | self.0.signal.requested.store(true, Ordering::Release); |
| ... | ... | @@ -343,7 +444,8 @@ impl Background { |
| 343 | 444 | self.0.signal.wake(); |
| 344 | 445 | } |
| 345 | 446 | |
| 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 | 449 | pub fn set_offline(&self, offline: bool) { |
| 348 | 450 | self.0.signal.offline.store(offline, Ordering::Release); |
| 349 | 451 | self.0.signal.wake(); |
| ... | ... | @@ -364,6 +466,18 @@ impl Shared { |
| 364 | 466 | } |
| 365 | 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 | } |
| 368 | 482 | |
| 369 | 483 | impl Reports { |
| ... | ... | @@ -387,30 +501,50 @@ impl Reports { |
| 387 | 501 | } |
| 388 | 502 | } |
| 389 | 503 | |
| 504 | /// What the background thread does next. | |
| 505 | enum 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 | ||
| 390 | 519 | impl Watched { |
| 391 | fn watch(&mut self, sections: Vec<(String, Option<PathBuf>)>, now: Instant) { | |
| 520 | fn watch(&mut self, sections: Vec<Known>, now: Instant) { | |
| 392 | 521 | let mut previous = std::mem::take(&mut self.sections); |
| 393 | 522 | let mut new = 0; |
| 394 | 523 | self.sections = sections |
| 395 | 524 | .into_iter() |
| 396 | 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 | 527 | Some(index) => Watch { |
| 399 | replica, | |
| 528 | replica: known.replica, | |
| 400 | 529 | ..previous.swap_remove(index) |
| 401 | 530 | }, |
| 402 | 531 | None => { |
| 403 | 532 | new += 1; |
| 533 | let (listed, stamp) = known.found.unzip(); | |
| 404 | 534 | Watch { |
| 405 | path, | |
| 406 | replica, | |
| 407 | stamp: None, | |
| 535 | path: known.path, | |
| 536 | replica: known.replica, | |
| 537 | stamp, | |
| 538 | listed, | |
| 539 | current: false, | |
| 540 | reported: false, | |
| 408 | 541 | status: SyncStatus { |
| 409 | 542 | synced: None, |
| 410 | 543 | error: None, |
| 411 | 544 | queued: 0, |
| 412 | 545 | }, |
| 413 | 546 | due: now + STAGGER * (new - 1), |
| 547 | held: None, | |
| 414 | 548 | } |
| 415 | 549 | } |
| 416 | 550 | }, |
| ... | ... | @@ -418,27 +552,181 @@ impl Watched { |
| 418 | 552 | .collect(); |
| 419 | 553 | } |
| 420 | 554 | |
| 421 | /// Checks every section from `now`, one `STAGGER` after another. | |
| 422 | fn rescan(&mut self, now: Instant) { | |
| 423 | for (index, watch) in (0..).zip(&mut self.sections) { | |
| 424 | watch.due = now + STAGGER * index; | |
| 555 | fn interval(&self) -> Duration { | |
| 556 | if self.reported { | |
| 557 | Background::BACKSTOP | |
| 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 | } |
| 427 | 576 | |
| 428 | 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 | 637 | for watch in &mut self.sections { |
| 430 | if paths.iter().any(|path| within(&watch.path, path)) { | |
| 431 | watch.due = watch.due.min(now + SETTLE); | |
| 638 | let (folder, name) = split(&watch.path); | |
| 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 | } |
| 435 | 674 | |
| 436 | /// The section checked next. | |
| 437 | fn next(&self) -> Option<usize> { | |
| 438 | (0..self.sections.len()).min_by_key(|&index| self.sections[index].due) | |
| 675 | /// What to do at `now`, waking the held sections that are due; `bound` while connected. | |
| 676 | fn next(&mut self, now: Instant, bound: bool) -> Next { | |
| 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 | } |
| 441 | 714 | |
| 715 | /// Lists each of `folders` through `list`; a folder that cannot be listed lists nothing, so | |
| 716 | /// that its sections are checked. | |
| 717 | fn 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 | 730 | /// Deletes the replica at `replica` if it holds nothing unpublished and no one holds it. |
| 443 | 731 | fn discard(replica: &Path) { |
| 444 | 732 | if matches!(crate::peek(replica), Ok((_, 0))) { |
| ... | ... | @@ -460,6 +748,11 @@ fn within(section: &str, path: &str) -> bool { |
| 460 | 748 | && matches!(section.as_bytes().get(path.len()), None | Some(b'/')) |
| 461 | 749 | } |
| 462 | 750 | |
| 751 | /// A catalog path's folder and name. | |
| 752 | fn split(path: &str) -> (&str, &str) { | |
| 753 | path.rsplit_once('/').unwrap_or(("", path)) | |
| 754 | } | |
| 755 | ||
| 463 | 756 | /// Whether a failed step lost the connection, rather than failing for its one file. |
| 464 | 757 | fn disconnected(error: &Error) -> bool { |
| 465 | 758 | use io::ErrorKind::*; |
| ... | ... | @@ -495,17 +788,22 @@ fn summary(status: &SyncStatus) -> (bool, Option<io::ErrorKind>, u64) { |
| 495 | 788 | } |
| 496 | 789 | |
| 497 | 790 | /// 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`. With | |
| 499 | /// `copies`, a section without a replica gets one from the file as it is now. | |
| 791 | /// holds its replica), then the file's stamp now and whether it changed since `seen`, which | |
| 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. | |
| 500 | 794 | fn step<R: Remote>( |
| 501 | 795 | remote: &mut R, |
| 502 | 796 | replica: Option<&Path>, |
| 503 | 797 | seen: Option<&Stamp>, |
| 798 | current: bool, | |
| 504 | 799 | copies: bool, |
| 505 | 800 | ) -> (Option<u64>, Result<(Stamp, bool)>) { |
| 506 | let stamp = match remote.stamp() { | |
| 507 | Ok(stamp) => stamp, | |
| 508 | Err(error) => return (None, Err(Error::RemoteIo(error))), | |
| 801 | let stamp = match seen.filter(|_| current) { | |
| 802 | Some(seen) => seen.clone(), | |
| 803 | None => match remote.stamp() { | |
| 804 | Ok(stamp) => stamp, | |
| 805 | Err(error) => return (None, Err(Error::RemoteIo(error))), | |
| 806 | }, | |
| 509 | 807 | }; |
| 510 | 808 | let moved = seen.is_some_and(|seen| *seen != stamp); |
| 511 | 809 | let Some(replica) = replica else { |
| ... | ... | @@ -562,6 +860,7 @@ fn step<R: Remote>( |
| 562 | 860 | #[cfg(test)] |
| 563 | 861 | mod tests { |
| 564 | 862 | use super::*; |
| 863 | use crate::discover::EntryKind; | |
| 565 | 864 | const BACKSTOP: Duration = Background::BACKSTOP; |
| 566 | 865 | use onestore::{CommitError, Transaction}; |
| 567 | 866 | use std::{ |
| ... | ... | @@ -569,37 +868,120 @@ mod tests { |
| 569 | 868 | sync::mpsc::{self, Receiver}, |
| 570 | 869 | }; |
| 571 | 870 | |
| 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 | 892 | (0..count) |
| 574 | .map(|n| { | |
| 575 | let folder = if n % 2 == 0 { "" } else { "Group/" }; | |
| 576 | (format!("{folder}Section {n:03}.one"), None) | |
| 893 | .map(|n| Known { | |
| 894 | path: path(n), | |
| 895 | replica: None, | |
| 896 | found: warm.then(|| (listed(n), stamp(n))), | |
| 577 | 897 | }) |
| 578 | 898 | .collect() |
| 579 | 899 | } |
| 580 | 900 | |
| 581 | /// Runs the schedule as the thread would with every check succeeding, up to `end`, | |
| 582 | /// answering which sections were checked when. | |
| 583 | fn run(watched: &mut Watched, end: Instant, interval: Duration) -> Vec<(Instant, String)> { | |
| 901 | /// The notebook's folders as a listing shows its first `count` sections. | |
| 902 | fn listing(count: usize) -> BTreeMap<String, Vec<Entry>> { | |
| 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 | 943 | let mut checks = Vec::new(); |
| 585 | while let Some(index) = watched.next() { | |
| 586 | let watch = &mut watched.sections[index]; | |
| 587 | if watch.due > end { | |
| 588 | break; | |
| 944 | loop { | |
| 945 | match watched.next(now, true) { | |
| 946 | Next::Wait(Some(wait)) if now + wait <= end => now += wait, | |
| 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 | } |
| 595 | 970 | |
| 596 | 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 | 973 | let start = Instant::now(); |
| 599 | 974 | let mut watched = Watched::default(); |
| 600 | watched.watch(sections(200), start); | |
| 601 | let checks = run(&mut watched, start + BACKSTOP - SETTLE, BACKSTOP); | |
| 975 | watched.watch(sections(200, false), start); | |
| 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 | 983 | assert_eq!(checks.len(), 200, "one first check each, then none"); |
| 984 | assert!(checks.iter().all(|(.., read)| *read)); | |
| 603 | 985 | for pair in checks.windows(2) { |
| 604 | 986 | assert!(pair[1].0 - pair[0].0 >= STAGGER, "never a burst"); |
| 605 | 987 | } |
| ... | ... | @@ -607,19 +989,59 @@ mod tests { |
| 607 | 989 | // where a 15-second poll made 5,760. |
| 608 | 990 | let day = run( |
| 609 | 991 | &mut watched, |
| 992 | start + BACKSTOP - SETTLE, | |
| 610 | 993 | start + Duration::from_secs(86_400) - SETTLE, |
| 611 | BACKSTOP, | |
| 994 | &listing(200), | |
| 612 | 995 | ); |
| 613 | 996 | assert_eq!(day.len(), 200 * (86_400 / BACKSTOP.as_secs() as usize - 1)); |
| 614 | 997 | } |
| 615 | 998 | |
| 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 | 1036 | #[test] |
| 617 | 1037 | fn a_reported_change_checks_exactly_the_sections_it_names() { |
| 618 | 1038 | let start = Instant::now(); |
| 619 | 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 | 1043 | let now = start + Duration::from_secs(60); |
| 622 | run(&mut watched, now, BACKSTOP); | |
| 1044 | run(&mut watched, start, now, &listings); | |
| 623 | 1045 | // A commit's several writes are reported apiece, and settle into one check. |
| 624 | 1046 | for _ in 0..3 { |
| 625 | 1047 | watched.touched(&["section 004.ONE".into()], now); |
| ... | ... | @@ -628,31 +1050,81 @@ mod tests { |
| 628 | 1050 | &["Section 004.one".into()], |
| 629 | 1051 | now + Duration::from_millis(300), |
| 630 | 1052 | ); |
| 631 | let checks = run(&mut watched, now + Duration::from_secs(60), BACKSTOP); | |
| 632 | assert_eq!(checks, vec![(now + SETTLE, "Section 004.one".to_owned())]); | |
| 633 | // A group's folder names the sections in it, and "" every section. | |
| 1053 | let checks = run(&mut watched, now, now + Duration::from_secs(60), &listings); | |
| 1054 | assert_eq!( | |
| 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 | 1060 | let now = now + Duration::from_secs(60); |
| 1061 | listings.get_mut("Group").unwrap()[2].listed.size += 1; | |
| 635 | 1062 | watched.touched(&["Group".into()], now); |
| 636 | let group = run(&mut watched, now + SETTLE, BACKSTOP); | |
| 637 | assert_eq!(group.len(), 100); | |
| 638 | assert!(group.iter().all(|(_, path)| path.starts_with("Group/"))); | |
| 639 | watched.touched(&["Grou".into(), String::new()], now); | |
| 640 | assert_eq!(run(&mut watched, now + SETTLE, BACKSTOP).len(), 200); | |
| 1063 | let group = run(&mut watched, now, now + SETTLE * 2, &listings); | |
| 1064 | assert_eq!( | |
| 1065 | group, | |
| 1066 | vec![(now + SETTLE, "Group/Section 005.one".to_owned(), true)] | |
| 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 | } |
| 642 | 1072 | |
| 1073 | /// Each file's image and how many times it was written, by path. | |
| 1074 | type Images = HashMap<String, (Vec<u8>, u64)>; | |
| 1075 | ||
| 643 | 1076 | /// Section files in memory, each stamp read reported as it happens. |
| 644 | 1077 | #[derive(Clone)] |
| 645 | 1078 | struct Files { |
| 646 | images: Arc<Mutex<HashMap<String, Vec<u8>>>>, | |
| 1079 | images: Arc<Mutex<Images>>, | |
| 647 | 1080 | stamps: mpsc::Sender<String>, |
| 648 | 1081 | } |
| 649 | 1082 | |
| 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 | 1122 | struct File(Files, String); |
| 651 | 1123 | |
| 652 | 1124 | impl Remote for File { |
| 653 | 1125 | fn read(&mut self) -> io::Result<Vec<u8>> { |
| 654 | 1126 | let images = self.0.images.lock().unwrap(); |
| 655 | Ok(images[&self.1].clone()) | |
| 1127 | Ok(images[&self.1].0.clone()) | |
| 656 | 1128 | } |
| 657 | 1129 | |
| 658 | 1130 | fn stamp(&mut self) -> io::Result<Stamp> { |
| ... | ... | @@ -678,17 +1150,19 @@ mod tests { |
| 678 | 1150 | } |
| 679 | 1151 | |
| 680 | 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 | 1154 | let (sender, stamps) = mpsc::channel(); |
| 683 | 1155 | let files = Files { |
| 684 | 1156 | images: Arc::default(), |
| 685 | 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 | 1160 | paths.sort(); |
| 689 | 1161 | for path in &paths { |
| 690 | let image = onestore::create_section("s.one", path, "Author").unwrap(); | |
| 691 | files.images.lock().unwrap().insert(path.clone(), image); | |
| 1162 | files.write( | |
| 1163 | path, | |
| 1164 | onestore::create_section("s.one", path, "Author").unwrap(), | |
| 1165 | ); | |
| 692 | 1166 | } |
| 693 | 1167 | let (armed, watches) = mpsc::channel(); |
| 694 | 1168 | let remote = files.clone(); |
| ... | ... | @@ -696,38 +1170,113 @@ mod tests { |
| 696 | 1170 | false, |
| 697 | 1171 | move |reports| { |
| 698 | 1172 | armed.send(reports).unwrap(); |
| 699 | let files = remote.clone(); | |
| 700 | Ok(( | |
| 701 | move |path: &str| File(files.clone(), path.to_owned()), | |
| 702 | Duration::from_secs(3600), | |
| 703 | )) | |
| 1173 | let (files, listed) = (remote.clone(), remote.clone()); | |
| 1174 | let bind = move |path: &str| File(files.clone(), path.to_owned()); | |
| 1175 | let list = move |folder: &str| Ok(listed.list(folder)); | |
| 1176 | Ok(((bind, list), true)) | |
| 704 | 1177 | }, |
| 705 | 1178 | || {}, |
| 706 | 1179 | ) |
| 707 | 1180 | .unwrap(); |
| 708 | background.watch(sections(6)); | |
| 709 | let mut first = quiet(&stamps, STAGGER * 4); | |
| 710 | first.sort(); | |
| 711 | assert_eq!(first, paths, "each section's first check"); | |
| 1181 | // A section the catalog read unchanged is not read again; a new one is. | |
| 1182 | let mut known: Vec<Known> = paths[1..].iter().map(|path| files.known(path)).collect(); | |
| 1183 | known.push(Known { | |
| 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 | 1190 | let watch = watches.recv().unwrap(); |
| 713 | 1191 | assert!(quiet(&stamps, SETTLE).is_empty(), "idle reads nothing"); |
| 714 | 1192 | |
| 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 | 1203 | let changed = &paths[3]; |
| 716 | files.images.lock().unwrap().insert( | |
| 717 | changed.clone(), | |
| 1204 | files.write( | |
| 1205 | changed, | |
| 718 | 1206 | onestore::create_section("s.one", "Changed", "Author").unwrap(), |
| 719 | 1207 | ); |
| 720 | 1208 | watch.touched(std::slice::from_ref(changed)); |
| 721 | 1209 | assert_eq!(quiet(&stamps, SETTLE * 2), vec![changed.clone()]); |
| 722 | 1210 | assert_eq!(background.changed(), vec![changed.clone()]); |
| 723 | 1211 | |
| 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 | 1219 | watch.lost(); |
| 726 | 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 | 1224 | // The ended watch's connection is gone; only the current one's loss counts. |
| 729 | 1225 | watch.lost(); |
| 730 | 1226 | assert!(quiet(&stamps, SETTLE).is_empty()); |
| 731 | 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 | 1 | //! Read-only notebook discovery over a caller-supplied root. |
| 2 | 2 | |
| 3 | 3 | use onestore::{ |
| 4 | FileType, RevisionIndex, Store, | |
| 4 | FileType, RevisionIndex, Stamp, Store, | |
| 5 | 5 | document::{Document, Kind}, |
| 6 | 6 | }; |
| 7 | use serde::Serialize; | |
| 7 | use serde::{Deserialize, Serialize}; | |
| 8 | 8 | use std::{ |
| 9 | 9 | collections::{BTreeMap, BTreeSet}, |
| 10 | 10 | io, |
| ... | ... | @@ -64,7 +64,7 @@ pub struct Toc { |
| 64 | 64 | pub unresolved: Vec<TocReference>, |
| 65 | 65 | } |
| 66 | 66 | |
| 67 | #[derive(Debug, Serialize)] | |
| 67 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 68 | 68 | pub struct TocReference { |
| 69 | 69 | /// Native TOCs can retain an identity after removing its cached filename. |
| 70 | 70 | pub filename: Option<String>, |
| ... | ... | @@ -83,6 +83,86 @@ pub enum EntryKind { |
| 83 | 83 | pub struct Entry { |
| 84 | 84 | pub name: String, |
| 85 | 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)] | |
| 91 | pub 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)] | |
| 100 | pub struct Cache(BTreeMap<String, Read>); | |
| 101 | ||
| 102 | /// A notebook file as discovery read it. | |
| 103 | #[derive(Clone, Serialize, Deserialize)] | |
| 104 | struct 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)] | |
| 114 | enum Held { | |
| 115 | Section { | |
| 116 | name: Option<String>, | |
| 117 | color: Option<u32>, | |
| 118 | document: [u8; 16], | |
| 119 | }, | |
| 120 | Locked, | |
| 121 | Toc(Vec<TocReference>), | |
| 122 | } | |
| 123 | ||
| 124 | impl 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 | ||
| 151 | impl 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 | } |
| 87 | 167 | |
| 88 | 168 | /// Rooted file access. Paths are relative, UTF-8, and use `/` between components. |
| ... | ... | @@ -95,6 +175,11 @@ pub trait Source { |
| 95 | 175 | fn read_asset(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> { |
| 96 | 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 | } |
| 99 | 184 | |
| 100 | 185 | /// Reads an external file-data reference from the section's sibling `_onefiles` folder. |
| ... | ... | @@ -170,9 +255,7 @@ pub enum Error { |
| 170 | 255 | /// Discovers rooted notebook topology within caller-specified work and size limits. |
| 171 | 256 | /// Reserved `_onefiles` directories contain payloads, not section groups. |
| 172 | 257 | pub fn discover(source: &mut impl Source, limits: Limits) -> Result<Folder, Error> { |
| 173 | let mut remaining = limits.entries; | |
| 174 | let mut identities = BTreeMap::new(); | |
| 175 | scan(source, "", &limits, 0, &mut remaining, &mut identities) | |
| 258 | Cache::default().discover(source, limits) | |
| 176 | 259 | } |
| 177 | 260 | |
| 178 | 261 | fn scan( |
| ... | ... | @@ -182,6 +265,7 @@ fn scan( |
| 182 | 265 | depth: usize, |
| 183 | 266 | remaining: &mut usize, |
| 184 | 267 | identities: &mut BTreeMap<[u8; 16], String>, |
| 268 | (cached, read): (&BTreeMap<String, Read>, &mut BTreeMap<String, Read>), | |
| 185 | 269 | ) -> Result<Folder, Error> { |
| 186 | 270 | if depth > limits.depth { |
| 187 | 271 | return Err(Error::Limit { path: path.into() }); |
| ... | ... | @@ -218,7 +302,15 @@ fn scan( |
| 218 | 302 | continue; |
| 219 | 303 | } |
| 220 | 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 | 314 | Ok(group) => result.groups.push(group), |
| 223 | 315 | Err(error @ Error::Io { .. }) if unavailable(&error) => { |
| 224 | 316 | result.unavailable.push(Unavailable { |
| ... | ... | @@ -244,134 +336,102 @@ fn scan( |
| 244 | 336 | { |
| 245 | 337 | return Err(Error::Entry { path: child }); |
| 246 | 338 | } |
| 247 | let bytes = match source.read(&child, limits.bytes_per_file) { | |
| 248 | Ok(bytes) => bytes, | |
| 249 | Err(error) => { | |
| 250 | let error = Error::Io { | |
| 339 | let reused = cached | |
| 340 | .get(&child) | |
| 341 | .filter(|known| known.listed == entry.listed) | |
| 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 | 376 | path: child.clone(), |
| 252 | 377 | error, |
| 253 | }; | |
| 254 | if expected == FileType::Section && unavailable(&error) { | |
| 255 | result.unavailable.push(Unavailable { | |
| 378 | })?; | |
| 379 | if store.header.file_type != expected || !store.checksum_mismatches.is_empty() { | |
| 380 | return Err(Error::Document { | |
| 256 | 381 | path: child, |
| 257 | group: false, | |
| 258 | error: error.to_string(), | |
| 382 | error: onestore::Error { | |
| 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 { | |
| 266 | return Err(Error::Limit { path: child }); | |
| 267 | } | |
| 268 | let store = Store::parse(&bytes).map_err(|error| Error::Document { | |
| 269 | path: child.clone(), | |
| 270 | error, | |
| 271 | })?; | |
| 272 | if store.header.file_type != expected || !store.checksum_mismatches.is_empty() { | |
| 273 | return Err(Error::Document { | |
| 274 | path: child, | |
| 275 | error: onestore::Error { | |
| 276 | offset: 0, | |
| 277 | message: "Unexpected file type or checksum mismatch", | |
| 403 | match held { | |
| 404 | Ok(Held::Section { | |
| 405 | name, | |
| 406 | color, | |
| 407 | document, | |
| 408 | }) => result.sections.push(Section { | |
| 409 | path: child.clone(), | |
| 410 | file_id, | |
| 411 | state: SectionState::Readable { | |
| 412 | name, | |
| 413 | color, | |
| 414 | document, | |
| 278 | 415 | }, |
| 279 | }); | |
| 280 | } | |
| 281 | let file_id = store.header.file_id; | |
| 282 | let parsed = (|| { | |
| 283 | let index = RevisionIndex::parse(&store)?; | |
| 284 | let document = Document::parse(&index)?; | |
| 285 | let revision = document.active(document.root)?; | |
| 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 | } | |
| 416 | }), | |
| 417 | Ok(Held::Locked) => result.sections.push(Section { | |
| 418 | path: child.clone(), | |
| 419 | file_id, | |
| 420 | state: SectionState::Locked, | |
| 421 | }), | |
| 422 | Ok(Held::Toc(unresolved)) => { | |
| 357 | 423 | result.toc = Some(Toc { |
| 358 | 424 | filename: entry.name.clone(), |
| 359 | 425 | file_id, |
| 360 | 426 | unresolved, |
| 361 | }); | |
| 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 }); | |
| 427 | }) | |
| 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 | 436 | if let Some(first) = identities.insert(file_id, child.clone()) { |
| 377 | 437 | return Err(Error::DuplicateIdentity { |
| ... | ... | @@ -429,12 +489,83 @@ fn scan( |
| 429 | 489 | error, |
| 430 | 490 | })?; |
| 431 | 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 | 500 | return Err(Error::Changed { path: path.into() }); |
| 434 | 501 | } |
| 435 | 502 | Ok(result) |
| 436 | 503 | } |
| 437 | 504 | |
| 505 | /// What discovery takes from the file `store` holds, a section or a TOC as `expected`. | |
| 506 | fn 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 | 569 | /// Access denied or a file gone mid-listing; a lost connection stays fatal. |
| 439 | 570 | fn unavailable(error: &Error) -> bool { |
| 440 | 571 | matches!(error, Error::Io { error, .. } if matches!( |
crates/notebook/src/discover/source.rs+38-2| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | use super::{Entry, EntryKind, Source, component}; | |
| 1 | use super::{Entry, EntryKind, Listed, Source, component}; | |
| 2 | 2 | use std::{ |
| 3 | 3 | io, |
| 4 | 4 | path::{Path, PathBuf}, |
| ... | ... | @@ -43,6 +43,7 @@ impl Source for Local { |
| 43 | 43 | .into_string() |
| 44 | 44 | .map_err(|_| io::ErrorKind::InvalidData)?; |
| 45 | 45 | let kind = entry.file_type()?; |
| 46 | let metadata = entry.metadata()?; | |
| 46 | 47 | entries.push(Entry { |
| 47 | 48 | name, |
| 48 | 49 | kind: if kind.is_dir() { |
| ... | ... | @@ -52,6 +53,13 @@ impl Source for Local { |
| 52 | 53 | } else { |
| 53 | 54 | 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 | 65 | Ok(entries) |
| ... | ... | @@ -67,6 +75,8 @@ impl Source for Local { |
| 67 | 75 | pub struct Smb<'a> { |
| 68 | 76 | client: &'a crate::smb::Client, |
| 69 | 77 | root: String, |
| 78 | /// Where the sections' replicas are, named by file identity. | |
| 79 | copies: Option<PathBuf>, | |
| 70 | 80 | } |
| 71 | 81 | |
| 72 | 82 | #[cfg(feature = "smb")] |
| ... | ... | @@ -76,7 +86,19 @@ impl<'a> Smb<'a> { |
| 76 | 86 | if !root.is_empty() && !root.split('/').all(component) { |
| 77 | 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 | } |
| 81 | 103 | |
| 82 | 104 | fn path(&self, relative: &str) -> io::Result<String> { |
| ... | ... | @@ -107,6 +129,10 @@ impl Source for Smb<'_> { |
| 107 | 129 | } else { |
| 108 | 130 | EntryKind::File |
| 109 | 131 | }, |
| 132 | listed: Listed { | |
| 133 | size: entry.size, | |
| 134 | modified: entry.modified, | |
| 135 | }, | |
| 110 | 136 | }) |
| 111 | 137 | .collect()) |
| 112 | 138 | } |
| ... | ... | @@ -118,4 +144,14 @@ impl Source for Smb<'_> { |
| 118 | 144 | fn read_asset(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> { |
| 119 | 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 | 99 | worker: Arc<Mutex<std::sync::Weak<worker::Signal>>>, |
| 100 | 100 | /// The section's root object space, which names the document. |
| 101 | 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)] | |
| 108 | struct Released(Mutex<Option<Box<dyn FnOnce() + Send>>>); | |
| 109 | ||
| 110 | impl 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 | } |
| 103 | 117 | |
| 104 | 118 | impl Replica { |
| ... | ... | @@ -210,6 +224,7 @@ impl Replica { |
| 210 | 224 | synchronization: Mutex::new(()), |
| 211 | 225 | worker, |
| 212 | 226 | root, |
| 227 | released: Released::default(), | |
| 213 | 228 | }) |
| 214 | 229 | } |
| 215 | 230 | |
| ... | ... | @@ -340,8 +355,8 @@ fn validate(source: &[u8]) -> Result<ExGuid> { |
| 340 | 355 | Ok(onestore::Section::open(&arena, source.to_vec())?.root()) |
| 341 | 356 | } |
| 342 | 357 | |
| 343 | /// A closed cache's base stamp and how many edits wait, without opening the replica. | |
| 344 | fn peek(path: &Path) -> Result<(onestore::Stamp, u64)> { | |
| 358 | /// A closed cache, without opening the replica. | |
| 359 | fn closed(path: &Path) -> Result<Connection> { | |
| 345 | 360 | let connection = cache_connection(path)?; |
| 346 | 361 | let application: u32 = |
| 347 | 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 | 364 | if application != APPLICATION_ID || version != schema::VERSION { |
| 350 | 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. | |
| 371 | fn peek(path: &Path) -> Result<(onestore::Stamp, u64)> { | |
| 372 | let connection = closed(path)?; | |
| 352 | 373 | let base = base::stamp(&connection, base::Image::Base)? |
| 353 | 374 | .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image"))?; |
| 354 | 375 | let queued: i64 = connection.query_row("SELECT count(*) FROM edits", [], |row| row.get(0))?; |
| 355 | 376 | Ok((base, unsigned(queued)?)) |
| 356 | 377 | } |
| 357 | 378 | |
| 379 | /// A closed cache's base image, if its stamp is `stamp`: the section file as it stands. | |
| 380 | fn 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 | ||
| 358 | 388 | fn pending(connection: &Connection) -> Result<Vec<PendingEdit>> { |
| 359 | 389 | queue::load(connection, None) |
| 360 | 390 | } |
crates/notebook/src/session.rs+100-31| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | //! The application's view of a notebook: sections opened through a local replica that |
| 2 | 2 | //! publishes their edits to the section file in the background. |
| 3 | 3 | |
| 4 | pub use crate::background::Background; | |
| 4 | pub use crate::background::{Background, Known}; | |
| 5 | 5 | use crate::{ |
| 6 | 6 | EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover, |
| 7 | 7 | }; |
| ... | ... | @@ -72,7 +72,14 @@ fn entries(folder: &discover::Folder) -> Entries { |
| 72 | 72 | /// Where a notebook's files live: a mounted directory or an SMB share. Paths are catalog |
| 73 | 73 | /// paths, `/`-separated and relative to the notebook root. |
| 74 | 74 | pub 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 | 83 | fn exists(&self, path: &str) -> bool; |
| 77 | 84 | fn read(&self, path: &str) -> Result<Vec<u8>>; |
| 78 | 85 | /// Creates a file holding `bytes`; an existing file is an error. |
| ... | ... | @@ -110,11 +117,16 @@ impl Directory { |
| 110 | 117 | } |
| 111 | 118 | |
| 112 | 119 | impl Storage for Directory { |
| 113 | fn discover(&self, limits: discover::Limits) -> Result<discover::Folder> { | |
| 114 | Ok(discover::discover( | |
| 115 | &mut discover::Local::open(&self.0)?, | |
| 116 | limits, | |
| 117 | )?) | |
| 120 | fn discover( | |
| 121 | &self, | |
| 122 | cache: &mut discover::Cache, | |
| 123 | limits: discover::Limits, | |
| 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 | } |
| 119 | 131 | |
| 120 | 132 | fn exists(&self, path: &str) -> bool { |
| ... | ... | @@ -162,6 +174,8 @@ impl Storage for Directory { |
| 162 | 174 | pub struct Share { |
| 163 | 175 | client: Arc<crate::smb::Client>, |
| 164 | 176 | root: String, |
| 177 | /// Where the sections' replicas are. | |
| 178 | copies: PathBuf, | |
| 165 | 179 | } |
| 166 | 180 | |
| 167 | 181 | #[cfg(feature = "smb")] |
| ... | ... | @@ -177,11 +191,17 @@ impl Share { |
| 177 | 191 | |
| 178 | 192 | #[cfg(feature = "smb")] |
| 179 | 193 | impl Storage for Share { |
| 180 | fn discover(&self, limits: discover::Limits) -> Result<discover::Folder> { | |
| 181 | Ok(discover::discover( | |
| 182 | &mut discover::Smb::new(&self.client, &self.root)?, | |
| 183 | limits, | |
| 184 | )?) | |
| 194 | fn discover( | |
| 195 | &self, | |
| 196 | cache: &mut discover::Cache, | |
| 197 | limits: discover::Limits, | |
| 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 | } |
| 186 | 206 | |
| 187 | 207 | fn exists(&self, path: &str) -> bool { |
| ... | ... | @@ -240,6 +260,9 @@ pub struct Notebook { |
| 240 | 260 | root: Option<PathBuf>, |
| 241 | 261 | cache: PathBuf, |
| 242 | 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 | } |
| 244 | 267 | |
| 245 | 268 | impl Notebook { |
| ... | ... | @@ -274,7 +297,16 @@ impl Notebook { |
| 274 | 297 | cache: impl AsRef<Path>, |
| 275 | 298 | ) -> Result<Self> { |
| 276 | 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 | } |
| 279 | 311 | |
| 280 | 312 | fn with( |
| ... | ... | @@ -283,14 +315,40 @@ impl Notebook { |
| 283 | 315 | cache: impl AsRef<Path>, |
| 284 | 316 | ) -> Result<Self> { |
| 285 | 317 | let cache = cache.as_ref().to_path_buf(); |
| 286 | std::fs::create_dir_all(&cache)?; | |
| 287 | let catalog = storage.discover(LIMITS)?; | |
| 288 | Ok(Self { | |
| 318 | let listings = cache.join("listings"); | |
| 319 | std::fs::create_dir_all(&listings)?; | |
| 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 | 331 | storage, |
| 290 | 332 | root, |
| 291 | 333 | cache, |
| 292 | 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 | } |
| 295 | 353 | |
| 296 | 354 | pub fn catalog(&self) -> &discover::Folder { |
| ... | ... | @@ -301,7 +359,8 @@ impl Notebook { |
| 301 | 359 | /// file identity so a renamed or moved section stays the same section. A failed read |
| 302 | 360 | /// keeps the previous catalog: an unreachable notebook is not an empty one. |
| 303 | 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 | 364 | let (before, before_orders) = entries(&self.catalog); |
| 306 | 365 | let (after, after_orders) = entries(&catalog); |
| 307 | 366 | let mut changes = Vec::new(); |
| ... | ... | @@ -900,15 +959,18 @@ impl Notebook { |
| 900 | 959 | }) |
| 901 | 960 | } |
| 902 | 961 | |
| 903 | /// Every readable section's catalog path and replica, for `Background::watch`. | |
| 904 | pub fn replicas(&self) -> Vec<(String, Option<PathBuf>)> { | |
| 962 | /// Every readable section, for `Background::watch`. | |
| 963 | pub fn replicas(&self) -> Vec<Known> { | |
| 905 | 964 | let mut sections = Vec::new(); |
| 906 | 965 | let mut folders = vec![&self.catalog]; |
| 907 | 966 | while let Some(folder) = folders.pop() { |
| 908 | 967 | for section in &folder.sections { |
| 909 | 968 | if matches!(section.state, discover::SectionState::Readable { .. }) { |
| 910 | let replica = self.replica_path(&section.path).ok(); | |
| 911 | sections.push((section.path.clone(), replica)); | |
| 969 | sections.push(Known { | |
| 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 | 976 | folders.extend(folder.groups.iter().rev()); |
| ... | ... | @@ -917,23 +979,24 @@ impl Notebook { |
| 917 | 979 | } |
| 918 | 980 | |
| 919 | 981 | /// Keeps the sections of a mounted notebook in sync while they are not open |
| 920 | /// (`Background`): each file is checked every `interval`, and sooner when | |
| 921 | /// `Background::touched` reports it changed. `copies` keeps an offline copy of every | |
| 922 | /// section, for a folder that is not on this computer. | |
| 982 | /// (`Background`): a file is checked when `Background::touched` reports it changed, and | |
| 983 | /// otherwise every `Background::BACKSTOP` when `watched`, as a host that watches the | |
| 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 | 986 | pub fn background( |
| 924 | 987 | &self, |
| 925 | interval: Duration, | |
| 988 | watched: bool, | |
| 926 | 989 | copies: bool, |
| 927 | 990 | notify: impl Fn() + Send + 'static, |
| 928 | 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 | } |
| 931 | 994 | |
| 932 | 995 | /// `background`, reaching each section file through the remote `remote` makes for it |
| 933 | 996 | /// (`section_with`). |
| 934 | 997 | pub fn background_with<R: Remote + 'static>( |
| 935 | 998 | &self, |
| 936 | interval: Duration, | |
| 999 | watched: bool, | |
| 937 | 1000 | copies: bool, |
| 938 | 1001 | remote: impl Fn(&Path) -> R + Clone + Send + 'static, |
| 939 | 1002 | notify: impl Fn() + Send + 'static, |
| ... | ... | @@ -948,8 +1011,14 @@ impl Notebook { |
| 948 | 1011 | Background::start( |
| 949 | 1012 | copies, |
| 950 | 1013 | move |_| { |
| 951 | let (root, remote) = (root.clone(), remote.clone()); | |
| 952 | Ok((move |path: &str| remote(&root.join(path)), interval)) | |
| 1014 | let (folder, remote) = (root.clone(), remote.clone()); | |
| 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 | 1023 | notify, |
| 955 | 1024 | ) |
| ... | ... | @@ -1085,7 +1154,7 @@ fn catalog_path(folder: &str, name: &str) -> String { |
| 1085 | 1154 | } |
| 1086 | 1155 | |
| 1087 | 1156 | /// The replica in `cache` of the section `identity` names. |
| 1088 | fn replica_file(cache: &Path, identity: &[u8; 16]) -> PathBuf { | |
| 1157 | pub(crate) fn replica_file(cache: &Path, identity: &[u8; 16]) -> PathBuf { | |
| 1089 | 1158 | let name: String = identity.iter().map(|byte| format!("{byte:02x}")).collect(); |
| 1090 | 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 | 9 | pub struct DirectoryEntry { |
| 10 | 10 | pub name: String, |
| 11 | 11 | pub size: u64, |
| 12 | /// LastWriteTime, FILETIME. | |
| 13 | pub modified: u64, | |
| 12 | 14 | /// MS-FSCC file attributes; directory is 0x10 and reparse point is 0x400. |
| 13 | 15 | pub attributes: u32, |
| 14 | 16 | } |
| ... | ... | @@ -71,7 +73,7 @@ impl Client { |
| 71 | 73 | }; |
| 72 | 74 | for entry in decode(&response.output_buffer)? { |
| 73 | 75 | if entries |
| 74 | .insert(entry.name, (entry.size, entry.attributes)) | |
| 76 | .insert(entry.name, (entry.size, entry.modified, entry.attributes)) | |
| 75 | 77 | .is_some() |
| 76 | 78 | { |
| 77 | 79 | return Err(io::ErrorKind::ResourceBusy.into()); |
| ... | ... | @@ -89,9 +91,10 @@ impl Client { |
| 89 | 91 | Ok(entries |
| 90 | 92 | .into_iter() |
| 91 | 93 | .filter(|(name, _)| name != "." && name != "..") |
| 92 | .map(|(name, (size, attributes))| DirectoryEntry { | |
| 94 | .map(|(name, (size, modified, attributes))| DirectoryEntry { | |
| 93 | 95 | name, |
| 94 | 96 | size, |
| 97 | modified, | |
| 95 | 98 | attributes, |
| 96 | 99 | }) |
| 97 | 100 | .collect()) |
| ... | ... | @@ -199,6 +202,7 @@ fn decode(mut bytes: &[u8]) -> io::Result<Vec<DirectoryEntry>> { |
| 199 | 202 | entries.push(DirectoryEntry { |
| 200 | 203 | name, |
| 201 | 204 | size: size.try_into().map_err(|_| io::ErrorKind::InvalidData)?, |
| 205 | modified: u64::from_le_bytes(bytes[24..32].try_into().unwrap()), | |
| 202 | 206 | attributes: u32::from_le_bytes(bytes[56..60].try_into().unwrap()), |
| 203 | 207 | }); |
| 204 | 208 | if next == 0 { |
| ... | ... | @@ -214,6 +218,7 @@ mod tests { |
| 214 | 218 | |
| 215 | 219 | fn record(name: &str, size: u64, attributes: u32) -> Vec<u8> { |
| 216 | 220 | let mut bytes = vec![0; 64]; |
| 221 | bytes[24..32].copy_from_slice(&(size + 7).to_le_bytes()); | |
| 217 | 222 | bytes[40..48].copy_from_slice(&size.to_le_bytes()); |
| 218 | 223 | bytes[56..60].copy_from_slice(&attributes.to_le_bytes()); |
| 219 | 224 | let name: Vec<_> = name.encode_utf16().flat_map(u16::to_le_bytes).collect(); |
| ... | ... | @@ -240,6 +245,7 @@ mod tests { |
| 240 | 245 | expected.push(DirectoryEntry { |
| 241 | 246 | name, |
| 242 | 247 | size, |
| 248 | modified: size + 7, | |
| 243 | 249 | attributes, |
| 244 | 250 | }); |
| 245 | 251 | } |
crates/notebook/src/smb/mod.rs+5| ... | ... | @@ -48,6 +48,11 @@ pub struct Client { |
| 48 | 48 | } |
| 49 | 49 | |
| 50 | 50 | impl 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 | 56 | pub fn connect( |
| 52 | 57 | address: &str, |
| 53 | 58 | share: &str, |
crates/notebook/src/worker.rs+20-3| ... | ... | @@ -18,6 +18,9 @@ pub(super) struct Signal { |
| 18 | 18 | synced: AtomicU64, |
| 19 | 19 | /// Asked for by `wake`: steps run while offline until one leaves nothing to do. |
| 20 | 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 | 24 | sender: SyncSender<()>, |
| 22 | 25 | } |
| 23 | 26 | |
| ... | ... | @@ -29,6 +32,7 @@ impl Signal { |
| 29 | 32 | offline: AtomicBool::new(false), |
| 30 | 33 | synced: AtomicU64::new(0), |
| 31 | 34 | requested: AtomicBool::new(false), |
| 35 | watched: AtomicBool::new(false), | |
| 32 | 36 | sender, |
| 33 | 37 | }; |
| 34 | 38 | (Arc::new(signal), receiver) |
| ... | ... | @@ -91,7 +95,8 @@ impl Drop for SyncWorker { |
| 91 | 95 | |
| 92 | 96 | impl Replica { |
| 93 | 97 | /// Starts one worker, reconnecting through `connect` after transport failures. |
| 94 | /// Local edits wake it; `interval` controls idle polling and transport retries. While | |
| 98 | /// Local edits wake it; `interval` controls idle polling, unless a watch wakes it | |
| 99 | /// (`Background::hold`), and transport retries. While | |
| 95 | 100 | /// nothing is queued, or the queue waits on a remote that has not changed since, a |
| 96 | 101 | /// remote whose `stamp` holds is not read again. |
| 97 | 102 | /// Contended operations returning `NotCommitted` also back off by up to one second. |
| ... | ... | @@ -134,6 +139,15 @@ impl Replica { |
| 134 | 139 | // The first step, and the first after a failure, always runs, so `observe` |
| 135 | 140 | // hears that the remote is reachable and what state the queue is in. |
| 136 | 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 | 151 | while !worker_signal.stopped.load(Ordering::Acquire) { |
| 138 | 152 | if worker_signal.offline.load(Ordering::Acquire) |
| 139 | 153 | && !worker_signal.requested.load(Ordering::Acquire) |
| ... | ... | @@ -148,7 +162,7 @@ impl Replica { |
| 148 | 162 | if reported && replica.settled(remote).unwrap_or(false) { |
| 149 | 163 | worker_signal.requested.store(false, Ordering::Release); |
| 150 | 164 | worker_signal.synced.store(crate::now(), Ordering::Release); |
| 151 | let _ = receiver.recv_timeout(interval); | |
| 165 | rest(); | |
| 152 | 166 | continue; |
| 153 | 167 | } |
| 154 | 168 | let result = replica.sync_once(remote); |
| ... | ... | @@ -167,6 +181,7 @@ impl Replica { |
| 167 | 181 | worker_signal.synced.store(crate::now(), Ordering::Release); |
| 168 | 182 | } |
| 169 | 183 | observe(&result); |
| 184 | let idle = matches!(result, Ok(Synced { edit: None, .. })); | |
| 170 | 185 | match result { |
| 171 | 186 | Ok(Synced { |
| 172 | 187 | edit: Some((_, EditStatus::Published { .. })), |
| ... | ... | @@ -209,7 +224,9 @@ impl Replica { |
| 209 | 224 | Err(error) => return Err(error), |
| 210 | 225 | } |
| 211 | 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 | 230 | let _ = receiver.recv_timeout(interval); |
| 214 | 231 | } |
| 215 | 232 | } |
crates/notebook/tests/discovery.rs+80-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | use notebook::discover::{Entry, Error, Limits, Local, Source, discover}; | |
| 1 | use notebook::discover::{Cache, Entry, Error, Limits, Local, Source, discover}; | |
| 2 | 2 | use std::{fs, io, path::Path}; |
| 3 | 3 | |
| 4 | 4 | fn limits() -> Limits { |
| ... | ... | @@ -357,3 +357,82 @@ fn unreadable_children_are_listed_as_unavailable_and_retried() { |
| 357 | 357 | fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap(); |
| 358 | 358 | assert!(denied.is_err()); |
| 359 | 359 | } |
| 360 | ||
| 361 | /// A local folder that notes each file read, with copies of some files by identity. | |
| 362 | struct Counting { | |
| 363 | source: Local, | |
| 364 | read: Vec<String>, | |
| 365 | copies: Vec<([u8; 16], Vec<u8>)>, | |
| 366 | } | |
| 367 | ||
| 368 | impl 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] | |
| 388 | fn 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 | 386 | section.close().unwrap(); |
| 387 | 387 | } |
| 388 | 388 | |
| 389 | /// The replicas in `cache`, beside the folder of catalog listings. | |
| 390 | fn 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 | 397 | #[test] |
| 390 | 398 | fn a_notebook_only_opens_discovered_section_paths() { |
| 391 | 399 | let directory = tempfile::tempdir().unwrap(); |
| ... | ... | @@ -404,7 +412,7 @@ fn a_notebook_only_opens_discovered_section_paths() { |
| 404 | 412 | 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 | 416 | assert_eq!(std::fs::read(&outside).unwrap(), source); |
| 409 | 417 | } |
| 410 | 418 | |
| ... | ... | @@ -427,7 +435,7 @@ fn a_catalog_section_replaced_by_an_outside_symlink_is_rejected() { |
| 427 | 435 | notebook.section("notes.one", || {}), |
| 428 | 436 | 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 | 439 | assert_eq!(std::fs::read(&outside).unwrap(), source); |
| 432 | 440 | } |
| 433 | 441 | |
| ... | ... | @@ -1028,9 +1036,7 @@ fn a_closed_sections_queued_edits_publish_in_the_background_when_online() { |
| 1028 | 1036 | let directory = tempfile::tempdir().unwrap(); |
| 1029 | 1037 | let notebook = two_sections(directory.path()); |
| 1030 | 1038 | let file = directory.path().join("Shared/Second.one"); |
| 1031 | let background = notebook | |
| 1032 | .background(Duration::from_millis(20), false, || {}) | |
| 1033 | .unwrap(); | |
| 1039 | let background = notebook.background(false, false, || {}).unwrap(); | |
| 1034 | 1040 | background.set_offline(true); |
| 1035 | 1041 | background.watch(notebook.replicas()); |
| 1036 | 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 | 1089 | let notified = Arc::new(AtomicUsize::new(0)); |
| 1084 | 1090 | let counter = Arc::clone(&notified); |
| 1085 | 1091 | let background = notebook |
| 1086 | .background(Duration::from_millis(20), false, move || { | |
| 1092 | .background(true, false, move || { | |
| 1087 | 1093 | counter.fetch_add(1, Ordering::SeqCst); |
| 1088 | 1094 | }) |
| 1089 | 1095 | .unwrap(); |
| ... | ... | @@ -1107,6 +1113,8 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() { |
| 1107 | 1113 | .unwrap(); |
| 1108 | 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 | 1118 | let mut noticed = Vec::new(); |
| 1111 | 1119 | until("both changes were noticed", || { |
| 1112 | 1120 | noticed.extend(background.changed()); |
| ... | ... | @@ -1130,9 +1138,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() { |
| 1130 | 1138 | fn a_reported_change_wakes_its_section_without_polling() { |
| 1131 | 1139 | let directory = tempfile::tempdir().unwrap(); |
| 1132 | 1140 | let notebook = two_sections(directory.path()); |
| 1133 | let background = notebook | |
| 1134 | .background(Duration::from_secs(3600), false, || {}) | |
| 1135 | .unwrap(); | |
| 1141 | let background = notebook.background(true, false, || {}).unwrap(); | |
| 1136 | 1142 | background.watch(notebook.replicas()); |
| 1137 | 1143 | until("both sections were reached", || { |
| 1138 | 1144 | background |
| ... | ... | @@ -1169,12 +1175,10 @@ fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() { |
| 1169 | 1175 | let replicas: Vec<_> = notebook |
| 1170 | 1176 | .replicas() |
| 1171 | 1177 | .into_iter() |
| 1172 | .map(|(_, replica)| replica.unwrap()) | |
| 1178 | .map(|known| known.replica.unwrap()) | |
| 1173 | 1179 | .collect(); |
| 1174 | 1180 | assert!(replicas.iter().all(|replica| !replica.exists())); |
| 1175 | let background = notebook | |
| 1176 | .background(Duration::from_secs(3600), true, || {}) | |
| 1177 | .unwrap(); | |
| 1181 | let background = notebook.background(true, true, || {}).unwrap(); | |
| 1178 | 1182 | background.watch(notebook.replicas()); |
| 1179 | 1183 | until("both sections have offline copies", || { |
| 1180 | 1184 | replicas.iter().all(|replica| replica.exists()) |
| ... | ... | @@ -1199,3 +1203,97 @@ fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() { |
| 1199 | 1203 | let kept = notebook::Replica::open(&second).unwrap(); |
| 1200 | 1204 | assert_eq!(kept.pending().unwrap().len(), 1); |
| 1201 | 1205 | } |
| 1206 | ||
| 1207 | /// A section file whose stamp reads are counted. | |
| 1208 | struct Counted(std::path::PathBuf, Arc<AtomicUsize>); | |
| 1209 | ||
| 1210 | impl 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] | |
| 1233 | fn 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 | 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 | 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 | 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 | | |
| 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` | | |
| 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, 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 | 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 | 51 | | `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode | |
| 52 | 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 @@ |
| 1 | use onestore::{RevisionIndex, Store, document::Document}; | |
| 2 | fn 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 @@ |
| 1 | use onestore::{RevisionIndex, Store, document::Document}; | |
| 2 | fn 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)] | |
| 29 | fn unused() {} |
crates/onestore/src/op/apply.rs+10-1| ... | ... | @@ -1222,7 +1222,16 @@ impl<'a> Writer<'_, 'a> { |
| 1222 | 1222 | .iter() |
| 1223 | 1223 | .map(|stroke| (stroke.id, stroke)) |
| 1224 | 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 | } |
| 1227 | 1236 | |
| 1228 | 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 | 642 | }); |
| 643 | 643 | let mut origin = ((left - stroke.width / 2.0) / 36.0).to_le_bytes().to_vec(); |
| 644 | 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 | 650 | values.extend([ |
| 647 | 651 | (0x14003419, index.to_le_bytes().to_vec()), |
| 648 | 652 | (0x1000341b, 0x409_u16.to_le_bytes().to_vec()), |
| ... | ... | @@ -671,8 +675,13 @@ pub(crate) fn ink_changes( |
| 671 | 675 | if ink.strokes.is_empty() { |
| 672 | 676 | return Err(invalid("New ink needs at least one stroke")); |
| 673 | 677 | } |
| 674 | if ink.layout != Default::default() { | |
| 675 | return Err(invalid("Ink positions come from its strokes")); | |
| 678 | let placed = crate::document::Layout { | |
| 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 | 686 | let (modified, filetime) = crate::create::current_timestamps()?; |
| 678 | 687 | let modified = modified.to_le_bytes(); |
| ... | ... | @@ -700,7 +709,10 @@ pub(crate) fn ink_changes( |
| 700 | 709 | let (kind, property, bytes) = shape.stored(); |
| 701 | 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 | 716 | let mut object = PropertyObject { |
| 705 | 717 | jcid: 0x60014, |
| 706 | 718 | bytes: crate::create::properties(&values)?, |
crates/onestore/src/page/ink.rs+9-3| ... | ... | @@ -329,8 +329,10 @@ impl InkShape { |
| 329 | 329 | match (kind?, line, anchors) { |
| 330 | 330 | (11, Some(line), _) if line.len() == 16 => { |
| 331 | 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)))) | |
| 333 | .filter(Self::finite) | |
| 332 | Some(Self::Line( | |
| 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 | 337 | (12, _, Some(bytes)) if bytes.len() >= 24 && (bytes.len() - 24) % 8 == 0 => { |
| 336 | 338 | let v = floats(bytes); |
| ... | ... | @@ -358,7 +360,11 @@ impl InkShape { |
| 358 | 360 | pub(crate) fn stored(&self) -> (u8, u32, Vec<u8>) { |
| 359 | 361 | let half = |v: &f32| (v / 36.0).to_le_bytes(); |
| 360 | 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 | 368 | Self::Closed { transform, anchors } => ( |
| 363 | 369 | 12, |
| 364 | 370 | 0x1c001daa, |
crates/onestore/src/page/text.rs+5| ... | ... | @@ -172,6 +172,11 @@ pub fn new_id() -> Result<ExGuid, EditError> { |
| 172 | 172 | Ok(id) |
| 173 | 173 | } |
| 174 | 174 | |
| 175 | /// A GUID of its own, as a recording's identity takes (AudioRecordingGuid). | |
| 176 | pub fn new_guid() -> Result<[u8; 16], EditError> { | |
| 177 | crate::write::fresh_guid().map_err(|_| EditError::Identity) | |
| 178 | } | |
| 179 | ||
| 175 | 180 | impl Paragraph { |
| 176 | 181 | pub fn project(&self) -> Result<TextProjection, EditError> { |
| 177 | 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 | 323 | .all(|(a, b)| (a[0] - b[0]).abs() < 0.05 && (a[1] - b[1]).abs() < 0.05) |
| 324 | 324 | }; |
| 325 | 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 | 332 | (line, ShapeKind::Line, [342.0, 86.4], [414.0, 122.4]), |
| 328 | 333 | (arrow, ShapeKind::Arrow, [252.0, 158.4], [324.0, 194.4]), |
| 329 | 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 | 373 | .map(|ink| (ink.layout.x, ink.layout.y)) |
| 369 | 374 | .collect(); |
| 370 | 375 | assert_eq!(moved.len(), 2); |
| 371 | assert!(moved.iter().any(|&(x, y)| x == Some(45.0) | |
| 372 | && y.is_some_and(|y| (y - 15.0).abs() < 1e-4))); | |
| 376 | assert!( | |
| 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 | } |
| 374 | 382 | |
| 375 | 383 | fn tool_page(source: &[u8]) -> (ExGuid, Page, Vec<Ink>) { |
| ... | ... | @@ -418,8 +426,20 @@ fn tool_page(source: &[u8]) -> (ExGuid, Page, Vec<Ink>) { |
| 418 | 426 | let inks = vec![ |
| 419 | 427 | free(&marker, vec![[60.0, 190.0], [220.0, 190.0]]), |
| 420 | 428 | free(&accent, wave), |
| 421 | Ink::drawn(ShapeKind::Rectangle, [252.0, 140.4], [324.0, 194.4], &shape_pen).unwrap(), | |
| 422 | Ink::drawn(ShapeKind::Ellipse, [342.0, 140.4], [432.0, 194.4], &shape_pen).unwrap(), | |
| 429 | Ink::drawn( | |
| 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 | 443 | Ink::drawn(ShapeKind::Arrow, [252.0, 230.4], [360.0, 266.4], &shape_pen).unwrap(), |
| 424 | 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 | 548 | other => panic!("{other:?}"), |
| 529 | 549 | } |
| 530 | 550 | } |
| 531 | assert_eq!(extra(wave.id, 0x14001d4e), Some(1u32.to_le_bytes().to_vec())); | |
| 532 | assert_eq!(extra(rectangle.id, 0x14001d4e), Some(2u32.to_le_bytes().to_vec())); | |
| 551 | assert_eq!( | |
| 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 | 559 | match &node(rectangle.id).kind { |
| 534 | 560 | Kind::Ink { |
| 535 | 561 | shape_kind: Some(12), |
| ... | ... | @@ -576,7 +602,9 @@ fn onenote_drawings_take_erasing_moving_and_more_strokes() { |
| 576 | 602 | } |
| 577 | 603 | if let Some(shape) = &mut ink.shape { |
| 578 | 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 | 608 | InkShape::Closed { transform, anchors } => InkShape::Closed { |
| 581 | 609 | transform: { |
| 582 | 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 | 13 | const TITLE: &str = "Files and recording"; |
| 14 | 14 | |
| 15 | 15 | fn page(bytes: &[u8]) -> (ExGuid, Page) { |
| 16 | titled(bytes, TITLE) | |
| 17 | } | |
| 18 | ||
| 19 | fn titled(bytes: &[u8], title: &str) -> (ExGuid, Page) { | |
| 16 | 20 | let store = Store::parse(bytes).unwrap(); |
| 17 | 21 | let index = RevisionIndex::parse(&store).unwrap(); |
| 18 | 22 | let document = Document::parse(&index).unwrap(); |
| ... | ... | @@ -21,7 +25,7 @@ fn page(bytes: &[u8]) -> (ExGuid, Page) { |
| 21 | 25 | .unwrap() |
| 22 | 26 | .into_iter() |
| 23 | 27 | .map(|(space, _)| (space, Page::from_space(&document, space).unwrap())) |
| 24 | .find(|(_, page)| page.title == TITLE) | |
| 28 | .find(|(_, page)| page.title == title) | |
| 25 | 29 | .unwrap() |
| 26 | 30 | } |
| 27 | 31 | |
| ... | ... | @@ -158,3 +162,111 @@ fn editing_a_recorded_page_keeps_the_recording_and_its_annotation() { |
| 158 | 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). | |
| 167 | fn 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. | |
| 184 | const 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. | |
| 187 | const 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] | |
| 192 | fn 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 | ||
| 251 | fn 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 | ||
| 261 | fn 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 | 66 | pub const SYNC_DONE: &[&str] = art!("icons/sync-done"); |
| 67 | 67 | pub const SYNC_ERROR: &[&str] = art!("icons/sync-error"); |
| 68 | 68 | pub const SYNC_OFFLINE: &[&str] = art!("icons/sync-offline"); |
| 69 | ||
| 70 | pub const ERASER: &[&str] = art!("icons/eraser"); | |
| 71 | pub const LASSO: &[&str] = art!("icons/lasso"); | |
| 72 | pub const PEN: &[&str] = art!("icons/pen"); | |
| 73 | pub const SELECT: &[&str] = art!("icons/select"); | |
| 74 | pub const SHAPES: &[&str] = art!("icons/shapes"); | |
| 75 | pub const SHAPE_ARROW: &[&str] = art!("icons/shape-arrow"); | |
| 76 | pub const SHAPE_LINE: &[&str] = art!("icons/shape-line"); | |
| 77 | pub const SHAPE_OVAL: &[&str] = art!("icons/shape-oval"); | |
| 78 | pub const SHAPE_RECTANGLE: &[&str] = art!("icons/shape-rectangle"); |
crates/snowbound/src/attachment.rs+25-9| ... | ... | @@ -5,11 +5,12 @@ use onestore::page::Attachment; |
| 5 | 5 | use std::{error::Error, path::Path}; |
| 6 | 6 | |
| 7 | 7 | /// The size OneNote 2010 shows a file's icon at, in points. |
| 8 | const ICON_SIZE: [f32; 2] = [24.0, 24.0]; | |
| 8 | pub(crate) const ICON_SIZE: [f32; 2] = [24.0, 24.0]; | |
| 9 | 9 | |
| 10 | 10 | impl State { |
| 11 | 11 | /// 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 | 14 | pub(crate) fn attach( |
| 14 | 15 | &mut self, |
| 15 | 16 | path: &Path, |
| ... | ... | @@ -31,9 +32,9 @@ impl State { |
| 31 | 32 | source_path: path.to_str().map(str::to_owned), |
| 32 | 33 | size: Some(ICON_SIZE), |
| 33 | 34 | layout: Default::default(), |
| 34 | bytes: Some(bytes.into()), | |
| 35 | 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 | 39 | let response = match at { |
| 39 | 40 | Some(point) => { |
| ... | ... | @@ -53,11 +54,27 @@ impl State { |
| 53 | 54 | Ok(()) |
| 54 | 55 | } |
| 55 | 56 | |
| 56 | /// Open: a copy of the file, in a folder of its own, with the system's application for it. | |
| 57 | pub(crate) fn open_attachment(&self, file: &Attachment) -> Result<(), Box<dyn Error>> { | |
| 57 | /// Open: a recording plays; another file opens as a copy, in a folder of its own, with | |
| 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 | 75 | let Some(bytes) = &file.bytes else { |
| 59 | 76 | unstored(); |
| 60 | return Ok(()); | |
| 77 | return Ok(None); | |
| 61 | 78 | }; |
| 62 | 79 | let root = std::env::temp_dir().join("Snowbound Attachments"); |
| 63 | 80 | std::fs::create_dir_all(&root)?; |
| ... | ... | @@ -68,8 +85,7 @@ impl State { |
| 68 | 85 | .ok_or("No folder is free for a copy of the file")?; |
| 69 | 86 | let path = folder.join(&file.filename); |
| 70 | 87 | std::fs::write(&path, bytes)?; |
| 71 | platform::open_file(&path); | |
| 72 | Ok(()) | |
| 88 | Ok(Some(path)) | |
| 73 | 89 | } |
| 74 | 90 | |
| 75 | 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 | 2 | //! chords on each platform, when it applies and what it does. |
| 3 | 3 | |
| 4 | 4 | use crate::{Command as Work, FONTS, HIGHLIGHTS, SIZES, State, page, platform, search}; |
| 5 | use canvas::editor::{Alignment, FormatState, Formatting, ListStyle, Toggle}; | |
| 6 | use canvas::interaction::Request; | |
| 5 | use canvas::editor::{Alignment, FAVORITES, FormatState, Formatting, ListStyle, Pen, Toggle}; | |
| 6 | use canvas::interaction::{Request, ink::Tool}; | |
| 7 | 7 | use draw::edit::{Key, Modifiers, NamedKey, Platform}; |
| 8 | use onestore::page::ink::ShapeKind; | |
| 8 | 9 | use std::{error::Error, sync::Arc}; |
| 9 | 10 | |
| 10 | 11 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| ... | ... | @@ -54,6 +55,12 @@ pub enum Id { |
| 54 | 55 | DateTime, |
| 55 | 56 | RecordAudio, |
| 56 | 57 | RecordVideo, |
| 58 | /// The Draw tab's tools. | |
| 59 | SelectType, | |
| 60 | Pen, | |
| 61 | Eraser, | |
| 62 | Lasso, | |
| 63 | Shape(ShapeKind), | |
| 57 | 64 | Toggle(Toggle), |
| 58 | 65 | Highlight, |
| 59 | 66 | FontColor, |
| ... | ... | @@ -90,6 +97,8 @@ pub enum Choice { |
| 90 | 97 | RuleLines(Option<usize>), |
| 91 | 98 | /// A template whose art becomes the page's background, or `None` for none. |
| 92 | 99 | Art(Option<&'static str>), |
| 100 | /// A place in the pen gallery, which Pen then draws with. | |
| 101 | Pen(usize), | |
| 93 | 102 | } |
| 94 | 103 | |
| 95 | 104 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] |
| ... | ... | @@ -299,6 +308,14 @@ pub const COMMANDS: &[Command] = &[ |
| 299 | 308 | ), |
| 300 | 309 | row!(Id::RecordAudio, "Record Audio", NONE, NONE), |
| 301 | 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 | 319 | row!(Id::Toggle(Toggle::Bold), "Bold", &[cmd('b')], &[cmd('b')]), |
| 303 | 320 | row!( |
| 304 | 321 | Id::Toggle(Toggle::Italic), |
| ... | ... | @@ -438,7 +455,8 @@ pub fn title(choice: Choice) -> String { |
| 438 | 455 | | Choice::Table(_) |
| 439 | 456 | | Choice::PageColor(_) |
| 440 | 457 | | Choice::RuleLines(_) |
| 441 | | Choice::Art(_) => { | |
| 458 | | Choice::Art(_) | |
| 459 | | Choice::Pen(_) => { | |
| 442 | 460 | unreachable!("Only the toolbar offers these") |
| 443 | 461 | } |
| 444 | 462 | } |
| ... | ... | @@ -596,6 +614,20 @@ pub fn shortcut(id: Id) -> String { |
| 596 | 614 | } |
| 597 | 615 | |
| 598 | 616 | impl 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 | 631 | pub(crate) fn format_state(&self) -> FormatState { |
| 600 | 632 | self.view.editor.format_state().unwrap_or_default() |
| 601 | 633 | } |
| ... | ... | @@ -643,7 +675,9 @@ impl State { |
| 643 | 675 | Choice::Highlight(_) | Choice::Color(_) | Choice::List(_) => return enabled(text), |
| 644 | 676 | Choice::Table(_) => Id::Table, |
| 645 | 677 | Choice::PageColor(_) | Choice::RuleLines(_) | Choice::Art(_) => Id::PageColor, |
| 678 | Choice::Pen(_) => Id::Pen, | |
| 646 | 679 | }; |
| 680 | let tool = self.view.tool(); | |
| 647 | 681 | match id { |
| 648 | 682 | Id::Settings |
| 649 | 683 | | Id::NewNotebook |
| ... | ... | @@ -708,12 +742,31 @@ impl State { |
| 708 | 742 | }, |
| 709 | 743 | Id::PageColor => enabled(writable && session.is_some()), |
| 710 | 744 | Id::ScreenClipping => enabled(text && cfg!(target_os = "macos")), |
| 711 | // Record Audio and Video await #36. | |
| 712 | Id::RecordAudio | Id::RecordVideo => enabled(false), | |
| 745 | Id::RecordAudio => Status { | |
| 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 | 752 | Id::InsertSpace => Status { |
| 714 | 753 | enabled: writable, |
| 715 | 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 | 770 | Id::Table |
| 718 | 771 | | Id::Picture |
| 719 | 772 | | Id::Attachment |
| ... | ... | @@ -826,6 +879,11 @@ impl State { |
| 826 | 879 | let lines = lines.map(|index| canvas::template::RULE_LINES[index].1); |
| 827 | 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 | 888 | let field = self.ui.focused().filter(|focus| *focus != page()); |
| 831 | 889 | let response = match id { |
| ... | ... | @@ -980,14 +1038,33 @@ impl State { |
| 980 | 1038 | self.save_settings(); |
| 981 | 1039 | return Ok(()); |
| 982 | 1040 | } |
| 983 | Id::RecordAudio | Id::RecordVideo => { | |
| 984 | return Ok(()); | |
| 985 | } | |
| 1041 | Id::RecordAudio => return self.record_audio(), | |
| 1042 | Id::RecordVideo => return Ok(()), | |
| 986 | 1043 | Id::InsertSpace => { |
| 987 | 1044 | let response = self.view.insert_space(); |
| 988 | 1045 | self.respond(response); |
| 989 | 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 | 1068 | Id::Link => { |
| 992 | 1069 | self.open_link_dialog(); |
| 993 | 1070 | return Ok(()); |
crates/snowbound/src/library.rs+36-18| ... | ... | @@ -167,7 +167,7 @@ pub struct Library { |
| 167 | 167 | /// Syncs the notebook's sections while no tab shows them, as OneNote syncs every |
| 168 | 168 | /// section of an open notebook. |
| 169 | 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 | 171 | watch: Option<Arc<crate::watch::Watch>>, |
| 172 | 172 | } |
| 173 | 173 | |
| ... | ... | @@ -255,6 +255,16 @@ impl Library { |
| 255 | 255 | if let Some(background) = &self.background { |
| 256 | 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 | 268 | Self { |
| 259 | 269 | location: self.location.clone(), |
| 260 | 270 | name: self.name.clone(), |
| ... | ... | @@ -263,7 +273,7 @@ impl Library { |
| 263 | 273 | notice: self.notice.clone(), |
| 264 | 274 | cache: self.cache.clone(), |
| 265 | 275 | background: self.background.clone(), |
| 266 | watch: self.watch.clone(), | |
| 276 | watch, | |
| 267 | 277 | } |
| 268 | 278 | } |
| 269 | 279 | |
| ... | ... | @@ -355,6 +365,9 @@ impl Library { |
| 355 | 365 | } |
| 356 | 366 | }; |
| 357 | 367 | section.set_offline(offline()); |
| 368 | if let Some(background) = &self.background { | |
| 369 | background.hold(path, &section); | |
| 370 | } | |
| 358 | 371 | Ok(section) |
| 359 | 372 | } |
| 360 | 373 | |
| ... | ... | @@ -460,26 +473,17 @@ pub fn recycle_bin(path: &str) -> bool { |
| 460 | 473 | } |
| 461 | 474 | |
| 462 | 475 | /// 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 a | |
| 464 | /// network volume is checked more often and kept in offline copies, as on a share. | |
| 476 | /// the folder's changes to it. A folder on a network volume is kept in offline copies, as on a | |
| 477 | /// share, and where the system does not report its server's changes, checked more often. | |
| 465 | 478 | fn local_background( |
| 466 | 479 | notebook: &Notebook, |
| 467 | 480 | location: &str, |
| 468 | 481 | ) -> (Option<Arc<Background>>, Option<Arc<crate::watch::Watch>>) { |
| 469 | let reports = Arc::new(OnceLock::<std::sync::Weak<Background>>::new()); | |
| 470 | let report = Arc::clone(&reports); | |
| 471 | let watch = crate::watch::watch(Path::new(location), move |paths| { | |
| 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 | }; | |
| 482 | let reports = Arc::new(OnceLock::new()); | |
| 483 | let watch = watcher(location, Arc::clone(&reports)); | |
| 484 | let copies = !crate::watch::on_this_computer(Path::new(location)); | |
| 481 | 485 | let Some(background) = notebook |
| 482 | .background(interval, remote, notify_background) | |
| 486 | .background(watch.is_some(), copies, notify_background) | |
| 483 | 487 | .inspect_err(|error| eprintln!("Background sync did not start: {error}")) |
| 484 | 488 | .ok() |
| 485 | 489 | else { |
| ... | ... | @@ -489,7 +493,21 @@ fn local_background( |
| 489 | 493 | background.watch(notebook.replicas()); |
| 490 | 494 | let background = Arc::new(background); |
| 491 | 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. | |
| 501 | fn 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 | } |
| 494 | 512 | |
| 495 | 513 | pub fn file_name(path: &Path) -> String { |
crates/snowbound/src/main.rs+163-22| ... | ... | @@ -10,6 +10,9 @@ mod history; |
| 10 | 10 | mod library; |
| 11 | 11 | mod link; |
| 12 | 12 | mod manage; |
| 13 | #[cfg_attr(target_os = "linux", path = "media_linux.rs")] | |
| 14 | #[cfg_attr(target_os = "macos", path = "media_macos.rs")] | |
| 15 | mod media; | |
| 13 | 16 | mod meeting; |
| 14 | 17 | #[cfg(target_os = "macos")] |
| 15 | 18 | mod menubar; |
| ... | ... | @@ -21,6 +24,7 @@ mod pane; |
| 21 | 24 | #[cfg_attr(target_os = "linux", path = "linux.rs")] |
| 22 | 25 | #[cfg_attr(target_os = "macos", path = "macos.rs")] |
| 23 | 26 | mod platform; |
| 27 | mod recording; | |
| 24 | 28 | mod rename; |
| 25 | 29 | mod screenshot; |
| 26 | 30 | mod search; |
| ... | ... | @@ -50,6 +54,7 @@ use library::Library; |
| 50 | 54 | use onestore::ExGuid; |
| 51 | 55 | use onestore::document::Format; |
| 52 | 56 | use onestore::page::Page; |
| 57 | use onestore::page::ink::ShapeKind; | |
| 53 | 58 | use onestore::page::text::Paragraph; |
| 54 | 59 | use std::{ |
| 55 | 60 | collections::{HashMap, HashSet}, |
| ... | ... | @@ -202,6 +207,7 @@ fn cursor_icon(cursor: Cursor) -> CursorIcon { |
| 202 | 207 | Cursor::NeswResize => CursorIcon::NeswResize, |
| 203 | 208 | Cursor::RowResize => CursorIcon::RowResize, |
| 204 | 209 | Cursor::ColResize => CursorIcon::ColResize, |
| 210 | Cursor::Crosshair => CursorIcon::Crosshair, | |
| 205 | 211 | } |
| 206 | 212 | } |
| 207 | 213 | |
| ... | ... | @@ -550,6 +556,7 @@ struct State { |
| 550 | 556 | title_focus: Option<ExGuid>, |
| 551 | 557 | /// What the template strip shows over a blank page. |
| 552 | 558 | templates: templates::View, |
| 559 | media: recording::Media, | |
| 553 | 560 | thumbnails: templates::Thumbnails, |
| 554 | 561 | search: search::Search, |
| 555 | 562 | /// Whether the page list is shown beside the page. |
| ... | ... | @@ -835,6 +842,7 @@ impl State { |
| 835 | 842 | sectionless, |
| 836 | 843 | drag: None, |
| 837 | 844 | templates: templates::View::Strip, |
| 845 | media: Default::default(), | |
| 838 | 846 | thumbnails: templates::Thumbnails::default(), |
| 839 | 847 | search, |
| 840 | 848 | pages_open: true, |
| ... | ... | @@ -912,7 +920,7 @@ impl State { |
| 912 | 920 | } |
| 913 | 921 | self.draw()?; |
| 914 | 922 | lap("drawn", start); |
| 915 | self.sync_index(false); | |
| 923 | self.sync_index(false, Vec::new()); | |
| 916 | 924 | let commands = std::mem::take(&mut self.commands); |
| 917 | 925 | let follow = !commands.is_empty() || self.ui.wants_frame() || self.opening.is_some(); |
| 918 | 926 | for command in commands { |
| ... | ... | @@ -1260,6 +1268,9 @@ impl State { |
| 1260 | 1268 | }, |
| 1261 | 1269 | ); |
| 1262 | 1270 | self.template_strip(&theme); |
| 1271 | if let Some(rect) = self.ui.rect(page()) { | |
| 1272 | self.transport(&theme, rect)?; | |
| 1273 | } | |
| 1263 | 1274 | let scroll = self.view.scroll(); |
| 1264 | 1275 | for (index, axis) in [Axis::X, Axis::Y].into_iter().enumerate() { |
| 1265 | 1276 | if let Some(offset) = ui::scrollbar( |
| ... | ... | @@ -1539,13 +1550,14 @@ impl State { |
| 1539 | 1550 | let engine = &self.view.engine; |
| 1540 | 1551 | let session = self.session.as_ref(); |
| 1541 | 1552 | let label = format!("{:.0}%", self.view.zoom() * 100.0); |
| 1553 | let drawing_pens = self.pens(); | |
| 1542 | 1554 | let ui = &mut self.ui; |
| 1543 | 1555 | let text = theme.text; |
| 1544 | 1556 | let tags: Vec<_> = self.tags.iter().enumerate().collect(); |
| 1545 | 1557 | let mut choice = group( |
| 1546 | 1558 | ui, |
| 1547 | 1559 | "navigate", |
| 1548 | 3, | |
| 1560 | 4, | |
| 1549 | 1561 | |ui| { |
| 1550 | 1562 | let mut choice = None; |
| 1551 | 1563 | for id in [Cmd::Back, Cmd::Forward, Cmd::Undo, Cmd::Redo] { |
| ... | ... | @@ -1561,7 +1573,7 @@ impl State { |
| 1561 | 1573 | choice = group( |
| 1562 | 1574 | ui, |
| 1563 | 1575 | "clipboard", |
| 1564 | 2, | |
| 1576 | 3, | |
| 1565 | 1577 | |ui| { |
| 1566 | 1578 | divider(ui, theme); |
| 1567 | 1579 | let mut choice = None; |
| ... | ... | @@ -1721,7 +1733,7 @@ impl State { |
| 1721 | 1733 | choice = group( |
| 1722 | 1734 | ui, |
| 1723 | 1735 | "character", |
| 1724 | 5, | |
| 1736 | 6, | |
| 1725 | 1737 | |ui| { |
| 1726 | 1738 | divider(ui, theme); |
| 1727 | 1739 | let mut choice = None; |
| ... | ... | @@ -1816,7 +1828,7 @@ impl State { |
| 1816 | 1828 | choice = group( |
| 1817 | 1829 | ui, |
| 1818 | 1830 | "paragraph", |
| 1819 | 4, | |
| 1831 | 5, | |
| 1820 | 1832 | |ui| { |
| 1821 | 1833 | divider(ui, theme); |
| 1822 | 1834 | let mut choice = None; |
| ... | ... | @@ -1946,7 +1958,7 @@ impl State { |
| 1946 | 1958 | choice = group( |
| 1947 | 1959 | ui, |
| 1948 | 1960 | "tags", |
| 1949 | 6, | |
| 1961 | 7, | |
| 1950 | 1962 | |ui| { |
| 1951 | 1963 | divider(ui, theme); |
| 1952 | 1964 | let mut choice = None; |
| ... | ... | @@ -1964,6 +1976,23 @@ impl State { |
| 1964 | 1976 | }, |
| 1965 | 1977 | ) |
| 1966 | 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 | 1996 | let inserted = [Cmd::Picture, Cmd::Link, Cmd::Date, Cmd::Equation]; |
| 1968 | 1997 | let more = [ |
| 1969 | 1998 | Cmd::ScreenClipping, |
| ... | ... | @@ -1979,7 +2008,7 @@ impl State { |
| 1979 | 2008 | choice = group( |
| 1980 | 2009 | ui, |
| 1981 | 2010 | "insert", |
| 1982 | 1, | |
| 2011 | 2, | |
| 1983 | 2012 | |ui| { |
| 1984 | 2013 | divider(ui, theme); |
| 1985 | 2014 | let mut choice = None; |
| ... | ... | @@ -1998,17 +2027,64 @@ impl State { |
| 1998 | 2027 | for id in inserted { |
| 1999 | 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 | 2035 | |ui| { |
| 2004 | 2036 | let mut entries = vec![Open("table", Cmd::Table)]; |
| 2005 | 2037 | entries.extend(inserted.map(Run)); |
| 2006 | 2038 | entries.push(Rule); |
| 2007 | 2039 | entries.extend(more); |
| 2040 | entries.push(Rule); | |
| 2041 | entries.extend(drawing.clone()); | |
| 2008 | 2042 | dropdown(ui, "menu", Head::Menu(art::PLUS), &entries, status_of) |
| 2009 | 2043 | }, |
| 2010 | 2044 | ) |
| 2011 | 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 | 2088 | ui.leaf( |
| 2013 | 2089 | "space", |
| 2014 | 2090 | Spec { |
| ... | ... | @@ -2393,6 +2469,7 @@ impl State { |
| 2393 | 2469 | } |
| 2394 | 2470 | Command::Page(Request::OpenLink(address)) => self.open_link(&address)?, |
| 2395 | 2471 | Command::Page(Request::OpenAttachment(file)) => self.open_attachment(&file)?, |
| 2472 | Command::Page(Request::Play { file, at_ms }) => self.play(&file, at_ms)?, | |
| 2396 | 2473 | Command::Choose(choice) => self.run(choice)?, |
| 2397 | 2474 | } |
| 2398 | 2475 | Ok(()) |
| ... | ... | @@ -2459,6 +2536,8 @@ impl State { |
| 2459 | 2536 | self.opening = Some(opening); |
| 2460 | 2537 | return Ok(()); |
| 2461 | 2538 | } |
| 2539 | // A recording goes on the page it started on. | |
| 2540 | self.stop_recording()?; | |
| 2462 | 2541 | // The page shown so far keeps what was typed while the next one loaded. |
| 2463 | 2542 | self.persist()?; |
| 2464 | 2543 | if let Some(session) = &self.session { |
| ... | ... | @@ -2486,15 +2565,11 @@ impl State { |
| 2486 | 2565 | } |
| 2487 | 2566 | // The notebook's background sync takes the section over once it is closed. |
| 2488 | 2567 | if let Some(previous) = self.session.replace(*session) { |
| 2489 | let background = previous.library.background.clone(); | |
| 2490 | 2568 | let section = previous.section; |
| 2491 | 2569 | std::thread::spawn(move || { |
| 2492 | 2570 | if let Err(error) = section.close() { |
| 2493 | 2571 | eprintln!("Synchronization stopped: {error}"); |
| 2494 | 2572 | } |
| 2495 | if let Some(background) = background { | |
| 2496 | background.wake(); | |
| 2497 | } | |
| 2498 | 2573 | }); |
| 2499 | 2574 | } |
| 2500 | 2575 | self.sectionless = None; |
| ... | ... | @@ -2840,14 +2915,19 @@ impl State { |
| 2840 | 2915 | /// last poll. |
| 2841 | 2916 | fn synced(&mut self) -> Result<(), Box<dyn Error>> { |
| 2842 | 2917 | // Every notebook's changes are taken, so none is reported again. |
| 2843 | let noticed = self | |
| 2918 | let changed: Vec<String> = self | |
| 2844 | 2919 | .notebooks |
| 2845 | 2920 | .iter() |
| 2846 | .filter_map(|library| library.background.as_ref()) | |
| 2847 | .filter(|background| !background.changed().is_empty()) | |
| 2848 | .count(); | |
| 2849 | if noticed > 0 { | |
| 2850 | self.sync_index(true); | |
| 2921 | .filter_map(|library| Some((library, library.background.as_ref()?))) | |
| 2922 | .flat_map(|(library, background)| { | |
| 2923 | background | |
| 2924 | .changed() | |
| 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 | 2932 | // Reports are rare: a status changed, or a section did. |
| 2853 | 2933 | self.window.request_redraw(); |
| ... | ... | @@ -3811,6 +3891,50 @@ fn list_gallery( |
| 3811 | 3891 | Some(place(chosen)) |
| 3812 | 3892 | } |
| 3813 | 3893 | |
| 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. | |
| 3896 | fn 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 | 3938 | /// A toolbar group that folds by `priority`: `full` builds its full form, `folded` the form |
| 3815 | 3939 | /// it folds to. Returns what either chose. |
| 3816 | 3940 | fn group( |
| ... | ... | @@ -4057,6 +4181,14 @@ fn artwork(id: commands::Id) -> Option<&'static [&'static str]> { |
| 4057 | 4181 | Cmd::Symbol => art::SYMBOL, |
| 4058 | 4182 | Cmd::RecordAudio => art::RECORD_AUDIO, |
| 4059 | 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 | 4192 | Cmd::PageColor => art::PAGE_COLOR, |
| 4061 | 4193 | Cmd::ZoomIn => art::ZOOM_IN, |
| 4062 | 4194 | Cmd::ZoomOut => art::ZOOM_OUT, |
| ... | ... | @@ -4388,6 +4520,11 @@ impl ApplicationHandler<UserEvent> for App { |
| 4388 | 4520 | state.access_adapter.process_event(&state.window, &event); |
| 4389 | 4521 | } |
| 4390 | 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 | 4528 | self.close(event_loop); |
| 4392 | 4529 | return; |
| 4393 | 4530 | } |
| ... | ... | @@ -4502,10 +4639,14 @@ impl ApplicationHandler<UserEvent> for App { |
| 4502 | 4639 | if repaint || wake.is_some_and(|wake| wake <= now) { |
| 4503 | 4640 | state.window.request_redraw(); |
| 4504 | 4641 | } |
| 4505 | let next = [blink, wake.filter(|wake| *wake > now)] | |
| 4506 | .into_iter() | |
| 4507 | .flatten() | |
| 4508 | .min(); | |
| 4642 | let next = [ | |
| 4643 | blink, | |
| 4644 | wake.filter(|wake| *wake > now), | |
| 4645 | state.media_wake(now), | |
| 4646 | ] | |
| 4647 | .into_iter() | |
| 4648 | .flatten() | |
| 4649 | .min(); | |
| 4509 | 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 | ||
| 5 | use std::{ | |
| 6 | cell::Cell, | |
| 7 | ffi::{CStr, CString, c_char, c_int, c_void}, | |
| 8 | path::Path, | |
| 9 | sync::OnceLock, | |
| 10 | }; | |
| 11 | ||
| 12 | type Element = *mut c_void; | |
| 13 | ||
| 14 | /// GST_STATE_NULL, GST_STATE_PAUSED and GST_STATE_PLAYING. | |
| 15 | const NULL: c_int = 1; | |
| 16 | const PAUSED: c_int = 3; | |
| 17 | const PLAYING: c_int = 4; | |
| 18 | /// GST_FORMAT_TIME, in nanoseconds. | |
| 19 | const TIME: c_int = 3; | |
| 20 | /// GST_SEEK_FLAG_FLUSH | GST_SEEK_FLAG_ACCURATE. | |
| 21 | const SEEK: c_int = 1 | 2; | |
| 22 | /// GST_MESSAGE_EOS, GST_MESSAGE_ERROR and GST_MESSAGE_ASYNC_DONE. | |
| 23 | const EOS: c_int = 1; | |
| 24 | const ERROR: c_int = 2; | |
| 25 | const ASYNC_DONE: c_int = 1 << 21; | |
| 26 | /// Nanoseconds a pipeline has to get ready or to finish. | |
| 27 | const PATIENCE: u64 = 5_000_000_000; | |
| 28 | /// GST_STATE_CHANGE_FAILURE. | |
| 29 | const FAILURE: c_int = 0; | |
| 30 | ||
| 31 | /// The GStreamer entry points used, from `libgstreamer-1.0.so.0`. | |
| 32 | struct 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. | |
| 47 | unsafe impl Send for Gst {} | |
| 48 | unsafe impl Sync for Gst {} | |
| 49 | ||
| 50 | /// The function `name` in `library`, of C signature `F`. | |
| 51 | unsafe 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. | |
| 57 | fn 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. | |
| 84 | fn 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. | |
| 98 | struct Pipeline { | |
| 99 | gst: &'static Gst, | |
| 100 | element: Element, | |
| 101 | } | |
| 102 | ||
| 103 | impl 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 | ||
| 150 | impl 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)] | |
| 159 | struct 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. | |
| 172 | pub 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 | ||
| 179 | impl 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. | |
| 239 | pub struct Microphone(Pipeline); | |
| 240 | ||
| 241 | impl 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 | ||
| 4 | use objc2::{ | |
| 5 | msg_send, msg_send_id, | |
| 6 | rc::{Allocated, Retained}, | |
| 7 | runtime::{AnyClass, AnyObject}, | |
| 8 | }; | |
| 9 | use objc2_foundation::{NSString, NSURL}; | |
| 10 | use std::path::Path; | |
| 11 | ||
| 12 | #[link(name = "AVFoundation", kind = "framework")] | |
| 13 | unsafe 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. | |
| 24 | const LINEAR_PCM: u32 = u32::from_be_bytes(*b"lpcm"); | |
| 25 | ||
| 26 | fn class(name: &str) -> &'static AnyClass { | |
| 27 | AnyClass::get(name).expect("AVFoundation is linked") | |
| 28 | } | |
| 29 | ||
| 30 | fn 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. | |
| 35 | pub struct Player(Retained<AnyObject>); | |
| 36 | ||
| 37 | impl 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 | ||
| 81 | impl 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. | |
| 88 | pub struct Microphone(Retained<AnyObject>); | |
| 89 | ||
| 90 | impl 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)] | |
| 148 | mod 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 | 391 | self.search.finding = false; |
| 392 | 392 | self.search.claim = true; |
| 393 | 393 | self.ui.close_popup(crate::search::results()); |
| 394 | self.sync_index(true); | |
| 394 | self.sync_index(true, Vec::new()); | |
| 395 | 395 | } |
| 396 | 396 | |
| 397 | 397 | /// Opens or closes a pane, as its command does. |
| ... | ... | @@ -406,7 +406,7 @@ impl State { |
| 406 | 406 | unchecked: false, |
| 407 | 407 | scope: TagScope::Notebook, |
| 408 | 408 | }); |
| 409 | self.sync_index(true); | |
| 409 | self.sync_index(true, Vec::new()); | |
| 410 | 410 | } |
| 411 | 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 | ||
| 6 | use crate::{State, media, platform}; | |
| 7 | use onestore::page::{Attachment, Recording}; | |
| 8 | use std::{ | |
| 9 | error::Error, | |
| 10 | path::{Path, PathBuf}, | |
| 11 | time::{Duration, Instant}, | |
| 12 | }; | |
| 13 | use 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. | |
| 16 | pub(crate) const RATE: u32 = 16_000; | |
| 17 | ||
| 18 | /// What records or plays now. | |
| 19 | #[derive(Default)] | |
| 20 | pub(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 | ||
| 35 | impl 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 | ||
| 245 | enum 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. | |
| 252 | fn 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`. | |
| 262 | pub(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. | |
| 271 | struct 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 | ||
| 282 | impl<'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. | |
| 328 | const 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 | ]; | |
| 335 | const 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. | |
| 338 | const BLOCK: usize = 512; | |
| 339 | /// Samples per block: the header's, then two per remaining byte. | |
| 340 | const 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. | |
| 344 | pub(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. | |
| 409 | fn 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)] | |
| 436 | pub(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 | 82 | |
| 83 | 83 | /// Work for the index thread. |
| 84 | 84 | enum Job { |
| 85 | /// Brings the index to these notebooks: sections whose files changed are read again and | |
| 86 | /// those no longer listed dropped. The open section, by key, is read through its replica. | |
| 85 | /// Brings the index to these notebooks: sections not read yet are read, and those no | |
| 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 | 89 | Notebooks { |
| 88 | 90 | libraries: Vec<Arc<Library>>, |
| 89 | 91 | open: Option<(String, Weak<Replica>)>, |
| 92 | changed: Vec<String>, | |
| 90 | 93 | }, |
| 91 | 94 | /// Reads pages of the open section again, with its page list. |
| 92 | 95 | Pages { |
| ... | ... | @@ -217,10 +220,18 @@ fn run( |
| 217 | 220 | std::thread::sleep(SETTLE); |
| 218 | 221 | } |
| 219 | 222 | let mut notebooks = None; |
| 223 | let mut changed = HashSet::new(); | |
| 220 | 224 | let mut pages: HashMap<String, (Weak<Replica>, HashSet<ExGuid>)> = HashMap::new(); |
| 221 | 225 | for job in std::iter::once(first).chain(jobs.try_iter()) { |
| 222 | 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 | 235 | Job::Pages { |
| 225 | 236 | key, |
| 226 | 237 | replica, |
| ... | ... | @@ -234,7 +245,14 @@ fn run( |
| 234 | 245 | } |
| 235 | 246 | let start = Instant::now(); |
| 236 | 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 | 256 | lap("index notebooks", start); |
| 239 | 257 | } |
| 240 | 258 | for (key, (replica, spaces)) in pages { |
| ... | ... | @@ -288,6 +306,7 @@ fn sync( |
| 288 | 306 | stamps: &mut HashMap<String, Stamp>, |
| 289 | 307 | libraries: &[Arc<Library>], |
| 290 | 308 | open: Option<&(String, Weak<Replica>)>, |
| 309 | changed: &HashSet<String>, | |
| 291 | 310 | ) { |
| 292 | 311 | let mut listed = HashSet::new(); |
| 293 | 312 | for library in libraries { |
| ... | ... | @@ -299,12 +318,16 @@ fn sync( |
| 299 | 318 | Some(_) => Path::new(&library.location).join(&path), |
| 300 | 319 | None => PathBuf::from(&library.location), |
| 301 | 320 | }; |
| 302 | let stamp = stamp(&file); | |
| 303 | // A file with no stamp, as on a share not mounted, is read once. | |
| 304 | if stamps | |
| 305 | .get(&key) | |
| 306 | .is_some_and(|known| known == &stamp || stamp.is_none()) | |
| 307 | { | |
| 321 | // A notebook's background sync names its changed sections, so none is checked here. | |
| 322 | let reported = library.background.is_some(); | |
| 323 | let stamp = if reported { None } else { stamp(&file) }; | |
| 324 | let fresh = match stamps.get(&key) { | |
| 325 | None => false, | |
| 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 | 331 | continue; |
| 309 | 332 | } |
| 310 | 333 | let replica = open |
| ... | ... | @@ -458,9 +481,9 @@ fn place(notebooks: &[Arc<Library>], key: &str) -> String { |
| 458 | 481 | } |
| 459 | 482 | |
| 460 | 483 | impl State { |
| 461 | /// Brings the index to the open notebooks and section when they changed, and to | |
| 462 | /// section files changed since, when `always`. | |
| 463 | pub(crate) fn sync_index(&mut self, always: bool) { | |
| 484 | /// Brings the index to the open notebooks and section when they changed, and when | |
| 485 | /// `always` or sections `changed` names, by key, to the section files changed since. | |
| 486 | pub(crate) fn sync_index(&mut self, always: bool, changed: Vec<String>) { | |
| 464 | 487 | let open = self.session.as_ref().map(|session| { |
| 465 | 488 | ( |
| 466 | 489 | session.library.key(&session.tabs[session.tab].path), |
| ... | ... | @@ -473,13 +496,14 @@ impl State { |
| 473 | 496 | .map(|library| Arc::as_ptr(library) as usize) |
| 474 | 497 | .chain(open.as_ref().map(|(_, replica)| replica.as_ptr() as usize)) |
| 475 | 498 | .collect(); |
| 476 | if !always && identity == self.search.synced { | |
| 499 | if !always && changed.is_empty() && identity == self.search.synced { | |
| 477 | 500 | return; |
| 478 | 501 | } |
| 479 | 502 | self.search.synced = identity; |
| 480 | 503 | let _ = self.search.jobs.send(Job::Notebooks { |
| 481 | 504 | libraries: self.notebooks.clone(), |
| 482 | 505 | open, |
| 506 | changed, | |
| 483 | 507 | }); |
| 484 | 508 | } |
| 485 | 509 | |
| ... | ... | @@ -596,7 +620,7 @@ impl State { |
| 596 | 620 | pub(crate) fn start_search(&mut self) { |
| 597 | 621 | self.search.finding = false; |
| 598 | 622 | if !self.ui.popup_open(results()) { |
| 599 | self.sync_index(true); | |
| 623 | self.sync_index(true, Vec::new()); | |
| 600 | 624 | self.ui.open_popup(results()); |
| 601 | 625 | self.search.open = true; |
| 602 | 626 | } |
crates/snowbound/src/settings.rs+4| ... | ... | @@ -39,6 +39,8 @@ pub struct Toolbar { |
| 39 | 39 | /// Places in OneNote's bullet and numbering libraries picked lately, latest first. |
| 40 | 40 | pub bullets: Vec<usize>, |
| 41 | 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 | } |
| 43 | 45 | |
| 44 | 46 | impl Default for Toolbar { |
| ... | ... | @@ -49,6 +51,7 @@ impl Default for Toolbar { |
| 49 | 51 | font_color: Some(0x0000ff), |
| 50 | 52 | bullets: Vec::new(), |
| 51 | 53 | numbering: Vec::new(), |
| 54 | pen: 0, | |
| 52 | 55 | } |
| 53 | 56 | } |
| 54 | 57 | } |
| ... | ... | @@ -175,6 +178,7 @@ mod tests { |
| 175 | 178 | font_color: Some(0x00ff00), |
| 176 | 179 | bullets: vec![3, 0], |
| 177 | 180 | numbering: vec![16], |
| 181 | pen: 6, | |
| 178 | 182 | }, |
| 179 | 183 | user_name: Some("Snowbound Test".into()), |
| 180 | 184 | color_scheme: ColorScheme::Dark, |
crates/snowbound/src/watch.rs+86-35| ... | ... | @@ -1,8 +1,8 @@ |
| 1 | //! Changes below a notebook folder on this computer, as the system reports them: FSEvents on | |
| 2 | //! macOS, inotify on Linux. A notebook on a share learns of its changes from the server | |
| 3 | //! instead (`notebook::session::Background::smb`). | |
| 1 | //! Changes below a notebook folder, as the system reports them: FSEvents on macOS, inotify on | |
| 2 | //! Linux. A notebook on a share learns of its changes from the server instead | |
| 3 | //! (`notebook::session::Background::smb`). | |
| 4 | 4 | |
| 5 | use std::path::Path; | |
| 5 | use std::path::{Path, PathBuf}; | |
| 6 | 6 | |
| 7 | 7 | /// Reports until dropped. |
| 8 | 8 | pub struct Watch { |
| ... | ... | @@ -10,12 +10,33 @@ pub struct Watch { |
| 10 | 10 | } |
| 11 | 11 | |
| 12 | 12 | /// 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 the | |
| 14 | /// system cannot watch it. | |
| 13 | /// it and `/`-separated, or a folder's path when the system can only say something in it did. | |
| 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. | |
| 15 | 16 | pub fn watch(root: &Path, changed: impl Fn(Vec<String>) + Send + Sync + 'static) -> Option<Watch> { |
| 16 | 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 | 38 | let folder = root.clone(); |
| 18 | let relative = move |paths: Vec<std::path::PathBuf>| { | |
| 39 | let relative = move |paths: Vec<PathBuf>| { | |
| 19 | 40 | changed( |
| 20 | 41 | paths |
| 21 | 42 | .iter() |
| ... | ... | @@ -26,28 +47,34 @@ pub fn watch(root: &Path, changed: impl Fn(Vec<String>) + Send + Sync + 'static) |
| 26 | 47 | .collect(), |
| 27 | 48 | ) |
| 28 | 49 | }; |
| 29 | platform::Stream::start(&root, Box::new(relative)) | |
| 50 | platform::Stream::start(&folders, Box::new(relative)) | |
| 30 | 51 | .inspect_err(|error| eprintln!("{}: changes go unwatched: {error}", root.display())) |
| 31 | 52 | .ok() |
| 32 | 53 | .map(|stream| Watch { _stream: stream }) |
| 33 | 54 | } |
| 34 | 55 | |
| 35 | /// Whether the folder at `path` is on this computer's own disks, where every change to it is | |
| 36 | /// one the system reports, rather than on a network volume. | |
| 56 | /// Whether the folder at `path` is on this computer's own disks rather than on a network | |
| 57 | /// volume. | |
| 37 | 58 | pub fn on_this_computer(path: &Path) -> bool { |
| 59 | statfs(path).is_none_or(|volume| local(&volume)) | |
| 60 | } | |
| 61 | ||
| 62 | fn statfs(path: &Path) -> Option<libc::statfs> { | |
| 38 | 63 | use std::os::unix::ffi::OsStrExt; |
| 39 | let Ok(name) = std::ffi::CString::new(path.as_os_str().as_bytes()) else { | |
| 40 | return true; | |
| 41 | }; | |
| 64 | let name = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?; | |
| 42 | 65 | let mut volume: libc::statfs = unsafe { std::mem::zeroed() }; |
| 43 | if unsafe { libc::statfs(name.as_ptr(), &mut volume) } != 0 { | |
| 44 | return true; | |
| 45 | } | |
| 46 | #[cfg(target_os = "macos")] | |
| 47 | return volume.f_flags & libc::MNT_LOCAL as u32 != 0; | |
| 48 | // NFS, SMB, CIFS, SMB2, FUSE (sshfs and the like), 9P, Ceph, AFS. | |
| 49 | #[cfg(target_os = "linux")] | |
| 50 | return ![ | |
| 66 | (unsafe { libc::statfs(name.as_ptr(), &mut volume) } == 0).then_some(volume) | |
| 67 | } | |
| 68 | ||
| 69 | #[cfg(target_os = "macos")] | |
| 70 | fn local(volume: &libc::statfs) -> bool { | |
| 71 | volume.f_flags & libc::MNT_LOCAL as u32 != 0 | |
| 72 | } | |
| 73 | ||
| 74 | // NFS, SMB, CIFS, SMB2, FUSE (sshfs and the like), 9P, Ceph, AFS. | |
| 75 | #[cfg(target_os = "linux")] | |
| 76 | fn local(volume: &libc::statfs) -> bool { | |
| 77 | ![ | |
| 51 | 78 | 0x6969, |
| 52 | 79 | 0x517b, |
| 53 | 80 | 0xff53_4d42, |
| ... | ... | @@ -57,7 +84,19 @@ pub fn on_this_computer(path: &Path) -> bool { |
| 57 | 84 | 0x00c3_6400, |
| 58 | 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")] | |
| 91 | fn 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")] | |
| 98 | fn smbfs(_: &libc::statfs) -> bool { | |
| 99 | false | |
| 61 | 100 | } |
| 62 | 101 | |
| 63 | 102 | type Changed = Box<dyn Fn(Vec<std::path::PathBuf>) + Send + Sync>; |
| ... | ... | @@ -69,7 +108,7 @@ mod platform { |
| 69 | 108 | ffi::{CStr, c_char, c_void}, |
| 70 | 109 | io, |
| 71 | 110 | os::unix::ffi::OsStrExt, |
| 72 | path::{Path, PathBuf}, | |
| 111 | path::PathBuf, | |
| 73 | 112 | }; |
| 74 | 113 | |
| 75 | 114 | type Stream_ = *mut c_void; |
| ... | ... | @@ -188,19 +227,31 @@ mod platform { |
| 188 | 227 | } |
| 189 | 228 | |
| 190 | 229 | impl Stream { |
| 191 | pub fn start(root: &Path, changed: Changed) -> io::Result<Self> { | |
| 192 | let root = root.as_os_str().as_bytes(); | |
| 230 | pub fn start(folders: &[PathBuf], changed: Changed) -> io::Result<Self> { | |
| 193 | 231 | let flags = NO_DEFER | WATCH_ROOT | if file_events() { FILE_EVENTS } else { 0 }; |
| 194 | 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 | 247 | std::ptr::null(), |
| 197 | root.as_ptr(), | |
| 198 | root.len() as isize, | |
| 199 | UTF8, | |
| 200 | 0, | |
| 248 | folders.as_ptr(), | |
| 249 | folders.len() as isize, | |
| 250 | &kCFTypeArrayCallBacks, | |
| 201 | 251 | ); |
| 202 | let paths = CFArrayCreate(std::ptr::null(), &path, 1, &kCFTypeArrayCallBacks); | |
| 203 | CFRelease(path); | |
| 252 | for folder in folders { | |
| 253 | CFRelease(folder); | |
| 254 | } | |
| 204 | 255 | let context = Context { |
| 205 | 256 | version: 0, |
| 206 | 257 | info: Box::into_raw(Box::new(changed)).cast(), |
| ... | ... | @@ -299,7 +350,7 @@ mod platform { |
| 299 | 350 | } |
| 300 | 351 | |
| 301 | 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 | 354 | let inotify = check(unsafe { libc::inotify_init1(libc::IN_CLOEXEC) })?; |
| 304 | 355 | let stop = match check(unsafe { libc::eventfd(0, libc::EFD_CLOEXEC) }) { |
| 305 | 356 | Ok(stop) => stop, |
| ... | ... | @@ -308,9 +359,9 @@ mod platform { |
| 308 | 359 | return Err(error); |
| 309 | 360 | } |
| 310 | 361 | }; |
| 362 | let root = folders[0].clone(); | |
| 311 | 363 | let mut folders = HashMap::new(); |
| 312 | add(inotify, root, &mut folders); | |
| 313 | let root = root.to_owned(); | |
| 364 | add(inotify, &root, &mut folders); | |
| 314 | 365 | let thread = std::thread::Builder::new() |
| 315 | 366 | .name("snowbound-watch".into()) |
| 316 | 367 | .spawn(move || { |
tools/canvas/build_macos.py+1| ... | ... | @@ -64,6 +64,7 @@ else: |
| 64 | 64 | 'NSHighResolutionCapable': True, |
| 65 | 65 | 'NSPrincipalClass': 'NSApplication', |
| 66 | 66 | 'CFBundleIconFile': 'Snowbound', |
| 67 | 'NSMicrophoneUsageDescription': 'Snowbound records audio into your notes when you choose Record Audio.', | |
| 67 | 68 | } | ({'LSMinimumSystemVersion': '10.6'} if args.snow_leopard else {}))) |
| 68 | 69 | # 10.6 runs the bundle unsigned. |
| 69 | 70 | if not args.snow_leopard: |
tools/test_ink_tools.py created+82| ... | ... | @@ -0,0 +1,82 @@ |
| 1 | from pathlib import Path | |
| 2 | import runpy | |
| 3 | import shutil | |
| 4 | from tempfile import TemporaryDirectory | |
| 5 | import unittest | |
| 6 | import xml.etree.ElementTree as ET | |
| 7 | ||
| 8 | from native_xml import ns | |
| 9 | ||
| 10 | ROOT = Path(__file__).resolve().parent.parent | |
| 11 | FIXTURE = ROOT / 'corpus/ink-tools' | |
| 12 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | |
| 13 | ||
| 14 | ||
| 15 | def 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 | ||
| 24 | def 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 | ||
| 28 | def drawings(page): | |
| 29 | return page.findall('one:InkDrawing', ns) | |
| 30 | ||
| 31 | ||
| 32 | def 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 | ||
| 37 | class 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 | ||
| 81 | if __name__ == '__main__': | |
| 82 | unittest.main() |
tools/test_recording.py created+88| ... | ... | @@ -0,0 +1,88 @@ |
| 1 | from pathlib import Path | |
| 2 | import hashlib | |
| 3 | import json | |
| 4 | import runpy | |
| 5 | import shutil | |
| 6 | from tempfile import TemporaryDirectory | |
| 7 | import unittest | |
| 8 | import xml.etree.ElementTree as ET | |
| 9 | ||
| 10 | from native_xml import ns, texts | |
| 11 | ||
| 12 | ROOT = Path(__file__).resolve().parent.parent | |
| 13 | FIXTURE = ROOT / 'corpus/recording' | |
| 14 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | |
| 15 | ||
| 16 | ||
| 17 | def 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 | ||
| 37 | def 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 | ||
| 45 | class 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 | ||
| 87 | if __name__ == '__main__': | |
| 88 | unittest.main() |
tools/verify-document.py+3| ... | ... | @@ -369,6 +369,9 @@ def compare(notebook, native, versions=None, password_file=None): |
| 369 | 369 | # Stroke coordinates are absolute; left-to-right pages report them |
| 370 | 370 | # relative to the canonical margin origin, right-to-left pages as stored. |
| 371 | 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 | 375 | if not source_page['kind']['rtl']: |
| 373 | 376 | for index, (axis, canonical_origin) in enumerate((('x', 36.0), ('y', 14.4))): |
| 374 | 377 | origin = source_page['kind']['margin_origin_' + axis] |